From 7ce3eabd17eb918ce326cf401e1e43cd80f62084 Mon Sep 17 00:00:00 2001
From: Sword352 <sword352email@gmail.com>
Date: Wed, 8 May 2024 07:35:46 +0200
Subject: [PATCH 01/29] Better character animation offsets handling.

---
 source/funkin/play/character/BaseCharacter.hx | 29 ++++++++++++++-----
 1 file changed, 21 insertions(+), 8 deletions(-)

diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 2796f8123..cb9d5ae3f 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -1,6 +1,7 @@
 package funkin.play.character;
 
 import flixel.math.FlxPoint;
+import flixel.FlxCamera;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.character.CharacterData.CharacterRenderType;
@@ -118,19 +119,17 @@ class BaseCharacter extends Bopper
    */
   public var cameraFocusPoint(default, null):FlxPoint = new FlxPoint(0, 0);
 
+  /**
+   * Defines the animation offset.
+   */
+  public var animOffset:FlxPoint = FlxPoint.get();
+
   override function set_animOffsets(value:Array<Float>):Array<Float>
   {
     if (animOffsets == null) value = [0, 0];
     if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
 
-    // Make sure animOffets are halved when scale is 0.5.
-    var xDiff = (animOffsets[0] * this.scale.x / (this.isPixel ? 6 : 1)) - value[0];
-    var yDiff = (animOffsets[1] * this.scale.y / (this.isPixel ? 6 : 1)) - value[1];
-
-    // Call the super function so that camera focus point is not affected.
-    super.set_x(this.x + xDiff);
-    super.set_y(this.y + yDiff);
-
+    animOffset.set(value[0], value[1]);
     return animOffsets = value;
   }
 
@@ -570,11 +569,25 @@ class BaseCharacter extends Bopper
     }
   }
 
+  // override getScreenPosition (used by FlxSprite's draw method) to account for animation offsets.
+  override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint
+  {
+    var output:FlxPoint = super.getScreenPosition(result, camera);
+    output -= animOffset;
+    return output;
+  }
+
   public override function onDestroy(event:ScriptEvent):Void
   {
     this.characterType = OTHER;
   }
 
+  override function destroy():Void
+  {
+    animOffset = flixel.util.FlxDestroyUtil.put(animOffset);
+    super.destroy();
+  }
+
   /**
    * Play the appropriate singing animation, for the given note direction.
    * @param dir The direction of the note.

From dd86934712276f136193033237cb15095cc0e787 Mon Sep 17 00:00:00 2001
From: Sword352 <sword352email@gmail.com>
Date: Wed, 8 May 2024 09:22:49 +0200
Subject: [PATCH 02/29] did the requested changes

---
 source/funkin/play/character/BaseCharacter.hx | 29 -------------------
 source/funkin/play/stage/Bopper.hx            | 27 +++++++++++++----
 2 files changed, 21 insertions(+), 35 deletions(-)

diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index cb9d5ae3f..eee7d15a2 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -1,7 +1,6 @@
 package funkin.play.character;
 
 import flixel.math.FlxPoint;
-import flixel.FlxCamera;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.character.CharacterData.CharacterRenderType;
@@ -119,20 +118,6 @@ class BaseCharacter extends Bopper
    */
   public var cameraFocusPoint(default, null):FlxPoint = new FlxPoint(0, 0);
 
