From 2916bd6ff7ec9f1e136d84323d8880ca009a3f4e Mon Sep 17 00:00:00 2001
From: Hundrec <hundrecard@gmail.com>
Date: Fri, 14 Jun 2024 21:52:52 -0700
Subject: [PATCH 01/29] Reorder download Git step in compiling guide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Moved “download Git” from the middle of the guide to the setup step
Should prevent errors with Git before installing Git
---
 docs/COMPILING.md | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index e7c19875a..b8ddee4a7 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -2,14 +2,14 @@
 
 0. Setup
     - Download Haxe from [Haxe.org](https://haxe.org)
+    - Download Git from [git-scm.com](https://www.git-scm.com)
 1. Cloning the Repository: Make sure when you clone, you clone the submodules to get the assets repo:
     - `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git`
     - If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way.
 2. Install `hmm` (run `haxelib --global install hmm` and then `haxelib --global run hmm setup`)
-3. Download Git from [git-scm.com](https://www.git-scm.com)
-4. Install all haxelibs of the current branch by running `hmm install`
-5. Setup lime: `haxelib run lime setup`
-6. Platform setup
+3. Install all haxelibs of the current branch by running `hmm install`
+4. Setup lime: `haxelib run lime setup`
+5. Platform setup
    - For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe)
         - When prompted, select "Individual Components" and make sure to download the following:
         - MSVC v143 VS 2022 C++ x64/x86 build tools
@@ -17,8 +17,8 @@
     - Mac: [`lime setup mac` Documentation](https://lime.openfl.org/docs/advanced-setup/macos/)
     - Linux: [`lime setup linux` Documentation](https://lime.openfl.org/docs/advanced-setup/linux/)
     - HTML5: Compiles without any extra setup
-7. If you are targeting for native, you may need to run `lime rebuild PLATFORM` and `lime rebuild PLATFORM -debug`
-8. `lime test PLATFORM` ! Add `-debug` to enable several debug features such as time travel (`PgUp`/`PgDn` in Play State).
+6. If you are targeting for native, you may need to run `lime rebuild PLATFORM` and `lime rebuild PLATFORM -debug`
+7. `lime test PLATFORM` ! Add `-debug` to enable several debug features such as time travel (`PgUp`/`PgDn` in Play State).
 
 # Troubleshooting
 

From 9fb4a8719a6cd07aa2af2638adc494786ca5425e Mon Sep 17 00:00:00 2001
From: Hundrec <hundrecard@gmail.com>
Date: Sat, 15 Jun 2024 13:37:21 -0400
Subject: [PATCH 02/29] Add ZIP button warning and restructured sentences

Each step should be easier to follow with this structure
---
 docs/COMPILING.md | 13 +++++++------
 1 file changed, 7 insertions(+), 6 deletions(-)

diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index b8ddee4a7..b2a106c86 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -3,12 +3,13 @@
 0. Setup
     - Download Haxe from [Haxe.org](https://haxe.org)
     - Download Git from [git-scm.com](https://www.git-scm.com)
-1. Cloning the Repository: Make sure when you clone, you clone the submodules to get the assets repo:
-    - `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git`
-    - If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way.
-2. Install `hmm` (run `haxelib --global install hmm` and then `haxelib --global run hmm setup`)
-3. Install all haxelibs of the current branch by running `hmm install`
-4. Setup lime: `haxelib run lime setup`
+    - Do NOT download the repository using the Download ZIP button on GitHub or you may run into errors!
+    - Instead, open a command prompt and do the following steps...
+1. Run `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git` to clone the repository with the necessary assets submodule
+    - _If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way._
+2. Run `haxelib --global install hmm` and then `haxelib --global run hmm setup` to install hmm.json
+3. Run `hmm install` to install all haxelibs of the current branch
+4. Run `haxelib run lime setup` to set up lime
 5. Platform setup
    - For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe)
         - When prompted, select "Individual Components" and make sure to download the following:

From 841a61408c13ff34760a448c588e35bc3b203bf0 Mon Sep 17 00:00:00 2001
From: Eric <ericmyllyoja@gmail.com>
Date: Sun, 16 Jun 2024 18:48:01 -0400
Subject: [PATCH 03/29] Make downloading the assets submodule a separate step.

---
 docs/COMPILING.md | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index b2a106c86..cc90bd348 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -5,8 +5,9 @@
     - Download Git from [git-scm.com](https://www.git-scm.com)
     - Do NOT download the repository using the Download ZIP button on GitHub or you may run into errors!
     - Instead, open a command prompt and do the following steps...
-1. Run `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git` to clone the repository with the necessary assets submodule
-    - _If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way._
+1. Run `git clone https://github.com/FunkinCrew/funkin.git` to clone the base repository.
+2. Run `git submodule update --init --recursive` to download the game's assets.
+    - NOTE: By performing this operation, you are downloading Content which is proprietary and protected by national and international copyright and trademark laws. See [the LICENSE.md file for the Funkin.assets](https://github.com/FunkinCrew/funkin.assets/blob/main/LICENSE.md) repo for more information.
 2. Run `haxelib --global install hmm` and then `haxelib --global run hmm setup` to install hmm.json
 3. Run `hmm install` to install all haxelibs of the current branch
 4. Run `haxelib run lime setup` to set up lime

From 8a4f19d603b5900692c8c646c05e8b409688e963 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 25 Jun 2024 18:22:30 -0400
Subject: [PATCH 04/29] Add change counts labels to Actions labeler

---
 .github/changed-lines-count-labeler.yml | 12 ++++++++++++
 .github/workflows/labeler.yml           | 15 ++++++++++++++-
 2 files changed, 26 insertions(+), 1 deletion(-)
 create mode 100644 .github/changed-lines-count-labeler.yml

diff --git a/.github/changed-lines-count-labeler.yml b/.github/changed-lines-count-labeler.yml
new file mode 100644
index 000000000..6f890f534
--- /dev/null
+++ b/.github/changed-lines-count-labeler.yml
@@ -0,0 +1,12 @@
+# Add 'small' to any changes below 10 lines
+small:
+  max: 9
+
+# Add 'medium' to any changes between 10 and 100 lines
+medium:
+  min: 10
+  max: 99
+
+# Add 'large' to any changes for more than 100 lines
+large:
+  min: 100
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 0bcc420d3..a861af578 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -9,6 +9,19 @@ jobs:
       pull-requests: write
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/labeler@v5
+    - name: Set basic labels
+      uses: actions/labeler@v5
       with:
         sync-labels: true
+  changed-lines-count-labeler:
+    permissions:
+      contents: read
+      pull-requests: write
+    runs-on: ubuntu-latest
+    name: An action for automatically labelling pull requests based on the changed lines count
+    steps:
+    - name: Set change count labels
+      uses: vkirilichev/changed-lines-count-labeler@v0.2
+      with:
+        repo-token: ${{ secrets.GITHUB_TOKEN }}
+        configuration-path: .github/changed-lines-count-labeler.yml

From fbc78adb65c7db8b045f1ea5d6599ae7348912a0 Mon Sep 17 00:00:00 2001
From: FlooferLand! <76737186+FlooferLand@users.noreply.github.com>
Date: Mon, 1 Jul 2024 14:42:50 +0300
Subject: [PATCH 05/29] Added new settings items

---
 source/funkin/ui/options/MenuItemEnums.hx     |  10 ++
 source/funkin/ui/options/PreferencesMenu.hx   | 149 +++++++++++-------
 .../options/items/CheckboxPreferenceItem.hx   |  49 ++++++
 .../ui/options/items/EnumPreferenceItem.hx    |  84 ++++++++++
 .../ui/options/items/NumberPreferenceItem.hx  | 136 ++++++++++++++++
 5 files changed, 372 insertions(+), 56 deletions(-)
 create mode 100644 source/funkin/ui/options/MenuItemEnums.hx
 create mode 100644 source/funkin/ui/options/items/CheckboxPreferenceItem.hx
 create mode 100644 source/funkin/ui/options/items/EnumPreferenceItem.hx
 create mode 100644 source/funkin/ui/options/items/NumberPreferenceItem.hx

diff --git a/source/funkin/ui/options/MenuItemEnums.hx b/source/funkin/ui/options/MenuItemEnums.hx
new file mode 100644
index 000000000..4513a92af
--- /dev/null
+++ b/source/funkin/ui/options/MenuItemEnums.hx
@@ -0,0 +1,10 @@
+package funkin.ui.options;
+
+// Add enums for use with `EnumPreferenceItem` here!
+/* Example:
+  class MyOptionEnum
+  {
+    public static inline var YuhUh = "true";  // "true" is the value's ID
+    public static inline var NuhUh = "false";
+  }
+ */
diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx
index 783aef0ba..5fbefceed 100644
--- a/source/funkin/ui/options/PreferencesMenu.hx
+++ b/source/funkin/ui/options/PreferencesMenu.hx
@@ -8,6 +8,11 @@ import funkin.ui.AtlasText.AtlasFont;
 import funkin.ui.options.OptionsState.Page;
 import funkin.graphics.FunkinCamera;
 import funkin.ui.TextMenuList.TextMenuItem;
+import funkin.audio.FunkinSound;
+import funkin.ui.options.MenuItemEnums;
+import funkin.ui.options.items.CheckboxPreferenceItem;
+import funkin.ui.options.items.NumberPreferenceItem;
+import funkin.ui.options.items.EnumPreferenceItem;
 
 class PreferencesMenu extends Page
 {
@@ -69,11 +74,51 @@ class PreferencesMenu extends Page
     }, Preferences.autoPause);
   }
 
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // Indent the selected item.
+    items.forEach(function(daItem:TextMenuItem) {
+      var thyOffset:Int = 0;
+
+      // Initializing thy text width (if thou text present)
+      var thyTextWidth:Int = 0;
+      if (Std.isOfType(daItem, EnumPreferenceItem)) thyTextWidth = cast(daItem, EnumPreferenceItem).lefthandText.getWidth();
+      else if (Std.isOfType(daItem, NumberPreferenceItem)) thyTextWidth = cast(daItem, NumberPreferenceItem).lefthandText.getWidth();
+
+      if (thyTextWidth != 0)
+      {
+        // Magic number because of the weird offset thats being added by default
+        thyOffset += thyTextWidth - 75;
+      }
+
+      if (items.selectedItem == daItem)
+      {
+        thyOffset += 150;
+      }
+      else
+      {
+        thyOffset += 120;
+      }
+
+      daItem.x = thyOffset;
+    });
+  }
+
+  // - Preference item creation methods -
+  // Should be moved into a separate PreferenceItems class but you can't access PreferencesMenu.items and PreferencesMenu.preferenceItems from outside.
+
+  /**
+   * Creates a pref item that works with booleans
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   */
   function createPrefItemCheckbox(prefName:String, prefDesc:String, onChange:Bool->Void, defaultValue:Bool):Void
   {
     var checkbox:CheckboxPreferenceItem = new CheckboxPreferenceItem(0, 120 * (items.length - 1 + 1), defaultValue);
 
-    items.createItem(120, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() {
+    items.createItem(0, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() {
       var value = !checkbox.currentValue;
       onChange(value);
       checkbox.currentValue = value;
@@ -82,62 +127,54 @@ class PreferencesMenu extends Page
     preferenceItems.add(checkbox);
   }
 
-  override function update(elapsed:Float)
+  /**
+   * Creates a pref item that works with general numbers
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed value looks
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   * @param min Minimum value (example: 0)
+   * @param max Maximum value (example: 10)
+   * @param step The value to increment/decrement by (default = 0.1)
+   * @param precision Rounds decimals up to a `precision` amount of digits (ex: 4 -> 0.1234, 2 -> 0.12)
+   */
+  function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Int, min:Int, max:Int,
+      step:Float = 0.1, precision:Int):Void
   {
-    super.update(elapsed);
+    var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, step, precision, onChange, valueFormatter);
+    items.addItem(prefName, item);
+    preferenceItems.add(item.lefthandText);
+  }
 
-    // Indent the selected item.
-    // TODO: Only do this on menu change?
-    items.forEach(function(daItem:TextMenuItem) {
-      if (items.selectedItem == daItem) daItem.x = 150;
-      else
-        daItem.x = 120;
-    });
-  }
-}
-
-class CheckboxPreferenceItem extends FlxSprite
-{
-  public var currentValue(default, set):Bool;
-
-  public function new(x:Float, y:Float, defaultValue:Bool = false)
-  {
-    super(x, y);
-
-    frames = Paths.getSparrowAtlas('checkboxThingie');
-    animation.addByPrefix('static', 'Check Box unselected', 24, false);
-    animation.addByPrefix('checked', 'Check Box selecting animation', 24, false);
-
-    setGraphicSize(Std.int(width * 0.7));
-    updateHitbox();
-
-    this.currentValue = defaultValue;
-  }
-
-  override function update(elapsed:Float)
-  {
-    super.update(elapsed);
-
-    switch (animation.curAnim.name)
-    {
-      case 'static':
-        offset.set();
-      case 'checked':
-        offset.set(17, 70);
-    }
-  }
-
-  function set_currentValue(value:Bool):Bool
-  {
-    if (value)
-    {
-      animation.play('checked', true);
-    }
-    else
-    {
-      animation.play('static');
-    }
-
-    return currentValue = value;
+  /**
+   * Creates a pref item that works with number percentages
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   * @param min Minimum value (default = 0)
+   * @param max Maximum value (default = 100)
+   */
+  function createPrefItemPercentage(prefName:String, prefDesc:String, onChange:Int->Void, defaultValue:Int, min:Int = 0, max:Int = 100):Void
+  {
+    var newCallback = function(value:Float) {
+      onChange(Std.int(value));
+    };
+    var formatter = function(value:Float) {
+      return '${value}%';
+    };
+    var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, 10, 0, newCallback, formatter);
+    items.addItem(prefName, item);
+    preferenceItems.add(item.lefthandText);
+  }
+
+  /**
+   * Creates a pref item that works with enums
+   * @param values Maps enum values to display strings _(ex: `NoteHitSoundType.PingPong => "Ping pong"`)_
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   */
+  function createPrefItemEnum(prefName:String, prefDesc:String, values:Map<String, String>, onChange:String->Void, defaultValue:String):Void
+  {
+    var item = new EnumPreferenceItem(0, (120 * items.length) + 30, prefName, values, defaultValue, onChange);
+    items.addItem(prefName, item);
+    preferenceItems.add(item.lefthandText);
   }
 }
diff --git a/source/funkin/ui/options/items/CheckboxPreferenceItem.hx b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx
new file mode 100644
index 000000000..88c4fb6b0
--- /dev/null
+++ b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx
@@ -0,0 +1,49 @@
+package funkin.ui.options.items;
+
+import flixel.FlxSprite.FlxSprite;
+
+class CheckboxPreferenceItem extends FlxSprite
+{
+  public var currentValue(default, set):Bool;
+
+  public function new(x:Float, y:Float, defaultValue:Bool = false)
+  {
+    super(x, y);
+
+    frames = Paths.getSparrowAtlas('checkboxThingie');
+    animation.addByPrefix('static', 'Check Box unselected', 24, false);
+    animation.addByPrefix('checked', 'Check Box selecting animation', 24, false);
+
+    setGraphicSize(Std.int(width * 0.7));
+    updateHitbox();
+
+    this.currentValue = defaultValue;
+  }
+
+  override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    switch (animation.curAnim.name)
+    {
+      case 'static':
+        offset.set();
+      case 'checked':
+        offset.set(17, 70);
+    }
+  }
+
+  function set_currentValue(value:Bool):Bool
+  {
+    if (value)
+    {
+      animation.play('checked', true);
+    }
+    else
+    {
+      animation.play('static');
+    }
+
+    return currentValue = value;
+  }
+}
diff --git a/source/funkin/ui/options/items/EnumPreferenceItem.hx b/source/funkin/ui/options/items/EnumPreferenceItem.hx
new file mode 100644
index 000000000..02a273353
--- /dev/null
+++ b/source/funkin/ui/options/items/EnumPreferenceItem.hx
@@ -0,0 +1,84 @@
+package funkin.ui.options.items;
+
+import funkin.ui.TextMenuList;
+import funkin.ui.AtlasText;
+import funkin.input.Controls;
+import funkin.ui.options.MenuItemEnums;
+import haxe.EnumTools;
+
+/**
+ * Preference item that allows the player to pick a value from an enum (list of values)
+ */
+class EnumPreferenceItem extends TextMenuItem
+{
+  function controls():Controls
+  {
+    return PlayerSettings.player1.controls;
+  }
+
+  public var lefthandText:AtlasText;
+
+  public var currentValue:String;
+  public var onChangeCallback:Null<String->Void>;
+  public var map:Map<String, String>;
+  public var keys:Array<String> = [];
+
+  var index = 0;
+
+  public function new(x:Float, y:Float, name:String, map:Map<String, String>, defaultValue:String, ?callback:String->Void)
+  {
+    super(x, y, name, function() {
+      callback(this.currentValue);
+    });
+
+    updateHitbox();
+
+    this.map = map;
+    this.currentValue = defaultValue;
+    this.onChangeCallback = callback;
+
+    var i:Int = 0;
+    for (key in map.keys())
+    {
+      this.keys.push(key);
+      if (this.currentValue == key) index = i;
+      i += 1;
+    }
+
+    lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT);
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // var fancyTextFancyColor:Color;
+    if (selected)
+    {
+      var shouldDecrease:Bool = controls().UI_LEFT_P;
+      var shouldIncrease:Bool = controls().UI_RIGHT_P;
+
+      if (shouldDecrease) index -= 1;
+      if (shouldIncrease) index += 1;
+
+      if (index > keys.length - 1) index = 0;
+      if (index < 0) index = keys.length - 1;
+
+      currentValue = keys[index];
+      if (onChangeCallback != null && (shouldIncrease || shouldDecrease))
+      {
+        onChangeCallback(currentValue);
+      }
+    }
+
+    lefthandText.text = formatted(currentValue);
+  }
+
+  function formatted(value:String):String
+  {
+    // FIXME: Can't add arrows around the text because the font doesn't support < >
+    // var leftArrow:String = selected ? '<' : '';
+    // var rightArrow:String = selected ? '>' : '';
+    return '${map.get(value) ?? value}';
+  }
+}
diff --git a/source/funkin/ui/options/items/NumberPreferenceItem.hx b/source/funkin/ui/options/items/NumberPreferenceItem.hx
new file mode 100644
index 000000000..f3cd3cd46
--- /dev/null
+++ b/source/funkin/ui/options/items/NumberPreferenceItem.hx
@@ -0,0 +1,136 @@
+package funkin.ui.options.items;
+
+import funkin.ui.TextMenuList;
+import funkin.ui.AtlasText;
+import funkin.input.Controls;
+
+/**
+ * Preference item that allows the player to pick a value between min and max
+ */
+class NumberPreferenceItem extends TextMenuItem
+{
+  function controls():Controls
+  {
+    return PlayerSettings.player1.controls;
+  }
+
+  // Widgets
+  public var lefthandText:AtlasText;
+
+  // Constants
+  static final HOLD_DELAY:Float = 0.3; // seconds
+  static final CHANGE_RATE:Float = 0.08; // seconds
+
+  // Constructor-initialized variables
+  public var currentValue:Float;
+  public var min:Float;
+  public var max:Float;
+  public var step:Float;
+  public var precision:Int;
+  public var onChangeCallback:Null<Float->Void>;
+  public var valueFormatter:Null<Float->String>;
+
+  // Variables
+  var holdDelayTimer:Float = HOLD_DELAY; // seconds
+  var changeRateTimer:Float = 0.0; // seconds
+
+  /**
+   * @param min Minimum value (example: 0)
+   * @param max Maximum value (example: 100)
+   * @param step The value to increment/decrement by (example: 10)
+   * @param callback Will get called every time the user changes the setting; use this to apply/save the setting.
+   * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed string looks
+   */
+  public function new(x:Float, y:Float, name:String, defaultValue:Float, min:Float, max:Float, step:Float, precision:Int, ?callback:Float->Void,
+      ?valueFormatter:Float->String):Void
+  {
+    super(x, y, name, function() {
+      callback(this.currentValue);
+    });
+    lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT);
+
+    updateHitbox();
+
+    this.currentValue = defaultValue;
+    this.min = min;
+    this.max = max;
+    this.step = step;
+    this.precision = precision;
+    this.onChangeCallback = callback;
+    this.valueFormatter = valueFormatter;
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // var fancyTextFancyColor:Color;
+    if (selected)
+    {
+      holdDelayTimer -= elapsed;
+      if (holdDelayTimer <= 0.0)
+      {
+        changeRateTimer -= elapsed;
+      }
+
+      var jpLeft:Bool = controls().UI_LEFT_P;
+      var jpRight:Bool = controls().UI_RIGHT_P;
+
+      if (jpLeft || jpRight)
+      {
+        holdDelayTimer = HOLD_DELAY;
+        changeRateTimer = 0.0;
+      }
+
+      var shouldDecrease:Bool = jpLeft;
+      var shouldIncrease:Bool = jpRight;
+
+      if (controls().UI_LEFT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0)
+      {
+        shouldDecrease = true;
+        changeRateTimer = CHANGE_RATE;
+      }
+      else if (controls().UI_RIGHT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0)
+      {
+        shouldIncrease = true;
+        changeRateTimer = CHANGE_RATE;
+      }
+
+      // Actually increasing/decreasing the value
+      if (shouldDecrease)
+      {
+        var isBelowMin:Bool = currentValue - step < min;
+        currentValue = (currentValue - step).clamp(min, max);
+        if (onChangeCallback != null && !isBelowMin) onChangeCallback(currentValue);
+      }
+      else if (shouldIncrease)
+      {
+        var isAboveMax:Bool = currentValue + step > max;
+        currentValue = (currentValue + step).clamp(min, max);
+        if (onChangeCallback != null && !isAboveMax) onChangeCallback(currentValue);
+      }
+    }
+
+    lefthandText.text = formatted(currentValue);
+  }
+
+  /** Turns the float into a string */
+  function formatted(value:Float):String
+  {
+    var float:Float = toFixed(value);
+    if (valueFormatter != null)
+    {
+      return valueFormatter(float);
+    }
+    else
+    {
+      return '${float}';
+    }
+  }
+
+  function toFixed(value:Float):Float
+  {
+    var multiplier:Float = Math.pow(10, precision);
+    return Math.floor(value * multiplier) / multiplier;
+  }
+}

From cd08116aed64f4761087e287a00786b77f40d02b Mon Sep 17 00:00:00 2001
From: FlooferLand! <76737186+FlooferLand@users.noreply.github.com>
Date: Mon, 1 Jul 2024 14:44:23 +0300
Subject: [PATCH 06/29] Added getWidth

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

diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx
index 186d87c2a..ef74abc1e 100644
--- a/source/funkin/ui/AtlasText.hx
+++ b/source/funkin/ui/AtlasText.hx
@@ -152,6 +152,32 @@ class AtlasText extends FlxTypedSpriteGroup<AtlasChar>
     }
   }
 