-  /**
-   * Defines the animation offset.
-   */
-  public var animOffset:FlxPoint = FlxPoint.get();
-
-  override function set_animOffsets(value:Array<Float>):Array<Float>
-  {
-    if (animOffsets == null) value = [0, 0];
-    if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
-
-    animOffset.set(value[0], value[1]);
-    return animOffsets = value;
-  }
-
   /**
    * If the x position changes, other than via changing the animation offset,
    *  then we need to update the camera focus point.
@@ -569,25 +554,11 @@ class BaseCharacter extends Bopper
     }
   }
 
-  // override getScreenPosition (used by FlxSprite's draw method) to account for animation offsets.
-  override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint
-  {
-    var output:FlxPoint = super.getScreenPosition(result, camera);
-    output -= animOffset;
-    return output;
-  }
-
   public override function onDestroy(event:ScriptEvent):Void
   {
     this.characterType = OTHER;
   }
 
-  override function destroy():Void
-  {
-    animOffset = flixel.util.FlxDestroyUtil.put(animOffset);
-    super.destroy();
-  }
-
   /**
    * Play the appropriate singing animation, for the given note direction.
    * @param dir The direction of the note.
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 262aff7bc..141a85c8f 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -1,6 +1,7 @@
 package funkin.play.stage;
 
 import flixel.FlxSprite;
+import flixel.FlxCamera;
 import flixel.math.FlxPoint;
 import flixel.util.FlxTimer;
 import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
@@ -67,6 +68,11 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     return value;
   }
 
+  /**
+   * Internally used to define the animation offsets to apply.
+   */
+  var _currentAnimOffset:FlxPoint = FlxPoint.get();
+
   /**
    * The offset of the character relative to the position specified by the stage.
    */
@@ -95,12 +101,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     if (animOffsets == null) animOffsets = [0, 0];
     if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
 
-    var xDiff = animOffsets[0] - value[0];
-    var yDiff = animOffsets[1] - value[1];
-
-    this.x += xDiff;
-    this.y += yDiff;
-
+    _currentAnimOffset.set(value[0], value[1]);
     return animOffsets = value;
   }
 
@@ -349,6 +350,20 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     return this.animation.curAnim.name;
   }
 
+  // override getScreenPosition (used by FlxSprite's draw method) to account for animation offsets.
+  override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint
+  {
+    var output:FlxPoint = super.getScreenPosition(result, camera);
+    output -= _currentAnimOffset;
+    return output;
+  }
+
+  override function destroy():Void
+  {
+    _currentAnimOffset = flixel.util.FlxDestroyUtil.put(_currentAnimOffset);
+    super.destroy();
+  }
+
   public function onPause(event:PauseScriptEvent) {}
 
   public function onResume(event:ScriptEvent) {}

From f6334fb30b12b44635a9574587f7689d2f3beb28 Mon Sep 17 00:00:00 2001
From: Sword352 <sword352email@gmail.com>
Date: Wed, 8 May 2024 22:18:41 +0200
Subject: [PATCH 03/29] Use `animOffsets` directly.

---
 source/funkin/play/stage/Bopper.hx | 15 ++-------------
 1 file changed, 2 insertions(+), 13 deletions(-)

diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 141a85c8f..5485edb3e 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -68,11 +68,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     return value;
   }
 
-  /**
-   * Internally used to define the animation offsets to apply.
-   */
-  var _currentAnimOffset:FlxPoint = FlxPoint.get();
-
   /**
    * The offset of the character relative to the position specified by the stage.
    */
@@ -101,7 +96,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     if (animOffsets == null) animOffsets = [0, 0];
     if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
 
-    _currentAnimOffset.set(value[0], value[1]);
     return animOffsets = value;
   }
 
@@ -354,16 +348,11 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
   override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint
   {
     var output:FlxPoint = super.getScreenPosition(result, camera);
-    output -= _currentAnimOffset;
+    output.x -= animOffsets[0];
+    output.y -= animOffsets[1];
     return output;
   }
 
-  override function destroy():Void
-  {
-    _currentAnimOffset = flixel.util.FlxDestroyUtil.put(_currentAnimOffset);
-    super.destroy();
-  }
-
   public function onPause(event:PauseScriptEvent) {}
 
   public function onResume(event:ScriptEvent) {}

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 04/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 05/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 06/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 07/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 08/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 09/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 6116ec3639b3720c3b2453be6a76adaa6998e447 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 12 Jul 2024 22:31:04 -0400
Subject: [PATCH 10/29] Some more fixes for offsets handling and stuffs

---
 source/funkin/play/PauseSubState.hx |  2 +-
 source/funkin/play/song/Song.hx     |  3 ++-
 source/funkin/play/stage/Bopper.hx  | 18 +++---------------
 3 files changed, 6 insertions(+), 17 deletions(-)

diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index d0c759b16..6f8908eea 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -306,7 +306,7 @@ class PauseSubState extends MusicBeatSubState
     metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
     if (PlayState.instance?.currentDifficulty != null)
     {
-      metadataDifficulty.text += PlayState.instance.currentDifficulty.toTitleCase();
+      metadataDifficulty.text += PlayState.instance.currentDifficulty.replace('-', ' ').toTitleCase();
     }
     metadataDifficulty.scrollFactor.set(0, 0);
     metadata.add(metadataDifficulty);
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 91d35d8fa..4e9f70920 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -277,7 +277,8 @@ 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('[SONG] Warning: Song $id (variation ${metadata.variation}) has no difficulties listed in metadata!');
+        continue;
       }
 
       // There may be more difficulties in the chart file than in the metadata,
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index ec7e77a86..de19c51b4 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -80,11 +80,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     if (globalOffsets == null) globalOffsets = [0, 0];
     if (globalOffsets == value) return value;
 
-    var xDiff = globalOffsets[0] - value[0];
-    var yDiff = globalOffsets[1] - value[1];
-
-    this.x += xDiff;
-    this.y += yDiff;
     return globalOffsets = value;
   }
 
@@ -315,14 +310,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
   function applyAnimationOffsets(name:String):Void
   {
     var offsets = animationOffsets.get(name);
-    if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0))
-    {
-      this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]];
-    }
-    else
-    {
-      this.animOffsets = globalOffsets;
-    }
+    this.animOffsets = offsets;
   }
 
   public function isAnimationFinished():Bool
@@ -350,8 +338,8 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
   override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint
   {
     var output:FlxPoint = super.getScreenPosition(result, camera);
-    output.x -= animOffsets[0];
-    output.y -= animOffsets[1];
+    output.x -= (animOffsets[0] - globalOffsets[0]) * this.scale.x;
+    output.y -= (animOffsets[1] - globalOffsets[1]) * this.scale.y;
     return output;
   }
 

From 73fc855f8e6eb8efb14e7ab7d35ff269e76287f2 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 18 Jul 2024 05:23:31 +0100
Subject: [PATCH 11/29] new stage names

---
 source/funkin/data/stage/StageRegistry.hx           | 4 ++--
 source/funkin/ui/debug/charting/ChartEditorState.hx | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/source/funkin/data/stage/StageRegistry.hx b/source/funkin/data/stage/StageRegistry.hx