+  public function getWidth():Int
+  {
+    var width = 0;
+    for (char in this.text.split(""))
+    {
+      switch (char)
+      {
+        case " ":
+          {
+            width += 40;
+          }
+        case "\n":
+          {}
+        case char:
+          {
+            var sprite = new AtlasChar(atlas, char);
+            sprite.revive();
+            sprite.char = char;
+            sprite.alpha = 1;
+            width += Std.int(sprite.width);
+          }
+      }
+    }
+    return width;
+  }
+
   override function toString()
   {
     return "InputItem, " + FlxStringUtil.getDebugString([

From 4044a7d9360a391c59878bbca65ab65e5b69e16d Mon Sep 17 00:00:00 2001
From: Hundrec <hundrecard@gmail.com>
Date: Fri, 14 Jun 2024 21:52:52 -0700
Subject: [PATCH 07/29] Reorder download Git step in compiling guide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Moved “download Git” from the middle of the guide to the setup step
Should prevent errors with Git before installing Git
---
 docs/COMPILING.md | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index e7c19875a..b8ddee4a7 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -2,14 +2,14 @@
 
 0. Setup
     - Download Haxe from [Haxe.org](https://haxe.org)
+    - Download Git from [git-scm.com](https://www.git-scm.com)
 1. Cloning the Repository: Make sure when you clone, you clone the submodules to get the assets repo:
     - `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git`
     - If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way.
 2. Install `hmm` (run `haxelib --global install hmm` and then `haxelib --global run hmm setup`)
-3. Download Git from [git-scm.com](https://www.git-scm.com)
-4. Install all haxelibs of the current branch by running `hmm install`
-5. Setup lime: `haxelib run lime setup`
-6. Platform setup
+3. Install all haxelibs of the current branch by running `hmm install`
+4. Setup lime: `haxelib run lime setup`
+5. Platform setup
    - For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe)
         - When prompted, select "Individual Components" and make sure to download the following:
         - MSVC v143 VS 2022 C++ x64/x86 build tools
@@ -17,8 +17,8 @@
     - Mac: [`lime setup mac` Documentation](https://lime.openfl.org/docs/advanced-setup/macos/)
     - Linux: [`lime setup linux` Documentation](https://lime.openfl.org/docs/advanced-setup/linux/)
     - HTML5: Compiles without any extra setup
-7. If you are targeting for native, you may need to run `lime rebuild PLATFORM` and `lime rebuild PLATFORM -debug`
-8. `lime test PLATFORM` ! Add `-debug` to enable several debug features such as time travel (`PgUp`/`PgDn` in Play State).
+6. If you are targeting for native, you may need to run `lime rebuild PLATFORM` and `lime rebuild PLATFORM -debug`
+7. `lime test PLATFORM` ! Add `-debug` to enable several debug features such as time travel (`PgUp`/`PgDn` in Play State).
 
 # Troubleshooting
 

From 562136ed554d73e04a015c32f9cb34b790b9cc07 Mon Sep 17 00:00:00 2001
From: Hundrec <hundrecard@gmail.com>
Date: Sat, 15 Jun 2024 13:37:21 -0400
Subject: [PATCH 08/29] Add ZIP button warning and restructured sentences

Each step should be easier to follow with this structure
---
 docs/COMPILING.md | 13 +++++++------
 1 file changed, 7 insertions(+), 6 deletions(-)

diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index b8ddee4a7..b2a106c86 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -3,12 +3,13 @@
 0. Setup
     - Download Haxe from [Haxe.org](https://haxe.org)
     - Download Git from [git-scm.com](https://www.git-scm.com)
-1. Cloning the Repository: Make sure when you clone, you clone the submodules to get the assets repo:
-    - `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git`
-    - If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way.
-2. Install `hmm` (run `haxelib --global install hmm` and then `haxelib --global run hmm setup`)
-3. Install all haxelibs of the current branch by running `hmm install`
-4. Setup lime: `haxelib run lime setup`
+    - Do NOT download the repository using the Download ZIP button on GitHub or you may run into errors!
+    - Instead, open a command prompt and do the following steps...
+1. Run `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git` to clone the repository with the necessary assets submodule
+    - _If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way._
+2. Run `haxelib --global install hmm` and then `haxelib --global run hmm setup` to install hmm.json
+3. Run `hmm install` to install all haxelibs of the current branch
+4. Run `haxelib run lime setup` to set up lime
 5. Platform setup
    - For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe)
         - When prompted, select "Individual Components" and make sure to download the following:

From 6e65996180de85c42b74f6730773975f819e2efa Mon Sep 17 00:00:00 2001
From: Eric <ericmyllyoja@gmail.com>
Date: Sun, 16 Jun 2024 18:48:01 -0400
Subject: [PATCH 09/29] Make downloading the assets submodule a separate step.

---
 docs/COMPILING.md | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index b2a106c86..cc90bd348 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -5,8 +5,9 @@
     - Download Git from [git-scm.com](https://www.git-scm.com)
     - Do NOT download the repository using the Download ZIP button on GitHub or you may run into errors!
     - Instead, open a command prompt and do the following steps...
-1. Run `git clone --recurse-submodules https://github.com/FunkinCrew/funkin.git` to clone the repository with the necessary assets submodule
-    - _If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way._
+1. Run `git clone https://github.com/FunkinCrew/funkin.git` to clone the base repository.
+2. Run `git submodule update --init --recursive` to download the game's assets.
+    - NOTE: By performing this operation, you are downloading Content which is proprietary and protected by national and international copyright and trademark laws. See [the LICENSE.md file for the Funkin.assets](https://github.com/FunkinCrew/funkin.assets/blob/main/LICENSE.md) repo for more information.
 2. Run `haxelib --global install hmm` and then `haxelib --global run hmm setup` to install hmm.json
 3. Run `hmm install` to install all haxelibs of the current branch
 4. Run `haxelib run lime setup` to set up lime

From 3f63bc35a4f27a51f07a5c1ac8053de2512d1757 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 25 Jun 2024 18:22:30 -0400
Subject: [PATCH 10/29] Add change counts labels to Actions labeler

---
 .github/changed-lines-count-labeler.yml | 12 ++++++++++++
 .github/workflows/labeler.yml           | 15 ++++++++++++++-
 2 files changed, 26 insertions(+), 1 deletion(-)
 create mode 100644 .github/changed-lines-count-labeler.yml

diff --git a/.github/changed-lines-count-labeler.yml b/.github/changed-lines-count-labeler.yml
new file mode 100644
index 000000000..6f890f534
--- /dev/null
+++ b/.github/changed-lines-count-labeler.yml
@@ -0,0 +1,12 @@
+# Add 'small' to any changes below 10 lines
+small:
+  max: 9
+
+# Add 'medium' to any changes between 10 and 100 lines
+medium:
+  min: 10
+  max: 99
+
+# Add 'large' to any changes for more than 100 lines
+large:
+  min: 100
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 0bcc420d3..a861af578 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -9,6 +9,19 @@ jobs:
       pull-requests: write
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/labeler@v5
+    - name: Set basic labels
+      uses: actions/labeler@v5
       with:
         sync-labels: true
+  changed-lines-count-labeler:
+    permissions:
+      contents: read
+      pull-requests: write
+    runs-on: ubuntu-latest
+    name: An action for automatically labelling pull requests based on the changed lines count
+    steps:
+    - name: Set change count labels
+      uses: vkirilichev/changed-lines-count-labeler@v0.2
+      with:
+        repo-token: ${{ secrets.GITHUB_TOKEN }}
+        configuration-path: .github/changed-lines-count-labeler.yml

From 7b9e4a054284dbe6e69c1659e04571247276a599 Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Wed, 10 Jul 2024 00:23:06 +0200
Subject: [PATCH 11/29] Fix F5 chart not reloading

---
 source/funkin/play/PlayState.hx               | 59 ++-----------------
 source/funkin/ui/MusicBeatState.hx            |  7 +--
 source/funkin/ui/MusicBeatSubState.hx         |  5 +-
 .../util/plugins/ReloadAssetsDebugPlugin.hx   | 14 ++++-
 4 files changed, 17 insertions(+), 68 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a4723611e..86e8e0241 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1347,64 +1347,13 @@ class PlayState extends MusicBeatSubState
   }
 
   /**
-   * 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
+  override function reloadAssets():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)
-    {
-      remove(currentStage);
-      var event:ScriptEvent = new ScriptEvent(DESTROY, false);
-      ScriptEventDispatcher.callEvent(currentStage, event);
-      currentStage = null;
-    }
-
-    if (!overrideMusic)
-    {
-      // Stop the instrumental.
-      if (FlxG.sound.music != null)
-      {
-        FlxG.sound.music.destroy();
-        FlxG.sound.music = null;
-      }
-
-      // Stop the vocals.
-      if (vocals != null && vocals.exists)
-      {
-        vocals.destroy();
-        vocals = null;
-      }
-    }
-    else
-    {
-      // Stop the instrumental.
-      if (FlxG.sound.music != null)
-      {
-        FlxG.sound.music.stop();
-      }
-
-      // Stop the vocals.
-      if (vocals != null && vocals.exists)
-      {
-        vocals.stop();
-      }
-    }
-
-    super.debug_refreshModules();
-
-    var event:ScriptEvent = new ScriptEvent(CREATE, false);
-    ScriptEventDispatcher.callEvent(currentSong, event);
+    funkin.modding.PolymodHandler.forceReloadAssets();
+    lastParams.targetSong = SongRegistry.instance.fetchEntry(currentSong.id);
+    LoadingState.loadPlayState(lastParams);
   }
 
   override function stepHit():Bool
diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx
index 92169df75..8668b64c1 100644
--- a/source/funkin/ui/MusicBeatState.hx
+++ b/source/funkin/ui/MusicBeatState.hx
@@ -78,9 +78,6 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
   {
     // Emergency exit button.
     if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
-
-    // This can now be used in EVERY STATE YAY!
-    if (FlxG.keys.justPressed.F5) debug_refreshModules();
   }
 
   override function update(elapsed:Float)
@@ -114,12 +111,10 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
     ModuleHandler.callEvent(event);
   }
 
-  function debug_refreshModules()
+  function reloadAssets()
   {
     PolymodHandler.forceReloadAssets();
 
-    this.destroy();
-
     // Create a new instance of the current state, so old data is cleared.
     FlxG.resetState();
   }
diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx
index 9035d12ff..5c40b37bc 100644
--- a/source/funkin/ui/MusicBeatSubState.hx
+++ b/source/funkin/ui/MusicBeatSubState.hx
@@ -72,9 +72,6 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler
     // Emergency exit button.
     if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
 
-    // This can now be used in EVERY STATE YAY!
-    if (FlxG.keys.justPressed.F5) debug_refreshModules();
-
     // Display Conductor info in the watch window.
     FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0);
     Conductor.watchQuick(conductorInUse);
@@ -82,7 +79,7 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler
     dispatchEvent(new UpdateScriptEvent(elapsed));
   }
 
-  function debug_refreshModules()
+  function reloadAssets()
   {
     PolymodHandler.forceReloadAssets();
 
diff --git a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx
index f69609531..0e1e238ac 100644
--- a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx
+++ b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx
@@ -1,6 +1,9 @@
 package funkin.util.plugins;
 
+import flixel.FlxG;
 import flixel.FlxBasic;
+import funkin.ui.MusicBeatState;
+import funkin.ui.MusicBeatSubState;
 
 /**
  * A plugin which adds functionality to press `F5` to reload all game assets, then reload the current state.
@@ -28,10 +31,15 @@ class ReloadAssetsDebugPlugin extends FlxBasic
     if (FlxG.keys.justPressed.F5)
     #end
     {
-      funkin.modding.PolymodHandler.forceReloadAssets();
+      var state:Dynamic = FlxG.state;
+      if (state is MusicBeatState || state is MusicBeatSubState) state.reloadAssets();
+      else
+      {
+        funkin.modding.PolymodHandler.forceReloadAssets();
 
-      // Create a new instance of the current state, so old data is cleared.
-      FlxG.resetState();
+        // Create a new instance of the current state, so old data is cleared.
+        FlxG.resetState();
+      }
     }
   }
 

From 18b795d4f74e81e8f9bbabd3e3e3e0fe7930a167 Mon Sep 17 00:00:00 2001
From: anysad <anysadiscool@gmail.com>
Date: Thu, 11 Jul 2024 18:10:45 +0300
Subject: [PATCH 12/29] Add HEY! song events to Tutorial

---
 source/funkin/play/PlayState.hx | 12 +-----------
 1 file changed, 1 insertion(+), 11 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 8d7d82aab..216acca71 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1502,7 +1502,7 @@ class PlayState extends MusicBeatSubState
     if (opponentStrumline != null) opponentStrumline.onBeatHit();
 
     // Make the characters dance on the beat
-    danceOnBeat();
+    //danceOnBeat();
 
     return true;
   }
@@ -1522,16 +1522,6 @@ class PlayState extends MusicBeatSubState
   function danceOnBeat():Void
   {
     if (currentStage == null) return;
-
-    // TODO: Add HEY! song events to Tutorial.
-    if (Conductor.instance.currentBeat % 16 == 15
-      && currentStage.getDad().characterId == 'gf'
-      && Conductor.instance.currentBeat > 16
-      && Conductor.instance.currentBeat < 48)
-    {
-      currentStage.getBoyfriend().playAnimation('hey', true);
-      currentStage.getDad().playAnimation('cheer', true);
-    }
   }
 
   /**

From 305ab3146fe8891e925d34f5f2aafd9006ce1288 Mon Sep 17 00:00:00 2001
From: anysad <anysadiscool@gmail.com>
Date: Thu, 11 Jul 2024 18:12:50 +0300
Subject: [PATCH 13/29] Add HEY! song events to Tutorial

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

diff --git a/assets b/assets
index 005c96f85..81e61c287 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 005c96f85f4304865acb196e7cc4d6d83f9d76d8
+Subproject commit 81e61c287670f6aa8b7faf2c27561df57361f1ad

From 4a7545a0dbdf7e8e1f1acca59940c87d59151646 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 11 Jul 2024 21:45:06 -0400
Subject: [PATCH 14/29] Remove empty function.

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

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 216acca71..309c21438 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1501,9 +1501,6 @@ class PlayState extends MusicBeatSubState
     if (playerStrumline != null) playerStrumline.onBeatHit();
     if (opponentStrumline != null) opponentStrumline.onBeatHit();
 
-    // Make the characters dance on the beat
-    //danceOnBeat();
-
     return true;
   }
 
@@ -1514,16 +1511,6 @@ class PlayState extends MusicBeatSubState
     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;
-  }
-
   /**
    * Initializes the game and HUD cameras.
    */

From 90155bcfbfa86561571b7c556b1f63486c18877d Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Wed, 10 Jul 2024 01:26:16 +0200
Subject: [PATCH 15/29] ChartEditor Live Input Code Refactor + 6 key fix

---
 .../ui/debug/charting/ChartEditorState.hx     | 57 ++++++-------------
 1 file changed, 18 insertions(+), 39 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index f72cca77f..5e7493840 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -282,6 +282,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   public static final WELCOME_MUSIC_FADE_IN_DURATION:Float = 10.0;
 
+  /**
+   * A map of the keys for every live input style.
+   */
+  public static final LIVE_INPUT_KEYS:Map<ChartEditorLiveInputStyle, Array<FlxKey>> = [
+    NumberKeys => [
+      FIVE, SIX, SEVEN, EIGHT,
+       ONE, TWO, THREE,  FOUR
+    ],
+    WASDKeys => [
+      LEFT, DOWN, UP, RIGHT,
+         A,    S,  W,     D
+    ],
+    None => []
+  ];
+
   /**
    * INSTANCE DATA
    */
@@ -5129,46 +5144,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   function handlePlayhead():Void
   {
     // Place notes at the playhead with the keyboard.
-    switch (currentLiveInputStyle)
+    for (note => key in LIVE_INPUT_KEYS[currentLiveInputStyle])
     {
-      case ChartEditorLiveInputStyle.WASDKeys:
-        if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4);
-        if (FlxG.keys.justReleased.A) finishPlaceNoteAtPlayhead(4);
-        if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5);
-        if (FlxG.keys.justReleased.S) finishPlaceNoteAtPlayhead(5);
-        if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6);
-        if (FlxG.keys.justReleased.W) finishPlaceNoteAtPlayhead(6);
-        if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7);
-        if (FlxG.keys.justReleased.D) finishPlaceNoteAtPlayhead(7);
-
-        if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0);
-        if (FlxG.keys.justReleased.LEFT) finishPlaceNoteAtPlayhead(0);
-        if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1);
-        if (FlxG.keys.justReleased.DOWN) finishPlaceNoteAtPlayhead(1);
-        if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2);
-        if (FlxG.keys.justReleased.UP) finishPlaceNoteAtPlayhead(2);
-        if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3);
-        if (FlxG.keys.justReleased.RIGHT) finishPlaceNoteAtPlayhead(3);
-      case ChartEditorLiveInputStyle.NumberKeys:
-        // Flipped because Dad is on the left but represents data 0-3.
-        if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4);
-        if (FlxG.keys.justReleased.ONE) finishPlaceNoteAtPlayhead(4);
-        if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5);
-        if (FlxG.keys.justReleased.TWO) finishPlaceNoteAtPlayhead(5);
-        if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6);
-        if (FlxG.keys.justReleased.THREE) finishPlaceNoteAtPlayhead(6);
-        if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7);
-        if (FlxG.keys.justReleased.FOUR) finishPlaceNoteAtPlayhead(7);
-
-        if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0);
-        if (FlxG.keys.justReleased.FIVE) finishPlaceNoteAtPlayhead(0);
-        if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1);
-        if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2);
-        if (FlxG.keys.justReleased.SEVEN) finishPlaceNoteAtPlayhead(2);
-        if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3);
-        if (FlxG.keys.justReleased.EIGHT) finishPlaceNoteAtPlayhead(3);
-      case ChartEditorLiveInputStyle.None:
-        // Do nothing.
+      if (FlxG.keys.checkStatus(key, JUST_PRESSED)) placeNoteAtPlayhead(note)
+      else if (FlxG.keys.checkStatus(key, JUST_RELEASED)) finishPlaceNoteAtPlayhead(note);
     }
 
     // Place events at playhead.

From 17f5a06256b34ff5224fde5b893cdac7c5086a5f Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Mon, 20 May 2024 02:56:57 +0200
Subject: [PATCH 16/29] Add camOther to fix zooms on pause and stickers

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

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 8d7d82aab..e87fde90c 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -503,9 +503,9 @@ class PlayState extends MusicBeatSubState
   public var camGame:FlxCamera;
 
   /**
-   * The camera which contains, and controls visibility of, a video cutscene.
+   * The camera which contains, and controls visibility of, a video cutscene, dialogue, pause menu and sticker transition.
    */
-  public var camCutscene:FlxCamera;
+  public var camOther:FlxCamera;
 
   /**
    * The combo popups. Includes the real-time combo counter and the rating.
@@ -975,7 +975,7 @@ class PlayState extends MusicBeatSubState
 
           FlxTransitionableState.skipNextTransIn = true;
           FlxTransitionableState.skipNextTransOut = true;
-          pauseSubState.camera = camHUD;
+          pauseSubState.camera = camOther;
           openSubState(pauseSubState);
           // boyfriendPos.put(); // TODO: Why is this here?
         }
@@ -1543,12 +1543,12 @@ class PlayState extends MusicBeatSubState
     camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage.
     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.
+    camOther = new FlxCamera();
+    camOther.bgColor.alpha = 0; // Show the game scene behind the camera.
 
     FlxG.cameras.reset(camGame);
     FlxG.cameras.add(camHUD, false);
-    FlxG.cameras.add(camCutscene, false);
+    FlxG.cameras.add(camOther, false);
 
     // Configure camera follow point.
     if (previousCameraFollowPoint != null)
@@ -1934,7 +1934,6 @@ class PlayState extends MusicBeatSubState
     if (!result) return;
 
     isInCutscene = false;
-    camCutscene.visible = false;
 
     // TODO: Maybe tween in the camera after any cutscenes.
     camHUD.visible = true;
@@ -1953,7 +1952,7 @@ class PlayState extends MusicBeatSubState
     if (!currentConversation.alive) currentConversation.revive();
 
     currentConversation.completeCallback = onConversationComplete;
-    currentConversation.cameras = [camCutscene];
+    currentConversation.cameras = [camOther];
     currentConversation.zIndex = 1000;
     add(currentConversation);
     refresh();
@@ -2788,7 +2787,7 @@ class PlayState extends MusicBeatSubState
         persistentUpdate = false;
         FlxTransitionableState.skipNextTransIn = true;
         FlxTransitionableState.skipNextTransOut = true;
-        pauseSubState.camera = camCutscene;
+        pauseSubState.camera = camOther;
         openSubState(pauseSubState);
       }
     }
@@ -2804,7 +2803,7 @@ class PlayState extends MusicBeatSubState
         persistentUpdate = false;
         FlxTransitionableState.skipNextTransIn = true;
         FlxTransitionableState.skipNextTransOut = true;
-        pauseSubState.camera = camCutscene;
+        pauseSubState.camera = camOther;
         openSubState(pauseSubState);
       }
     }

From e23e6c160d9ed9243a380c4b6842a059a3954768 Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Mon, 20 May 2024 23:52:45 +0200
Subject: [PATCH 17/29] Fix references to camCutscene

---
 source/funkin/play/cutscene/VideoCutscene.hx | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index abbcd4f54..7dac97885 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -81,12 +81,11 @@ class VideoCutscene
     // Trigger the cutscene. Don't play the song in the background.
     PlayState.instance.isInCutscene = true;
     PlayState.instance.camHUD.visible = false;
-    PlayState.instance.camCutscene.visible = true;
 
     // Display a black screen to hide the game while the video is playing.
     blackScreen = new FlxSprite(-200, -200).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
     blackScreen.scrollFactor.set(0, 0);
-    blackScreen.cameras = [PlayState.instance.camCutscene];
+    blackScreen.cameras = [PlayState.instance.camOther];
     PlayState.instance.add(blackScreen);
 
     VideoCutscene.cutsceneType = cutsceneType;
@@ -120,7 +119,7 @@ class VideoCutscene
 
       vid.finishCallback = finishVideo.bind(0.5);
 
-      vid.cameras = [PlayState.instance.camCutscene];
+      vid.cameras = [PlayState.instance.camOther];
 
       PlayState.instance.add(vid);
 
@@ -147,7 +146,7 @@ class VideoCutscene
       vid.bitmap.onEndReached.add(finishVideo.bind(0.5));
       vid.autoPause = FlxG.autoPause;
 
-      vid.cameras = [PlayState.instance.camCutscene];
+      vid.cameras = [PlayState.instance.camOther];
 
       PlayState.instance.add(vid);
 
@@ -305,7 +304,6 @@ class VideoCutscene
     vid = null;
     #end
 
-    PlayState.instance.camCutscene.visible = true;
     PlayState.instance.camHUD.visible = true;
 
     FlxTween.tween(blackScreen, {alpha: 0}, transitionTime,

From e6c97678002e1b4ba823b71471ec0d6052d96c0a Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Wed, 12 Jun 2024 00:34:04 +0200
Subject: [PATCH 18/29] Revert camCutscene rename

---
 source/funkin/play/PlayState.hx              | 16 ++++++++--------
 source/funkin/play/cutscene/VideoCutscene.hx |  6 +++---
 2 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index e87fde90c..0bb57b8cb 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -505,7 +505,7 @@ class PlayState extends MusicBeatSubState
   /**
    * The camera which contains, and controls visibility of, a video cutscene, dialogue, pause menu and sticker transition.
    */