index a03371296..fbb6f188e 100644
--- a/source/funkin/data/stage/StageRegistry.hx
+++ b/source/funkin/data/stage/StageRegistry.hx
@@ -93,8 +93,8 @@ class StageRegistry extends BaseRegistry<Stage, StageData>
   public function listBaseGameStageIds():Array<String>
   {
     return [
-      "mainStage", "spookyMansion", "phillyTrain", "limoRide", "mallXmas", "mallEvil", "school", "schoolEvil", "tankmanBattlefield", "phillyStreets",
-      "phillyBlazin",
+      "mainStage", "spookyMansion", "phillyTrain", "phillyTrainErect", "limoRide", "mallXmas", "mallEvil", "school", "schoolEvil", "tankmanBattlefield",
+      "phillyStreets", "phillyBlazin",
     ];
   }
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index f72cca77f..a4e0de61e 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -5703,9 +5703,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     {
       case 'mainStage':
         PlayStatePlaylist.campaignId = 'week1';
-      case 'spookyMansion':
+      case 'spookyMansion' | 'spookyMansionErect':
         PlayStatePlaylist.campaignId = 'week2';
-      case 'phillyTrain':
+      case 'phillyTrain' | 'phillyTrainErect':
         PlayStatePlaylist.campaignId = 'week3';
       case 'limoRide':
         PlayStatePlaylist.campaignId = 'week4';

From ad93706b1df16f8191e839b6920da4e5e316c071 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 18 Jul 2024 05:23:37 +0100
Subject: [PATCH 12/29] adjust color shader

---
 .../graphics/shaders/AdjustColorShader.hx     | 55 +++++++++++++++++++
 1 file changed, 55 insertions(+)
 create mode 100644 source/funkin/graphics/shaders/AdjustColorShader.hx

diff --git a/source/funkin/graphics/shaders/AdjustColorShader.hx b/source/funkin/graphics/shaders/AdjustColorShader.hx
new file mode 100644
index 000000000..2b0970eeb
--- /dev/null
+++ b/source/funkin/graphics/shaders/AdjustColorShader.hx
@@ -0,0 +1,55 @@
+package funkin.graphics.shaders;
+
+import flixel.addons.display.FlxRuntimeShader;
+import funkin.Paths;
+import openfl.utils.Assets;
+
+class AdjustColorShader extends FlxRuntimeShader
+{
+  public var hue(default, set):Float;
+  public var saturation(default, set):Float;
+  public var brightness(default, set):Float;
+  public var contrast(default, set):Float;
+
+  public function new()
+  {
+    super(Assets.getText(Paths.frag('adjustColor')));
+    // FlxG.debugger.addTrackerProfile(new TrackerProfile(HSVShader, ['hue', 'saturation', 'brightness', 'contrast']));
+    hue = 0;
+    saturation = 0;
+    brightness = 0;
+    contrast = 0;
+  }
+
+  function set_hue(value:Float):Float
+  {
+    this.setFloat('hue', value);
+    this.hue = value;
+
+    return this.hue;
+  }
+
+  function set_saturation(value:Float):Float
+  {
+    this.setFloat('saturation', value);
+    this.saturation = value;
+
+    return this.saturation;
+  }
+
+  function set_brightness(value:Float):Float
+  {
+    this.setFloat('brightness', value);
+    this.brightness = value;
+
+    return this.brightness;
+  }
+
+  function set_contrast(value:Float):Float
+  {
+    this.setFloat('contrast', value);
+    this.contrast = value;
+
+    return this.contrast;
+  }
+}

From 4bcfbc957f82726973b4aa1428b285d1132a19e7 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 18 Jul 2024 05:24:15 +0100
Subject: [PATCH 13/29] assets submod

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

diff --git a/assets b/assets
index 005c96f85..68bf145d5 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 005c96f85f4304865acb196e7cc4d6d83f9d76d8
+Subproject commit 68bf145d5786b2c3e4539a46727da67bef1fd039

From a0ab216617b16d87ed363a590979b4cdc8879012 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 18 Jul 2024 06:04:19 +0100
Subject: [PATCH 14/29] merged rain shader stuff from
 'feature/week2-erect-bg-rain'

---
 .../shaders/RuntimePostEffectShader.hx        | 31 +++++++++++++++++++
 .../graphics/shaders/RuntimeRainShader.hx     |  8 +++++
 2 files changed, 39 insertions(+)

diff --git a/source/funkin/graphics/shaders/RuntimePostEffectShader.hx b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
index 9f49da075..d39f57efe 100644
--- a/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
+++ b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
@@ -2,6 +2,7 @@ package funkin.graphics.shaders;
 
 import flixel.FlxCamera;
 import flixel.FlxG;
+import flixel.graphics.frames.FlxFrame;
 import flixel.addons.display.FlxRuntimeShader;
 import lime.graphics.opengl.GLProgram;
 import lime.utils.Log;
@@ -32,6 +33,9 @@ class RuntimePostEffectShader extends FlxRuntimeShader
 		// equals (camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom)
 		uniform vec4 uCameraBounds;
 
+		// equals (frame.left, frame.top, frame.right, frame.bottom)
+		uniform vec4 uFrameBounds;
+
 		// screen coord -> world coord conversion
 		// returns world coord in px
 		vec2 screenToWorld(vec2 screenCoord) {
@@ -56,6 +60,25 @@ class RuntimePostEffectShader extends FlxRuntimeShader
 			return (worldCoord - offset) / scale;
 		}
 
+		// screen coord -> frame coord conversion
+		// returns normalized frame coord
+		vec2 screenToFrame(vec2 screenCoord) {
+			float left = uFrameBounds.x;
+			float top = uFrameBounds.y;
+			float right = uFrameBounds.z;
+			float bottom = uFrameBounds.w;
+			float width = right - left;
+			float height = bottom - top;
+
+			float clampedX = clamp(screenCoord.x, left, right);
+			float clampedY = clamp(screenCoord.y, top, bottom);
+
+			return vec2(
+				(clampedX - left) / (width),
+				(clampedY - top) / (height)
+			);
+		}
+
 		// internally used to get the maximum `openfl_TextureCoordv`
 		vec2 bitmapCoordScale() {
 			return openfl_TextureCoordv / screenCoord;
@@ -80,6 +103,8 @@ class RuntimePostEffectShader extends FlxRuntimeShader
   {
     super(fragmentSource, null, glVersion);
     uScreenResolution.value = [FlxG.width, FlxG.height];
+    uCameraBounds.value = [0, 0, FlxG.width, FlxG.height];
+    uFrameBounds.value = [0, 0, FlxG.width, FlxG.height];
   }
 
   // basically `updateViewInfo(FlxG.width, FlxG.height, FlxG.camera)` is good
@@ -89,6 +114,12 @@ class RuntimePostEffectShader extends FlxRuntimeShader
     uCameraBounds.value = [camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom];
   }
 
+  public function updateFrameInfo(frame:FlxFrame)
+  {
+    // NOTE: uv.width is actually the right pos and uv.height is the bottom pos
+    uFrameBounds.value = [frame.uv.x, frame.uv.y, frame.uv.width, frame.uv.height];
+  }
+
   override function __createGLProgram(vertexSource:String, fragmentSource:String):GLProgram
   {
     try
diff --git a/source/funkin/graphics/shaders/RuntimeRainShader.hx b/source/funkin/graphics/shaders/RuntimeRainShader.hx
index 239276bbe..68a203179 100644
--- a/source/funkin/graphics/shaders/RuntimeRainShader.hx
+++ b/source/funkin/graphics/shaders/RuntimeRainShader.hx
@@ -32,6 +32,14 @@ class RuntimeRainShader extends RuntimePostEffectShader
     return time = value;
   }
 
+  public var spriteMode(default, set):Bool = false;
+
+  function set_spriteMode(value:Bool):Bool
+  {
+    this.setBool('uSpriteMode', value);
+    return spriteMode = value;
+  }
+
   // The scale of the rain depends on the world coordinate system, so higher resolution makes
   // the raindrops smaller. This parameter can be used to adjust the total scale of the scene.
   // The size of the raindrops is proportional to the value of this parameter.

From 563905ebd42810550904a3c4c894bc24afbc4f30 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 18 Jul 2024 06:04:32 +0100
Subject: [PATCH 15/29] assets submod

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

diff --git a/assets b/assets
index 68bf145d5..cfd67caa6 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 68bf145d5786b2c3e4539a46727da67bef1fd039
+Subproject commit cfd67caa688465b4a282837434832c107b661b04

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 16/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 17/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 3db0cf3e0ab4817f211cd3d8a74c616b15be1239 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 26 Jul 2024 00:22:36 -0400
Subject: [PATCH 18/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 19/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;
 

From 195010c7edf31944e40ad790d44b2c987dd931cd Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 18 Jul 2024 17:45:48 -0400
Subject: [PATCH 20/29] hxcpp and lime hmm updates for arm64 mac

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

diff --git a/hmm.json b/hmm.json
index fabd8b51d..60a35d4fe 100644
--- a/hmm.json
+++ b/hmm.json
@@ -141,7 +141,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "872ff6db2f2d27c0243d4ff76802121ded550dd7",
+      "ref": "ede0005c3aedcbfea7b6247bfc4972ecf7f78a14",
       "url": "https://github.com/FunkinCrew/lime"
     },
     {

From 5e04628480b53b3d1c5b7386826ddb7a36e2ad93 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Sun, 21 Jul 2024 19:02:58 -0400
Subject: [PATCH 21/29] lime shallow cairo

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

diff --git a/hmm.json b/hmm.json
index 60a35d4fe..ba5274f0d 100644
--- a/hmm.json
+++ b/hmm.json
@@ -141,7 +141,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "ede0005c3aedcbfea7b6247bfc4972ecf7f78a14",
+      "ref": "916da928134edc57db44f604036ae9bec828f340",
       "url": "https://github.com/FunkinCrew/lime"
     },
     {

From e4e4c66c6c06173b187109053769fd4ad4f08758 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 25 Jul 2024 08:33:01 -0400
Subject: [PATCH 22/29] Update Lime version

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

diff --git a/hmm.json b/hmm.json
index ba5274f0d..5254fe5dd 100644
--- a/hmm.json
+++ b/hmm.json
@@ -141,7 +141,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "916da928134edc57db44f604036ae9bec828f340",
+      "ref": "a393a54b6120bfa9a2f1db5a16fbf1137d41f257",
       "url": "https://github.com/FunkinCrew/lime"
     },
     {

From 68b7610225aa4fd382d53950d0ae889333d9e752 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 29 Jul 2024 17:26:49 -0400
Subject: [PATCH 23/29] Update OpenFL to latest (requires a few fixes)

---
 hmm.json                                           | 14 ++++++++------
 source/funkin/audio/visualize/ABotVis.hx           |  2 +-
 source/funkin/audio/visualize/VisShit.hx           |  2 +-
 source/funkin/audio/waveform/WaveformDataParser.hx |  2 +-
 .../toolboxes/ChartEditorNoteDataToolbox.hx        |  4 ++--
 source/funkin/ui/freeplay/FreeplayState.hx         |  2 +-
 source/funkin/ui/story/LevelProp.hx                |  2 +-
 7 files changed, 15 insertions(+), 13 deletions(-)

diff --git a/hmm.json b/hmm.json
index 5254fe5dd..e9aa678ef 100644
--- a/hmm.json
+++ b/hmm.json
@@ -23,8 +23,10 @@
     },
     {
       "name": "flixel-text-input",
-      "type": "haxelib",
-      "version": "1.1.0"
+      "type": "git",
+      "dir": null,
+      "ref": "951a0103a17bfa55eed86703ce50b4fb0d7590bc",
+      "url": "https://github.com/FunkinCrew/flixel-text-input"
     },
     {
       "name": "flixel-ui",
@@ -75,14 +77,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "5dc4c933bdc029f6139a47962e3b8c754060f210",
+      "ref": "22f7c5a8ffca90d4677cffd6e570f53761709fbc",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "57c1604d6b5174839d7e0e012a4dd5dcbfc129da",
+      "ref": "28bb710d0ae5d94b5108787593052165be43b980",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
@@ -141,7 +143,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "a393a54b6120bfa9a2f1db5a16fbf1137d41f257",
+      "ref": "f6153ffcb1ffcf733f91d531eac5fda4189e07f7",
       "url": "https://github.com/FunkinCrew/lime"
     },
     {
@@ -190,7 +192,7 @@
       "name": "thx.core",
       "type": "git",
       "dir": null,
-      "ref": "6240b6e136f7490d9298edbe8c1891374bd7cdf2",
+      "ref": "76d87418fadd92eb8e1b61f004cff27d656e53dd",
       "url": "https://github.com/fponticelli/thx.core"
     },
     {
diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx
index cf43a8add..a6ad0570e 100644
--- a/source/funkin/audio/visualize/ABotVis.hx
+++ b/source/funkin/audio/visualize/ABotVis.hx
@@ -54,7 +54,7 @@ class ABotVis extends FlxTypedSpriteGroup<FlxSprite>
   public function initAnalyzer()
   {
     @:privateAccess
-    analyzer = new SpectralAnalyzer(snd._channel.__source, 7, 0.1, 40);
+    analyzer = new SpectralAnalyzer(snd._channel.__audioSource, 7, 0.1, 40);
 
     #if desktop
     // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5
diff --git a/source/funkin/audio/visualize/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx
index ba235fe89..83b9496ac 100644
--- a/source/funkin/audio/visualize/VisShit.hx
+++ b/source/funkin/audio/visualize/VisShit.hx
@@ -117,7 +117,7 @@ class VisShit
       {
         // Math.pow3
         @:privateAccess
-        var buf = snd._channel.__source.buffer;
+        var buf = snd._channel.__audioSource.buffer;
 
         // @:privateAccess
         audioData = cast buf.data; // jank and hacky lol! kinda busted on HTML5 also!!
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
index 5aa54d744..ca421581b 100644
--- a/source/funkin/audio/waveform/WaveformDataParser.hx
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -16,7 +16,7 @@ class WaveformDataParser
 
     // Method 1. This only works if the sound has been played before.
     @:privateAccess
-    var soundBuffer:Null<lime.media.AudioBuffer> = sound?._channel?.__source?.buffer;
+    var soundBuffer:Null<lime.media.AudioBuffer> = sound?._channel?.__audioSource?.buffer;
 
     if (soundBuffer == null)
     {
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx
index 12f7f7d63..100654a02 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx
@@ -192,7 +192,7 @@ class ChartEditorNoteDataToolbox extends ChartEditorBaseToolbox
           var paramStepper:NumberStepper = new NumberStepper();
           paramStepper.value = (setParamsToPlace ? chartEditorState.noteParamsToPlace[i].value : param.data?.defaultValue) ?? 0.0;
           paramStepper.percentWidth = 100;
-          paramStepper.step = param.data?.step ?? 1;
+          paramStepper.step = param.data?.step ?? 1.0;
 
           // this check should be unnecessary but for some reason
           // even when these are null it will set it to 0
@@ -283,7 +283,7 @@ class ChartEditorNoteDataToolbox extends ChartEditorBaseToolbox
       return;
     }
 
-    var heightToSet:Int = Std.int(Math.max(DIALOG_HEIGHT, (toolboxNotesGrid?.height ?? 50) + HEIGHT_OFFSET)) + MINIMIZE_FIX;
+    var heightToSet:Int = Std.int(Math.max(DIALOG_HEIGHT, (toolboxNotesGrid?.height ?? 50.0) + HEIGHT_OFFSET)) + MINIMIZE_FIX;
     if (this.height != heightToSet)
     {
       this.height = heightToSet;
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 2341f04a6..690e3b910 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1527,7 +1527,7 @@ class FreeplayState extends MusicBeatSubState
           var moveDataX = funnyMoveShit.x ?? spr.x;
           var moveDataY = funnyMoveShit.y ?? spr.y;
           var moveDataSpeed = funnyMoveShit.speed ?? 0.2;
-          var moveDataWait = funnyMoveShit.wait ?? 0;
+          var moveDataWait = funnyMoveShit.wait ?? 0.0;
 
           FlxTween.tween(spr, {x: moveDataX, y: moveDataY}, moveDataSpeed, {ease: FlxEase.expoIn});
 
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
index 0547404a1..4e78415e3 100644
--- a/source/funkin/ui/story/LevelProp.hx
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -16,7 +16,7 @@ class LevelProp extends Bopper
       this.propData = value;
 
       this.visible = this.propData != null;
-      danceEvery = this.propData?.danceEvery ?? 0;
+      danceEvery = this.propData?.danceEvery ?? 0.0;
 
       applyData();
     }

From c9c81fae51e5f2a23ee7fc1ac0cfd499e95b1b9a Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 29 Jul 2024 21:26:02 -0400
Subject: [PATCH 24/29] assets submod?

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

diff --git a/assets b/assets
index 005c96f85..aa1231e8c 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 005c96f85f4304865acb196e7cc4d6d83f9d76d8
+Subproject commit aa1231e8cf2990bb902eac3b37815c010fa9919a

From 3b7e65679357687bd6901f45c120de06970d46f8 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Wed, 31 Jul 2024 14:36:06 +0100
Subject: [PATCH 25/29] add stage names to registry + chart editor

---
 source/funkin/data/stage/StageRegistry.hx           | 4 ++--
 source/funkin/ui/debug/charting/ChartEditorState.hx | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/source/funkin/data/stage/StageRegistry.hx b/source/funkin/data/stage/StageRegistry.hx
index fbb6f188e..1f0504247 100644
--- a/source/funkin/data/stage/StageRegistry.hx
+++ b/source/funkin/data/stage/StageRegistry.hx
@@ -93,8 +93,8 @@ class StageRegistry extends BaseRegistry<Stage, StageData>
   public function listBaseGameStageIds():Array<String>
   {
     return [
-      "mainStage", "spookyMansion", "phillyTrain", "phillyTrainErect", "limoRide", "mallXmas", "mallEvil", "school", "schoolEvil", "tankmanBattlefield",
-      "phillyStreets", "phillyBlazin",
+      "mainStage", "spookyMansion", "phillyTrain", "phillyTrainErect", "limoRide", "limoRideErect", "mallXmas", "mallEvil", "school", "schoolEvil",
+      "tankmanBattlefield", "phillyStreets", "phillyBlazin",
     ];
   }
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index a4e0de61e..24d290abd 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -5707,7 +5707,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         PlayStatePlaylist.campaignId = 'week2';
       case 'phillyTrain' | 'phillyTrainErect':
         PlayStatePlaylist.campaignId = 'week3';
-      case 'limoRide':
+      case 'limoRide' | 'limoRideErect':
         PlayStatePlaylist.campaignId = 'week4';
       case 'mallXmas' | 'mallEvil':
         PlayStatePlaylist.campaignId = 'week5';

From 270748d1081a947c0c84ecd718e65ccb34ba6911 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Wed, 31 Jul 2024 14:36:24 +0100
Subject: [PATCH 26/29] fuck these decimals messing up my compiling

---
 source/funkin/ui/freeplay/FreeplayState.hx | 2 +-
 source/funkin/ui/story/LevelProp.hx        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index dc42bd651..fa895042f 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1527,7 +1527,7 @@ class FreeplayState extends MusicBeatSubState
           var moveDataX = funnyMoveShit.x ?? spr.x;
           var moveDataY = funnyMoveShit.y ?? spr.y;
           var moveDataSpeed = funnyMoveShit.speed ?? 0.2;
-          var moveDataWait = funnyMoveShit.wait ?? 0;
+          var moveDataWait = funnyMoveShit.wait ?? 0.0;
 
           FlxTween.tween(spr, {x: moveDataX, y: moveDataY}, moveDataSpeed, {ease: FlxEase.expoIn});
 
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
index 0547404a1..4e78415e3 100644
--- a/source/funkin/ui/story/LevelProp.hx
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -16,7 +16,7 @@ class LevelProp extends Bopper
       this.propData = value;
 
       this.visible = this.propData != null;
-      danceEvery = this.propData?.danceEvery ?? 0;
+      danceEvery = this.propData?.danceEvery ?? 0.0;
 
       applyData();
     }

From 8200a08152ceea85cd242b990518007af5d4806f Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Wed, 31 Jul 2024 14:36:34 +0100
Subject: [PATCH 27/29] assets submod

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

diff --git a/assets b/assets
index cfd67caa6..06067d187 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit cfd67caa688465b4a282837434832c107b661b04
+Subproject commit 06067d187e7699a8eec42ab07c53d195c589a690

From f4968b0ae5211e70f99d68f8de618553c8eb9a35 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 5 Aug 2024 10:47:59 -0400
Subject: [PATCH 28/29] openfl hmm commit to dev-funkin

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

diff --git a/hmm.json b/hmm.json
index e9aa678ef..c72b4240a 100644
--- a/hmm.json
+++ b/hmm.json
@@ -178,7 +178,7 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "228c1b5063911e2ad75cef6e3168ef0a4b9f9134",
+      "ref": "8306425c497766739510ab29e876059c96f77bd2",
       "url": "https://github.com/FunkinCrew/openfl"
     },
     {

From c3d497a9d907b38cd11d1fa15f2d9a85bf82db96 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 7 Aug 2024 19:01:46 -0400
Subject: [PATCH 29/29] hxcpp bump

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

diff --git a/hmm.json b/hmm.json
index c72b4240a..aad0be8cf 100644
--- a/hmm.json
+++ b/hmm.json
@@ -104,7 +104,7 @@
       "type": "git",
       "dir": null,
       "url": "https://github.com/HaxeFoundation/hxcpp",
-      "ref": "01cfee282a9a783e10c5a7774a3baaf547e6b0a7"
+      "ref": "8dc8020f8465027de6c2aaaed90718bc693651ed"
     },
     {
       "name": "hxcpp-debug-server",