-  public var camOther:FlxCamera;
+  public var camCutscene:FlxCamera;
 
   /**
    * The combo popups. Includes the real-time combo counter and the rating.
@@ -975,7 +975,7 @@ class PlayState extends MusicBeatSubState
 
           FlxTransitionableState.skipNextTransIn = true;
           FlxTransitionableState.skipNextTransOut = true;
-          pauseSubState.camera = camOther;
+          pauseSubState.camera = camCutscene;
           openSubState(pauseSubState);
           // boyfriendPos.put(); // TODO: Why is this here?
         }
@@ -1543,12 +1543,12 @@ class PlayState extends MusicBeatSubState
     camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage.
     camHUD = new FlxCamera();
     camHUD.bgColor.alpha = 0; // Show the game scene behind the camera.
-    camOther = new FlxCamera();
-    camOther.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(camOther, false);
+    FlxG.cameras.add(camCutscene, false);
 
     // Configure camera follow point.
     if (previousCameraFollowPoint != null)
@@ -1952,7 +1952,7 @@ class PlayState extends MusicBeatSubState
     if (!currentConversation.alive) currentConversation.revive();
 
     currentConversation.completeCallback = onConversationComplete;
-    currentConversation.cameras = [camOther];
+    currentConversation.cameras = [camCutscene];
     currentConversation.zIndex = 1000;
     add(currentConversation);
     refresh();
@@ -2787,7 +2787,7 @@ class PlayState extends MusicBeatSubState
         persistentUpdate = false;
         FlxTransitionableState.skipNextTransIn = true;
         FlxTransitionableState.skipNextTransOut = true;
-        pauseSubState.camera = camOther;
+        pauseSubState.camera = camCutscene;
         openSubState(pauseSubState);
       }
     }
@@ -2803,7 +2803,7 @@ class PlayState extends MusicBeatSubState
         persistentUpdate = false;
         FlxTransitionableState.skipNextTransIn = true;
         FlxTransitionableState.skipNextTransOut = true;
-        pauseSubState.camera = camOther;
+        pauseSubState.camera = camCutscene;
         openSubState(pauseSubState);
       }
     }
diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index 7dac97885..60454b881 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -85,7 +85,7 @@ class VideoCutscene
     // Display a black screen to hide the game while the video is playing.
     blackScreen = new FlxSprite(-200, -200).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
     blackScreen.scrollFactor.set(0, 0);
-    blackScreen.cameras = [PlayState.instance.camOther];
+    blackScreen.cameras = [PlayState.instance.camCutscene];
     PlayState.instance.add(blackScreen);
 
     VideoCutscene.cutsceneType = cutsceneType;
@@ -119,7 +119,7 @@ class VideoCutscene
 
       vid.finishCallback = finishVideo.bind(0.5);
 
-      vid.cameras = [PlayState.instance.camOther];
+      vid.cameras = [PlayState.instance.camCutscene];
 
       PlayState.instance.add(vid);
 
@@ -146,7 +146,7 @@ class VideoCutscene
       vid.bitmap.onEndReached.add(finishVideo.bind(0.5));
       vid.autoPause = FlxG.autoPause;
 
-      vid.cameras = [PlayState.instance.camOther];
+      vid.cameras = [PlayState.instance.camCutscene];
 
       PlayState.instance.add(vid);
 

From 558ec535320cf87c1ad4e31f3f611758c93a74a8 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 18 Jul 2024 23:27:12 -0400
Subject: [PATCH 19/29] Switch songs with no difficulties from an error to a
 warning.

---
 source/funkin/play/song/Song.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 91d35d8fa..147923add 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -277,7 +277,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
       // If there are no difficulties in the metadata, there's a problem.
       if (metadata.playData.difficulties.length == 0)
       {
-        throw 'Song $id has no difficulties listed in metadata!';
+        trace('[WARN] Song $id has no difficulties listed in metadata!');
       }
 
       // There may be more difficulties in the chart file than in the metadata,

From 729745899ede349dad49d58309e84b72b6126192 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 18 Jul 2024 23:27:24 -0400
Subject: [PATCH 20/29] Blacklist haxe.Unserializer in scripts.

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

diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index c352aa606..c5dfcdca3 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -251,6 +251,10 @@ class PolymodHandler
     // Lib.load() can load malicious DLLs
     Polymod.blacklistImport('cpp.Lib');
 
+    // `Unserializer`
+    // Unserializerr.DEFAULT_RESOLVER.resolveClass() can access blacklisted packages
+    Polymod.blacklistImport('Unserializer');
+
     // `polymod.*`
     // You can probably unblacklist a module
     for (cls in ClassMacro.listClassesInPackage('polymod'))

From 754787553593fe42a2479db09db728192673603d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 18 Jul 2024 23:27:41 -0400
Subject: [PATCH 21/29] Allow hiding HUD on launcher builds.

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

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 8d7d82aab..a39f10d97 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -578,7 +578,6 @@ class PlayState extends MusicBeatSubState
 
   // TODO: Refactor or document
   var generatedMusic:Bool = false;
-  var perfectMode:Bool = false;
 
   static final BACKGROUND_COLOR:FlxColor = FlxColor.BLACK;
 
@@ -2610,12 +2609,6 @@ class PlayState extends MusicBeatSubState
    */
   function debugKeyShit():Void
   {
-    #if !debug
-    perfectMode = false;
-    #else
-    if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
-    #end
-
     #if CHART_EDITOR_SUPPORTED
     // Open the stage editor overlaying the current state.
     if (controls.DEBUG_STAGE)
@@ -2647,6 +2640,9 @@ class PlayState extends MusicBeatSubState
     #end
 
     #if (debug || FORCE_DEBUG_VERSION)
+    // H: Hide the HUD.
+    if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
+
     // 1: End the song immediately.
     if (FlxG.keys.justPressed.ONE) endSong(true);
 

From 9b8961d4b5c8e150a2c77d39d53566bed5fe6ea7 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 22 Jul 2024 22:20:51 -0400
Subject: [PATCH 22/29] assets submod

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

diff --git a/assets b/assets
index 4af95a506..aa1231e8c 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 4af95a506fc62cd683422dfb9c599877b26c27db
+Subproject commit aa1231e8cf2990bb902eac3b37815c010fa9919a

From 6e301bf648e6df451289a4b1a6ef8453766e7a41 Mon Sep 17 00:00:00 2001
From: Burgerballs <107233412+Burgerballs@users.noreply.github.com>
Date: Tue, 23 Jul 2024 18:09:39 +0100
Subject: [PATCH 23/29] Update PlayState.hx

---
 source/funkin/play/PlayState.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 8d7d82aab..873082091 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2043,7 +2043,7 @@ class PlayState extends MusicBeatSubState
 
     vocals.pause();
 
-    FlxG.sound.music.play(FlxG.sound.music.time);
+    FlxG.sound.music.play(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
 
     vocals.time = FlxG.sound.music.time;
     vocals.play(false, FlxG.sound.music.time);

From 1831daac382894aa121180dc8e704d35ce55755c Mon Sep 17 00:00:00 2001
From: Burgerballs <107233412+Burgerballs@users.noreply.github.com>
Date: Tue, 23 Jul 2024 19:02:09 +0100
Subject: [PATCH 24/29] Update PlayState.hx

---
 source/funkin/play/PlayState.hx | 32 +++++++++++++++++---------------
 1 file changed, 17 insertions(+), 15 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 873082091..18ec0f002 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1417,17 +1417,6 @@ class PlayState extends MusicBeatSubState
 
     if (isGamePaused) return false;
 
-    if (!startingSong
-      && FlxG.sound.music != null
-      && (Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200
-        || Math.abs(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200))
-    {
-      trace("VOCALS NEED RESYNC");
-      if (vocals != null) trace(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
-      trace(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
-      resyncVocals();
-    }
-
     if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.instance.currentStep));
     if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.instance.currentStep));
 
@@ -1449,6 +1438,17 @@ class PlayState extends MusicBeatSubState
       // activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
     }
 
+    if (!startingSong
+      && FlxG.sound.music != null
+      && (Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 100
+        || Math.abs(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 100))
+    {
+      trace("VOCALS NEED RESYNC");
+      if (vocals != null) trace(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
+      trace(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
+      resyncVocals();
+    }
+
     // Only bop camera if zoom level is below 135%
     if (Preferences.zoomCamera
       && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom)
@@ -2040,13 +2040,15 @@ class PlayState extends MusicBeatSubState
 
     // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.)
     if (!FlxG.sound.music.playing) return;
-
+    var timeToPlayAt:Float = Conductor.instance.songPosition - Conductor.instance.instrumentalOffset;
+    FlxG.sound.music.pause();
     vocals.pause();
 
-    FlxG.sound.music.play(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
+    FlxG.sound.music.time = timeToPlayAt;
+    FlxG.sound.music.play(false, timeToPlayAt);
 
-    vocals.time = FlxG.sound.music.time;
-    vocals.play(false, FlxG.sound.music.time);
+    vocals.time = timeToPlayAt;
+    vocals.play(false, timeToPlayAt);
   }
 
   /**

From aaab24850df5b3882b2282d5b3c8f6d8174853be Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 24 Jun 2024 22:21:53 -0400
Subject: [PATCH 25/29] flixel haxelib updates

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

diff --git a/hmm.json b/hmm.json
index 8eaf24212..07216a351 100644
--- a/hmm.json
+++ b/hmm.json
@@ -11,14 +11,14 @@
       "name": "flixel",
       "type": "git",
       "dir": null,
-      "ref": "a7d8e3bad89a0a3506a4714121f73d8e34522c49",
+      "ref": "10c2a203c43a78ff1ff26b8368fd736576829d8d",
       "url": "https://github.com/FunkinCrew/flixel"
     },
     {
       "name": "flixel-addons",
       "type": "git",
       "dir": null,
-      "ref": "a523c3b56622f0640933944171efed46929e360e",
+      "ref": "9c6fb47968e894eb36bf10e94725cd7640c49281",
       "url": "https://github.com/FunkinCrew/flixel-addons"
     },
     {
@@ -30,7 +30,7 @@
       "name": "flixel-ui",
       "type": "git",
       "dir": null,
-      "ref": "719b4f10d94186ed55f6fef1b6618d32abec8c15",
+      "ref": "d0afed7293c71ffdb1184751317fc709b44c9056",
       "url": "https://github.com/HaxeFlixel/flixel-ui"
     },
     {

From a15695c3dc8b3c38d8049cb39db4bbf27f2f1d9a Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Fri, 19 Jul 2024 19:13:58 -0400
Subject: [PATCH 26/29] compile dev version of hxcpp

---
 .github/workflows/build-game.yml | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/build-game.yml b/.github/workflows/build-game.yml
index 07802557c..ba8167607 100644
--- a/.github/workflows/build-game.yml
+++ b/.github/workflows/build-game.yml
@@ -45,7 +45,11 @@ jobs:
       uses: ./.github/actions/setup-haxe
       with:
         gh-token: ${{ steps.app_token.outputs.token }}
-
+    - name: Setup HXCPP dev commit
+      run: |
+        cd .haxelib/hxcpp/git/tools/hxcpp
+        haxe compile.hxml
+        cd ../../../../..
     - name: Build game
       if: ${{ matrix.target == 'windows' }}
       run: |

From 4dfb46955239664439fbb3c64d8bd40a41b1169c Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 22 Jul 2024 10:12:41 -0400
Subject: [PATCH 27/29] update hxcpp haxelib

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

diff --git a/hmm.json b/hmm.json
index 8eaf24212..387d63f5e 100644
--- a/hmm.json
+++ b/hmm.json
@@ -99,8 +99,10 @@
     },
     {
       "name": "hxcpp",
-      "type": "haxelib",
-      "version": "4.3.2"
+      "type": "git",
+      "dir": null,
+      "url": "https://github.com/HaxeFoundation/hxcpp",
+      "ref": "01cfee282a9a783e10c5a7774a3baaf547e6b0a7"
     },
     {
       "name": "hxcpp-debug-server",

From 3db0cf3e0ab4817f211cd3d8a74c616b15be1239 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 26 Jul 2024 00:22:36 -0400
Subject: [PATCH 28/29] Update Polymod to support applying JSON patches in the
 _merge folder of mods.

---
 hmm.json | 16 +++++++++++++++-
 1 file changed, 15 insertions(+), 1 deletion(-)

diff --git a/hmm.json b/hmm.json
index 375fdd0ac..fabd8b51d 100644
--- a/hmm.json
+++ b/hmm.json
@@ -123,6 +123,20 @@
       "ref": "a8c26f18463c98da32f744c214fe02273e1823fa",
       "url": "https://github.com/FunkinCrew/json2object"
     },
+    {
+      "name": "jsonpatch",
+      "type": "git",
+      "dir": null,
+      "ref": "f9b83215acd586dc28754b4ae7f69d4c06c3b4d3",
+      "url": "https://github.com/EliteMasterEric/jsonpatch"
+    },
+    {
+      "name": "jsonpath",
+      "type": "git",
+      "dir": null,
+      "ref": "7a24193717b36393458c15c0435bb7c4470ecdda",
+      "url": "https://github.com/EliteMasterEric/jsonpath"
+    },
     {
       "name": "lime",
       "type": "git",
@@ -169,7 +183,7 @@
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "bfbe30d81601b3543d80dce580108ad6b7e182c7",
+      "ref": "98945c6c7f5ecde01a32c4623d3515bf012a023a",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {

From f3624f7e76cdd8dcba0762a39ebe1be28389ecef Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 28 Jul 2024 01:42:09 -0400
Subject: [PATCH 29/29] Fixes for scripted song events, define vocal tracks per
 variation, display suffixed difficulties properly.

---
 source/funkin/data/event/SongEventRegistry.hx |   4 +-
 source/funkin/data/song/CHANGELOG.md          |   7 +
 source/funkin/data/song/SongData.hx           |  59 +++---
 source/funkin/modding/PolymodHandler.hx       |   2 +
 source/funkin/play/PlayState.hx               |   8 +-
 source/funkin/play/song/Song.hx               | 168 ++++++++++++------
 source/funkin/ui/freeplay/FreeplayState.hx    |  30 +++-
 source/funkin/ui/freeplay/SongMenuItem.hx     |   3 +-
 8 files changed, 188 insertions(+), 93 deletions(-)

diff --git a/source/funkin/data/event/SongEventRegistry.hx b/source/funkin/data/event/SongEventRegistry.hx
index 9b0163557..5ee2d39fa 100644
--- a/source/funkin/data/event/SongEventRegistry.hx
+++ b/source/funkin/data/event/SongEventRegistry.hx
@@ -46,7 +46,7 @@ class SongEventRegistry
 
       if (event != null)
       {
-        trace('  Loaded built-in song event: (${event.id})');
+        trace('  Loaded built-in song event: ${event.id}');
         eventCache.set(event.id, event);
       }
       else
@@ -59,9 +59,9 @@ class SongEventRegistry
   static function registerScriptedEvents()
   {
     var scriptedEventClassNames:Array<String> = ScriptedSongEvent.listScriptClasses();
+    trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
     if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) return;
 
-    trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
     for (eventCls in scriptedEventClassNames)
     {
       var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN");
diff --git a/source/funkin/data/song/CHANGELOG.md b/source/funkin/data/song/CHANGELOG.md
index 4f1c66ade..ca36a1d6d 100644
--- a/source/funkin/data/song/CHANGELOG.md
+++ b/source/funkin/data/song/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.2.4]
+### Added
+- Added `playData.characters.opponentVocals` to specify which vocal track(s) to play for the opponent.
+  - If the value isn't present, it will use the `playData.characters.opponent`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the opponent)
+- Added `playData.characters.playerVocals` to specify which vocal track(s) to play for the player.
+  - If the value isn't present, it will use the `playData.characters.player`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the player)
+
 ## [2.2.3]
 ### Added
 - Added `charter` field to denote authorship of a chart.
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 7bf3f8f19..f487eb54d 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -529,12 +529,26 @@ class SongCharacterData implements ICloneable<SongCharacterData>
   @:default([])
   public var altInstrumentals:Array<String> = [];
 
-  public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '')
+  @:optional
+  public var opponentVocals:Null<Array<String>> = null;
+
+  @:optional
+  public var playerVocals:Null<Array<String>> = null;
+
+  public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '', ?altInstrumentals:Array<String>,
+      ?opponentVocals:Array<String>, ?playerVocals:Array<String>)
   {
     this.player = player;
     this.girlfriend = girlfriend;
     this.opponent = opponent;
     this.instrumental = instrumental;
+
+    this.altInstrumentals = altInstrumentals;
+    this.opponentVocals = opponentVocals;
+    this.playerVocals = playerVocals;
+
+    if (opponentVocals == null) this.opponentVocals = [opponent];
+    if (playerVocals == null) this.playerVocals = [player];
   }
 
   public function clone():SongCharacterData
@@ -722,18 +736,6 @@ class SongEventDataRaw implements ICloneable<SongEventDataRaw>
   {
     return new SongEventDataRaw(this.time, this.eventKind, this.value);
   }
-}
-
-/**
- * Wrap SongEventData in an abstract so we can overload operators.
- */
-@:forward(time, eventKind, value, activated, getStepTime, clone)
-abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
-{
-  public function new(time:Float, eventKind:String, value:Dynamic = null)
-  {
-    this = new SongEventDataRaw(time, eventKind, value);
-  }
 
   public function valueAsStruct(?defaultKey:String = "key"):Dynamic
   {
@@ -757,27 +759,27 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     }
   }
 
-  public inline function getHandler():Null<SongEvent>
+  public function getHandler():Null<SongEvent>
   {
     return SongEventRegistry.getEvent(this.eventKind);
   }
 
-  public inline function getSchema():Null<SongEventSchema>
+  public function getSchema():Null<SongEventSchema>
   {
     return SongEventRegistry.getEventSchema(this.eventKind);
   }
 
-  public inline function getDynamic(key:String):Null<Dynamic>
+  public function getDynamic(key:String):Null<Dynamic>
   {
     return this.value == null ? null : Reflect.field(this.value, key);
   }
 
-  public inline function getBool(key:String):Null<Bool>
+  public function getBool(key:String):Null<Bool>
   {
     return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
-  public inline function getInt(key:String):Null<Int>
+  public function getInt(key:String):Null<Int>
   {
     if (this.value == null) return null;
     var result = Reflect.field(this.value, key);
@@ -787,7 +789,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     return cast result;
   }
 
-  public inline function getFloat(key:String):Null<Float>
+  public function getFloat(key:String):Null<Float>
   {
     if (this.value == null) return null;
     var result = Reflect.field(this.value, key);
@@ -797,17 +799,17 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     return cast result;
   }
 
-  public inline function getString(key:String):String
+  public function getString(key:String):String
   {
     return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
-  public inline function getArray(key:String):Array<Dynamic>
+  public function getArray(key:String):Array<Dynamic>
   {
     return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
-  public inline function getBoolArray(key:String):Array<Bool>
+  public function getBoolArray(key:String):Array<Bool>
   {
     return this.value == null ? null : cast Reflect.field(this.value, key);
   }
@@ -839,6 +841,19 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
 
     return result;
   }
+}
+
+/**
+ * Wrap SongEventData in an abstract so we can overload operators.
+ */
+@:forward(time, eventKind, value, activated, getStepTime, clone, getHandler, getSchema, getDynamic, getBool, getInt, getFloat, getString, getArray,
+  getBoolArray, buildTooltip, valueAsStruct)
+abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
+{
+  public function new(time:Float, eventKind:String, value:Dynamic = null)
+  {
+    this = new SongEventDataRaw(time, eventKind, value);
+  }
 
   public function clone():SongEventData
   {
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 9a9ef9e66..5767199ba 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -234,6 +234,8 @@ class PolymodHandler
     // NOTE: Scripted classes are automatically aliased to their parent class.
     Polymod.addImportAlias('flixel.math.FlxPoint', flixel.math.FlxPoint.FlxBasePoint);
 
+    Polymod.addImportAlias('funkin.data.event.SongEventSchema', funkin.data.event.SongEventSchema.SongEventSchemaRaw);
+
     // Add blacklisting for prohibited classes and packages.
 
     // `Sys`
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 32b6e7b62..871c784df 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -580,6 +580,8 @@ class PlayState extends MusicBeatSubState
   // TODO: Refactor or document
   var generatedMusic:Bool = false;
 
+  var skipEndingTransition:Bool = false;
+
   static final BACKGROUND_COLOR:FlxColor = FlxColor.BLACK;
 
   /**
@@ -1926,7 +1928,9 @@ class PlayState extends MusicBeatSubState
       return;
     }
 
-    FlxG.sound.music.onComplete = endSong.bind(false);
+    FlxG.sound.music.onComplete = function() {
+      endSong(skipEndingTransition);
+    };
     // A negative instrumental offset means the song skips the first few milliseconds of the track.
     // This just gets added into the startTimestamp behavior so we don't need to do anything extra.
     FlxG.sound.music.play(true, startTimestamp - Conductor.instance.instrumentalOffset);
@@ -1965,7 +1969,7 @@ class PlayState extends MusicBeatSubState
     if (vocals == null) return;
 
     // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.)
-    if (!FlxG.sound.music.playing) return;
+    if (!(FlxG?.sound?.music?.playing ?? false)) return;
 
     vocals.pause();
 
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 147923add..2e7e13f51 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -494,6 +494,24 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return diffFiltered;
   }
 
+  public function listSuffixedDifficulties(variationIds:Array<String>, ?showLocked:Bool, ?showHidden:Bool):Array<String>
+  {
+    var result = [];
+
+    for (variation in variationIds)
+    {
+      var difficulties = listDifficulties(variation, null, showLocked, showHidden);
+      for (difficulty in difficulties)
+      {
+        var suffixedDifficulty = (variation != Constants.DEFAULT_VARIATION
+          && variation != 'erect') ? '$difficulty-${variation}' : difficulty;
+        result.push(suffixedDifficulty);
+      }
+    }
+
+    return result;
+  }
+
   public function hasDifficulty(diffId:String, ?variationId:String, ?variationIds:Array<String>):Bool
   {
     if (variationIds == null) variationIds = [];
@@ -706,10 +724,11 @@ class SongDifficulty
    * Cache the vocals for a given character.
    * @param id The character we are about to play.
    */
-  public inline function cacheVocals():Void
+  public function cacheVocals():Void
   {
     for (voice in buildVoiceList())
     {
+      trace('Caching vocal track: $voice');
       FlxG.sound.cache(voice);
     }
   }
@@ -721,6 +740,20 @@ class SongDifficulty
    * @param id The character we are about to play.
    */
   public function buildVoiceList():Array<String>
+  {
+    var result:Array<String> = [];
+    result = result.concat(buildPlayerVoiceList());
+    result = result.concat(buildOpponentVoiceList());
+    if (result.length == 0)
+    {
+      var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
+      // Try to use `Voices.ogg` if no other voices are found.
+      if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix'));
+    }
+    return result;
+  }
+
+  public function buildPlayerVoiceList():Array<String>
   {
     var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
 
@@ -728,62 +761,88 @@ class SongDifficulty
     // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`.
     // Then, check for  `Voices-bf-car.ogg`, then `Voices-bf.ogg`.
 
-    var playerId:String = characters.player;
-    var voicePlayer:String = Paths.voices(this.song.id, '-$playerId$suffix');
-    while (voicePlayer != null && !Assets.exists(voicePlayer))
+    if (characters.playerVocals == null)
     {
-      // Remove the last suffix.
-      // For example, bf-car becomes bf.
-      playerId = playerId.split('-').slice(0, -1).join('-');
-      // Try again.
-      voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
-    }
-    if (voicePlayer == null)
-    {
-      // Try again without $suffix.
-      playerId = characters.player;
-      voicePlayer = Paths.voices(this.song.id, '-${playerId}');
-      while (voicePlayer != null && !Assets.exists(voicePlayer))
+      var playerId:String = characters.player;
+      var playerVoice:String = Paths.voices(this.song.id, '-${playerId}$suffix');
+
+      while (playerVoice != null && !Assets.exists(playerVoice))
       {
         // Remove the last suffix.
+        // For example, bf-car becomes bf.
         playerId = playerId.split('-').slice(0, -1).join('-');
         // Try again.
-        voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
+        playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
+      }
+      if (playerVoice == null)
+      {
+        // Try again without $suffix.
+        playerId = characters.player;
+        playerVoice = Paths.voices(this.song.id, '-${playerId}');
+        while (playerVoice != null && !Assets.exists(playerVoice))
+        {
+          // Remove the last suffix.
+          playerId = playerId.split('-').slice(0, -1).join('-');
+          // Try again.
+          playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
+        }
       }
-    }
 
-    var opponentId:String = characters.opponent;
-    var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
-    while (voiceOpponent != null && !Assets.exists(voiceOpponent))
-    {
-      // Remove the last suffix.
-      opponentId = opponentId.split('-').slice(0, -1).join('-');
-      // Try again.
-      voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
+      return playerVoice != null ? [playerVoice] : [];
     }
-    if (voiceOpponent == null)
+    else
     {
-      // Try again without $suffix.
-      opponentId = characters.opponent;
-      voiceOpponent = Paths.voices(this.song.id, '-${opponentId}');
-      while (voiceOpponent != null && !Assets.exists(voiceOpponent))
+      // The metadata explicitly defines the list of voices.
+      var playerIds:Array<String> = characters?.playerVocals ?? [characters.player];
+      var playerVoices:Array<String> = playerIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix'));
+
+      return playerVoices;
+    }
+  }
+
+  public function buildOpponentVoiceList():Array<String>
+  {
+    var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
+
+    // Automatically resolve voices by removing suffixes.
+    // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`.
+    // Then, check for  `Voices-bf-car.ogg`, then `Voices-bf.ogg`.
+
+    if (characters.opponentVocals == null)
+    {
+      var opponentId:String = characters.opponent;
+      var opponentVoice:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
+      while (opponentVoice != null && !Assets.exists(opponentVoice))
       {
         // Remove the last suffix.
         opponentId = opponentId.split('-').slice(0, -1).join('-');
         // Try again.
-        voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
+        opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
+      }
+      if (opponentVoice == null)
+      {
+        // Try again without $suffix.
+        opponentId = characters.opponent;
+        opponentVoice = Paths.voices(this.song.id, '-${opponentId}');
+        while (opponentVoice != null && !Assets.exists(opponentVoice))
+        {
+          // Remove the last suffix.
+          opponentId = opponentId.split('-').slice(0, -1).join('-');
+          // Try again.
+          opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
+        }
       }
-    }
 
-    var result:Array<String> = [];
-    if (voicePlayer != null) result.push(voicePlayer);
-    if (voiceOpponent != null) result.push(voiceOpponent);
-    if (voicePlayer == null && voiceOpponent == null)
-    {
-      // Try to use `Voices.ogg` if no other voices are found.
-      if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix'));
+      return opponentVoice != null ? [opponentVoice] : [];
+    }
+    else
+    {
+      // The metadata explicitly defines the list of voices.
+      var opponentIds:Array<String> = characters?.opponentVocals ?? [characters.opponent];
+      var opponentVoices:Array<String> = opponentIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix'));
+
+      return opponentVoices;
     }
-    return result;
   }
 
   /**
@@ -795,26 +854,19 @@ class SongDifficulty
   {
     var result:VoicesGroup = new VoicesGroup();
 
-    var voiceList:Array<String> = buildVoiceList();
-
-    if (voiceList.length == 0)
-    {
-      trace('Could not find any voices for song ${this.song.id}');
-      return result;
-    }
+    var playerVoiceList:Array<String> = this.buildPlayerVoiceList();
+    var opponentVoiceList:Array<String> = this.buildOpponentVoiceList();
 
     // Add player vocals.
-    if (voiceList[0] != null) result.addPlayerVoice(FunkinSound.load(voiceList[0]));
-    // Add opponent vocals.
-    if (voiceList[1] != null) result.addOpponentVoice(FunkinSound.load(voiceList[1]));
-
-    // Add additional vocals.
-    if (voiceList.length > 2)
+    for (playerVoice in playerVoiceList)
     {
-      for (i in 2...voiceList.length)
-      {
-        result.add(FunkinSound.load(Assets.getSound(voiceList[i])));
-      }
+      result.addPlayerVoice(FunkinSound.load(playerVoice));
+    }
+
+    // Add opponent vocals.
+    for (opponentVoice in opponentVoiceList)
+    {
+      result.addOpponentVoice(FunkinSound.load(opponentVoice));
     }
 
     result.playerVoicesOffset = offsets.getVocalOffset(characters.player);
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index dc42bd651..2341f04a6 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -339,7 +339,7 @@ class FreeplayState extends MusicBeatSubState
         // Only display songs which actually have available difficulties for the current character.
         var displayedVariations = song.getVariationsByCharacter(currentCharacter);
         trace('Displayed Variations (${songId}): $displayedVariations');
-        var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
+        var availableDifficultiesForSong:Array<String> = song.listSuffixedDifficulties(displayedVariations, false, false);
         trace('Available Difficulties: $availableDifficultiesForSong');
         if (availableDifficultiesForSong.length == 0) continue;
 
@@ -1120,7 +1120,7 @@ class FreeplayState extends MusicBeatSubState
 
               // NOW we can interact with the menu
               busy = false;
-              grpCapsules.members[curSelected].sparkle.alpha = 0.7;
+              capsule.sparkle.alpha = 0.7;
               playCurSongPreview(capsule);
             }, null);
 
@@ -1674,6 +1674,9 @@ class FreeplayState extends MusicBeatSubState
           songCapsule.init(null, null, null);
         }
       }
+
+      // Reset the song preview in case we changed variations (normal->erect etc)
+      playCurSongPreview();
     }
 
     // Set the album graphic and play the animation if relevant.
@@ -1912,8 +1915,10 @@ class FreeplayState extends MusicBeatSubState
     }
   }
 
-  public function playCurSongPreview(daSongCapsule:SongMenuItem):Void
+  public function playCurSongPreview(?daSongCapsule:SongMenuItem):Void
   {
+    if (daSongCapsule == null) daSongCapsule = grpCapsules.members[curSelected];
+
     if (curSelected == 0)
     {
       FunkinSound.playMusic('freeplayRandom',
@@ -2145,7 +2150,7 @@ class FreeplaySongData
 
   function updateValues(variations:Array<String>):Void
   {
-    this.songDifficulties = song.listDifficulties(null, variations, false, false);
+    this.songDifficulties = song.listSuffixedDifficulties(variations, false, false);
     if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY;
 
     var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, null, variations);
@@ -2207,15 +2212,26 @@ class DifficultySprite extends FlxSprite
 
     difficultyId = diffId;
 
-    if (Assets.exists(Paths.file('images/freeplay/freeplay${diffId}.xml')))
+    var assetDiffId:String = diffId;
+    while (!Assets.exists(Paths.image('freeplay/freeplay${assetDiffId}')))
     {
-      this.frames = Paths.getSparrowAtlas('freeplay/freeplay${diffId}');
+      // Remove the last suffix of the difficulty id until we find an asset or there are no more suffixes.
+      var assetDiffIdParts:Array<String> = assetDiffId.split('-');
+      assetDiffIdParts.pop();
+      if (assetDiffIdParts.length == 0) break;
+      assetDiffId = assetDiffIdParts.join('-');
+    }
+
+    // Check for an XML to use an animation instead of an image.
+    if (Assets.exists(Paths.file('images/freeplay/freeplay${assetDiffId}.xml')))
+    {
+      this.frames = Paths.getSparrowAtlas('freeplay/freeplay${assetDiffId}');
       this.animation.addByPrefix('idle', 'idle0', 24, true);
       if (Preferences.flashingLights) this.animation.play('idle');
     }
     else
     {
-      this.loadGraphic(Paths.image('freeplay/freeplay' + diffId));
+      this.loadGraphic(Paths.image('freeplay/freeplay' + assetDiffId));
     }
   }
 }
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index 2eec83223..b4409d377 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -162,7 +162,7 @@ class SongMenuItem extends FlxSpriteGroup
 
     sparkle = new FlxSprite(ranking.x, ranking.y);
     sparkle.frames = Paths.getSparrowAtlas('freeplay/sparkle');
-    sparkle.animation.addByPrefix('sparkle', 'sparkle', 24, false);
+    sparkle.animation.addByPrefix('sparkle', 'sparkle Export0', 24, false);
     sparkle.animation.play('sparkle', true);
     sparkle.scale.set(0.8, 0.8);
     sparkle.blend = BlendMode.ADD;
@@ -523,7 +523,6 @@ class SongMenuItem extends FlxSpriteGroup
     checkWeek(songData?.songId);
   }
 
-
   var frameInTicker:Float = 0;
   var frameInTypeBeat:Int = 0;