From 1b6febf01c9a7826ec65004d3d4e46780792116f Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 18 Apr 2024 20:23:03 -0400
Subject: [PATCH 01/72] initial freeplay songs loading

---
 Project.xml                                |  1 +
 checkstyle.json                            |  2 +-
 hmm.json                                   |  9 +-
 source/funkin/Paths.hx                     | 20 ++++-
 source/funkin/audio/FunkinSound.hx         | 97 ++++++++++++++++++++--
 source/funkin/ui/freeplay/FreeplayState.hx | 23 +++--
 6 files changed, 127 insertions(+), 25 deletions(-)

diff --git a/Project.xml b/Project.xml
index fcfcfb9f3..87608bb88 100644
--- a/Project.xml
+++ b/Project.xml
@@ -126,6 +126,7 @@
 	<haxelib name="hxCodec" if="desktop" unless="hl" /> <!-- Video playback -->
 	<haxelib name="funkin.vis"/>
 
+	<haxelib name="FlxPartialSound" /> <!-- Loading partial sound data -->
 
 	<haxelib name="json2object" /> <!-- JSON parsing -->
 	<haxelib name="thx.semver" /> <!-- Version string handling -->
diff --git a/checkstyle.json b/checkstyle.json
index dc89409da..41f0a7998 100644
--- a/checkstyle.json
+++ b/checkstyle.json
@@ -79,7 +79,7 @@
     {
       "props": {
         "ignoreExtern": true,
-        "format": "^[a-z][A-Z][A-Z0-9]*(_[A-Z0-9_]+)*$",
+        "format": "^[a-zA-Z0-9]+(?:_[a-zA-Z0-9]+)*$",
         "tokens": ["INLINE", "NOTINLINE"]
       },
       "type": "ConstantName"
diff --git a/hmm.json b/hmm.json
index a6e4467a9..6b119c52f 100644
--- a/hmm.json
+++ b/hmm.json
@@ -1,5 +1,12 @@
 {
   "dependencies": [
+    {
+      "name": "FlxPartialSound",
+      "type": "git",
+      "dir": null,
+      "ref": "main",
+      "url": "https://github.com/FunkinCrew/FlxPartialSound.git"
+    },
     {
       "name": "discord_rpc",
       "type": "git",
@@ -171,4 +178,4 @@
       "url": "https://github.com/FunkinCrew/thx.semver"
     }
   ]
-}
+}
\ No newline at end of file
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 54a4b7acf..b0a97c4fa 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -123,9 +123,17 @@ class Paths
     return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.${Constants.EXT_SOUND}';
   }
 
-  public static function inst(song:String, ?suffix:String = ''):String
+  /**
+   * Gets the path to an `Inst.mp3/ogg` song instrumental from songs:assets/songs/`song`/
+   * @param song name of the song to get instrumental for
+   * @param suffix any suffix to add to end of song name, used for `-erect` variants usually
+   * @param withExtension if it should return with the audio file extension `.mp3` or `.ogg`.
+   * @return String
+   */
+  public static function inst(song:String, ?suffix:String = '', ?withExtension:Bool = true):String
   {
-    return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.${Constants.EXT_SOUND}';
+    var ext:String = withExtension ? '.${Constants.EXT_SOUND}' : '';
+    return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix$ext';
   }
 
   public static function image(key:String, ?library:String):String
@@ -153,3 +161,11 @@ class Paths
     return FlxAtlasFrames.fromSpriteSheetPacker(image(key, library), file('images/$key.txt', library));
   }
 }
+
+enum abstract PathsFunction(String)
+{
+  var MUSIC;
+  var INST;
+  var VOICES;
+  var SOUND;
+}
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index df05cc3ef..728a06a32 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -11,7 +11,11 @@ import funkin.audio.waveform.WaveformDataParser;
 import funkin.data.song.SongData.SongMusicData;
 import funkin.data.song.SongRegistry;
 import funkin.util.tools.ICloneable;
+import funkin.util.flixel.sound.FlxPartialSound;
+import funkin.Paths.PathsFunction;
 import openfl.Assets;
+import lime.app.Future;
+import lime.app.Promise;
 import openfl.media.SoundMixer;
 #if (openfl >= "8.0.0")
 import openfl.utils.AssetType;
@@ -342,20 +346,52 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
         FlxG.log.warn('Tried and failed to find music metadata for $key');
       }
     }
-
-    var music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
-    if (music != null)
+    var pathsFunction = params.pathsFunction ?? MUSIC;
+    var pathToUse = switch (pathsFunction)
     {
-      FlxG.sound.music = music;
+      case MUSIC: Paths.music('$key/$key');
+      case INST: Paths.inst('$key');
+      default: Paths.music('$key/$key');
+    }
 
-      // Prevent repeat update() and onFocus() calls.
-      FlxG.sound.list.remove(FlxG.sound.music);
+    var shouldLoadPartial = params.partialParams?.loadPartial ?? false;
 
-      return true;
+    if (shouldLoadPartial)
+    {
+      var music = FunkinSound.loadPartial(pathToUse, params.partialParams?.start ?? 0, params.partialParams?.end ?? 1, params?.startingVolume ?? 1.0,
+        params.loop ?? true, false, true);
+
+      if (music != null)
+      {
+        music.onComplete(function(partialMusic:Null<FunkinSound>) {
+          @:nullSafety(Off)
+          FlxG.sound.music = partialMusic;
+          FlxG.sound.list.remove(FlxG.sound.music);
+        });
+
+        return true;
+      }
+      else
+      {
+        return false;
+      }
     }
     else
     {
-      return false;
+      var music = FunkinSound.load(pathToUse, params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
+      if (music != null)
+      {
+        FlxG.sound.music = music;
+
+        // Prevent repeat update() and onFocus() calls.
+        FlxG.sound.list.remove(FlxG.sound.music);
+
+        return true;
+      }
+      else
+      {
+        return false;
+      }
     }
   }
 
@@ -415,6 +451,36 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     return sound;
   }
 
+  /**
+   * Will load a section of a sound file, useful for Freeplay where we don't want to load all the bytes of a song
+   * @param path The path to the sound file
+   * @param start The start time of the sound file
+   * @param end The end time of the sound file
+   * @param volume Volume to start at
+   * @param looped Whether the sound file should loop
+   * @param autoDestroy Whether the sound file should be destroyed after it finishes playing
+   * @param autoPlay Whether the sound file should play immediately
+   * @return A FunkinSound object
+   */
+  public static function loadPartial(path:String, start:Float = 0, end:Float = 1, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false,
+      autoPlay:Bool = true, ?onComplete:Void->Void, ?onLoad:Void->Void):Future<Null<FunkinSound>>
+  {
+    var promise:lime.app.Promise<Null<FunkinSound>> = new lime.app.Promise<Null<FunkinSound>>();
+
+    // split the path and get only after first :
+    // we are bypassing the openfl/lime asset library fuss
+    path = Paths.stripLibrary(path);
+
+    var soundRequest = FlxPartialSound.partialLoadFromFile(path, start, end);
+
+    soundRequest.onComplete(function(partialSound) {
+      var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, onComplete, onLoad);
+      promise.complete(snd);
+    });
+
+    return promise.future;
+  }
+
   @:nullSafety(Off)
   public override function destroy():Void
   {
@@ -498,4 +564,19 @@ typedef FunkinSoundPlayMusicParams =
    * @default `true`
    */
   var ?mapTimeChanges:Bool;
+
+  /**
+   * Which Paths function to use to load a song
+   * @default `MUSIC`
+   */
+  var ?pathsFunction:PathsFunction;
+
+  var ?partialParams:PartialSoundParams;
+}
+
+typedef PartialSoundParams =
+{
+  var loadPartial:Bool;
+  var start:Float;
+  var end:Float;
 }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 7b7543845..c290e6553 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1244,22 +1244,19 @@ class FreeplayState extends MusicBeatSubState
       else
       {
         // TODO: Stream the instrumental of the selected song?
-        var didReplace:Bool = FunkinSound.playMusic('freakyMenu',
+        FunkinSound.playMusic(daSongCapsule.songData.songId,
           {
-            startingVolume: 0.0,
+            startingVolume: 0.5,
             overrideExisting: true,
-            restartTrack: false
+            restartTrack: false,
+            pathsFunction: INST,
+            partialParams:
+              {
+                loadPartial: true,
+                start: 0,
+                end: 0.1
+              }
           });
-        if (didReplace)
-        {
-          FunkinSound.playMusic('freakyMenu',
-            {
-              startingVolume: 0.0,
-              overrideExisting: true,
-              restartTrack: false
-            });
-          FlxG.sound.music.fadeIn(2, 0, 0.8);
-        }
       }
       grpCapsules.members[curSelected].selected = true;
     }

From f2a06ad37b79c76566ab62f8244a84ac864155a0 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 9 May 2024 01:56:52 -0400
Subject: [PATCH 02/72] fading in and erect track loading

---
 source/funkin/audio/FunkinSound.hx         | 14 ++++++++++++--
 source/funkin/ui/freeplay/FreeplayState.hx |  9 +++++++--
 2 files changed, 19 insertions(+), 4 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 728a06a32..5a49e29ee 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -347,10 +347,11 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
     var pathsFunction = params.pathsFunction ?? MUSIC;
+    var suffix = params.suffix ?? '';
     var pathToUse = switch (pathsFunction)
     {
       case MUSIC: Paths.music('$key/$key');
-      case INST: Paths.inst('$key');
+      case INST: Paths.inst('$key', suffix);
       default: Paths.music('$key/$key');
     }
 
@@ -359,7 +360,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     if (shouldLoadPartial)
     {
       var music = FunkinSound.loadPartial(pathToUse, params.partialParams?.start ?? 0, params.partialParams?.end ?? 1, params?.startingVolume ?? 1.0,
-        params.loop ?? true, false, true);
+        params.loop ?? true, false, true, params.onComplete, params.onLoad);
 
       if (music != null)
       {
@@ -541,6 +542,12 @@ typedef FunkinSoundPlayMusicParams =
    */
   var ?startingVolume:Float;
 
+  /**
+   * The suffix of the music file to play. Usually for "-erect" tracks when loading an INST file
+   * @default ``
+   */
+  var ?suffix:String;
+
   /**
    * Whether to override music if a different track is already playing.
    * @default `false`
@@ -572,6 +579,9 @@ typedef FunkinSoundPlayMusicParams =
   var ?pathsFunction:PathsFunction;
 
   var ?partialParams:PartialSoundParams;
+
+  var ?onComplete:Void->Void;
+  var ?onLoad:Void->Void;
 }
 
 typedef PartialSoundParams =
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index c290e6553..d0183bf8e 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1243,19 +1243,24 @@ class FreeplayState extends MusicBeatSubState
       }
       else
       {
+        var potentiallyErect:String = (currentDifficulty == "erect") || (currentDifficulty == "nightmare") ? "-erect" : "";
         // TODO: Stream the instrumental of the selected song?
         FunkinSound.playMusic(daSongCapsule.songData.songId,
           {
-            startingVolume: 0.5,
+            startingVolume: 0.0,
             overrideExisting: true,
             restartTrack: false,
             pathsFunction: INST,
+            suffix: potentiallyErect,
             partialParams:
               {
                 loadPartial: true,
                 start: 0,
                 end: 0.1
-              }
+              },
+            onLoad: function() {
+              FlxG.sound.music.fadeIn(2, 0, 0.4);
+            }
           });
       }
       grpCapsules.members[curSelected].selected = true;

From 6fc5e8cc2f7e5c9855be1950a75be6b0c4c15c05 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 9 May 2024 22:36:39 -0400
Subject: [PATCH 03/72] Fixed up some metadata

---
 example_mods/introMod/_polymod_meta.json   |   2 +-
 example_mods/testing123/_polymod_meta.json |   2 +-
 tests/unit/assets/shared/images/arrows.png | Bin 4806 -> 0 bytes
 tests/unit/assets/shared/images/arrows.xml |  27 ---------------------
 4 files changed, 2 insertions(+), 29 deletions(-)
 delete mode 100644 tests/unit/assets/shared/images/arrows.png
 delete mode 100644 tests/unit/assets/shared/images/arrows.xml

diff --git a/example_mods/introMod/_polymod_meta.json b/example_mods/introMod/_polymod_meta.json
index e0b03f1cd..4dc0cd804 100644
--- a/example_mods/introMod/_polymod_meta.json
+++ b/example_mods/introMod/_polymod_meta.json
@@ -3,7 +3,7 @@
   "description": "An introductory mod.",
   "contributors": [
     {
-      "name": "MasterEric"
+      "name": "EliteMasterEric"
     }
   ],
   "api_version": "0.1.0",
diff --git a/example_mods/testing123/_polymod_meta.json b/example_mods/testing123/_polymod_meta.json
index 4c0f177f9..0a2ed042c 100644
--- a/example_mods/testing123/_polymod_meta.json
+++ b/example_mods/testing123/_polymod_meta.json
@@ -3,7 +3,7 @@
   "description": "Newgrounds? More like OLDGROUNDS lol.",
   "contributors": [
     {
-      "name": "MasterEric"
+      "name": "EliteMasterEric"
     }
   ],
   "api_version": "0.1.0",
diff --git a/tests/unit/assets/shared/images/arrows.png b/tests/unit/assets/shared/images/arrows.png
deleted file mode 100644
index a443684327b409e3297d9456cb28afddd889b499..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 4806
zcmai2XEYp6yj`pygdov-7k%|!cGXp)6MuaLK|-P>EP||(5G_IU5<Pm^=%Oy7cUD`1
zD6588<!#=1-`|IsIdkUBo%zk&JHLA-=CPsny}L|z0RX@~T^)!CzJ=h6Iq4mIeVDHC
z0pE~$>sY@60Hmk>6#_e#O;P}WCQuimZWfrip8@wZoBrMxN~^)i^X@p2BQX*PrgnR4
zB`_5RX(P^C<+|v4=n}&Oq;pdb3KA044U4U`06wI5QmZ+_<m*^z<c-;oAsjk&I8p#i
z8{A8eNrB;i@3dgqU6&<s!<mw=_+rj)e6V#hc9M1HEh~H1du=)AXjeWqmV|5-!};m0
zE$pG(nGc3L^Tun9d+GI@MQpMJW8HUmH8Fr8i*o-#tL%SN0?P2r3EZyAlHouoiwGmw
zV@v(brQ9bxfJ<9au^HMA`gd}KYqJNLm7}^Z{LK8;L1pQpX1D5hWqF0=F$)}Wp$Lc=
z-y7DQl9jIWx#fIG{vJKd**SVz@3=unm*MheK^z)Xa`RF2KhHmj68c8WeFfz7Eyo4i
zP1R{}I4a|nDSiK6Jbkd?#xqBnd@3%*S2O^2`qT;U{V(pp>)Z1EZ%Hw02Y^tV11u68
zJ2jL>z*K3A!K|AAY9;ySFApP`fNCAt%76>#jpNA`+3`t0;OWgJ2Y%4{3cs4xp9K-X
zuk;WPYl|@U`Mev<*fjuFg{H|RxdFzZ(tg_Rs1mR4p3oBo<9BP*w@RjoxDNvHqD)e4
ztURd&;4}cu&0>c1qz7uzQ{<<*-#NJ%1Sf8b2-=4I;YtdehK2P|Jv|g#vP6cxQ9Ih_
z{<Pm&iiO#eL45dTQo&GNbrnE>VR}zOPayta1izIO2I=#USlH(^S1zAUjxAAiRu*jH
zCxCb)me;|vmA7-2>pFMT+%<Wx-~3rkRb?M2MT}&sSa`;R>!IvbDwODcJQRYbFGGc^
zvkBh)&`1%|@PdkAaWYi%RH0^)6Eo+(T~gk`>;J}$iXP;8L`~2xiS79L<LyP7I4n#k
zsAf{rh(lw!%9YBr1NbIO^g`s_uyr3bS+yee-pjTv!^VtTj!Y@YzizEW<kUzMn&WEp
zHl|>U<L9jCn*wZoqM+`BPdT;1*(&k0+M|h3pE)Kjj9aom0nW`m(rs6VmsD{uT3x^d
z#?dulC#$k|XKls4zf$#9zO#S7<MLC7b!Xre{lLE5aF70@<tLwADm#_C@7VMhS@i5s
z=HkYZ1!Q3Zu7dtI`#0IiZBskp%+u}4=M>Bo42o0kEOhm~wnZWboa@C6=Xu^((8{it
z<vH~qk>r_`G#e0=cJZxcP838zUT=_ncdWL1-)f*^;y4m@YK*zT&ad(R{d0Z7y60v6
zrRyc}q1A%(sqQXe=gtqNnTM4`4$4MRc6oV{4?4GHS7gfXvW4~pzVdEsd1$Cfuv7y?
zP`m?^tW9bzH_~jOMbe_iRfV__Y}^Nc)%A8G>r6}X&N2@Eo*s(N<&46X#O{_U&rCkQ
z{MDpKX3Z(#i(qQAFMYBpRB$fD3wZ~56l*pWe%Gw6Olm0nZh_svUBv6$srLaw)Bm{&
znlnl~V-w)ikJ=<iY?su6FA!I1Y_jLA%UhZrb}8=ccROCux7Wgv4QT3Rl;{O88*59}
zS?7u&z?_5|RrpsP9url+p`5aqbnbp#3vvRih|;7*k}^U=a4G6{9v^969F&b-_&Ub*
z&V=VlrdTg^qEtv3*AlpB+mKjOdzbEqfv-dX$}*W-4fNQ?x$tv}9T<<WG}EaqHO6+H
zTL`YeWi>j<7TP9IBEImfm_ZR-4VUZj1TB9w6t@#;&Uf#diLn6rk{Z)C^^v&6-p0DR
z^o((zocBd_Gszpl9a`J307h3M4gn7XmGTDeYcwx=eJc;7q`O{oiAc$dTKnEqnzil`
zdM2eb&!2F=R2Ke%@UseG_8Ib@?oL`iKDG-wdcG1#9Qw0{hOn^^5}wCs2m?%oI6hz{
zgF2EKr56swk`rJsA5gmqqfT{j<akx*qA1MlM0#j6o{S|L;X2xP;)_LlW?7*i!G&qC
z)+@EKn=r?dDU!n}64?GOYQ6Od7|c_T5XqL=z9ho<;fM>i$98)R`xEl|c0K5^zGqE6
zhL;VbcI!OC+F?e*E~)jveC8@jUso~z2_x-S7(YN#5P#B=;`l_-Y>aLBs<I4Mdm>=>
z?(F!MJQz&#8BxUv5QaxGP_QO??*hp>ib4W?wmHTYi>W*qb@?=v-Wx$df=(jb2F!Ii
z^hQ?)w04-c)zXlphQ=7s>X3+(PXl-FX9|%YI&z<MpJ!RQQ_pS%S%Q4ZkF4Vd?^;*T
zD1D0uO|ZqLM1HN_iod_pLKot)eq*fl+jBB7BQyU-je|4mc}<K;NNFpt*NXS@qgqN>
z*yl79kRE>rf0ov!Q$i#Bv*00+U{)+>8TC-<CYm>CyDA<wP*{jmzaNyI8_f$aRt@jB
zCs|#e$|iu#<r^G;mMNx=L}~lreMh1gLXq!7!Pq9-?4ZQ~=||Pi5X=`lcdgF2%+5bA
z`Lkvz>;o-fq<jA1HXxvXOV@rPm-r`oIab<!5{PD^%^vd)(reZc_PZq>FIQN@wxL->
zzkeg2IlF)SK{@{Co`GNEbfYkuCA|_bF<CV3>dgWuhKeQFU#I~3E4QNl%cE>FdDyw%
zfdcLNx50w9CVVS<rtfO=`&$51GcI2x;Q}@`j<njBs@cPuB`v|1I{Wv(0r=urHL>b~
zfJ|kRk<VE{00H+h@<ZxsurKJ~wNrw%s;9m%{LbiB*0d0Mm{@#ZKxgMy`Gvt}T|EI(
z#&gl;xHTVl0L90MjT_c^<c4HEt)h@@;jp!M#M`ELN!1~oh~jy|0_T&z$xg4;VA_3a
z@Kk|i+s$V*g!8P7g6ILEDw+2Q-1~;r+?e;|@9;oS%(7;a_1;phCzi5Z<&Y8J>^kBy
zF=rh1f}TuYBl1Jk*03xJ520y_Q8mC!ex(;~R8Tn8GdEeH7(=Nr{I8mog9?`_T4v_d
z=It<fCv)kq((4=$y>zYGTKvv)X;y%t%4jSz*hMp9lS(hgdG<v4=8f?aJB$wG9Cbp#
zkm(e8Vk;(61g<ih#vfpT2SSxGTuk%<NJ;cxu$h_9++st5uPj+|fA5hw(VCVhI477k
z29OZFVD0a;>6aj}YIACP9sIuvU#HC%_Umma*H@Njv;pJcQO!d0o~nKg>4hVz9969g
z6+=2ZPiM&KZZ~;SWvX~;ugM}m;IvoVp$JOOz3%XibPXKlWQE{uGiM<wCt;1?VoSud
z&<xR7r8dOG|ILEb&E^e{4G7TQwxqwg^9IT&!}l3E*TwQkbY0JvmYYA`S*=Onc(7dF
z0<BiH&F=kmNAbpZ*7GtZ){~^OGL|XcTvj~S=0U1)=0ZzGw128?GQneO%dWWb7>eJM
zsY9xszm^hj^MkTLC;|M+WwEqRORGvL4~@-z!sj9%IMJF!SICcA@{a_v-+vAEK(4z-
zrBNb;KmAwobwlIZSA!~AMOM_cBBA%iafoFobg$4J%JGHEaXwIO;~`WYB=UB<PmDHX
zW6*AI@3d$fH<Y`<ysp#EIf8#Yq#L`$qBrKEFB*5L6I0-2>*78&5iv%wd@6!p1}A6O
zOM1`a@@`i}p9Te+)N6t(A+qD%717s;vdP&Kp9s+Tau!|>9a(8YG*tmy3$}c}5!wGD
zVlJTlE}@Y4#z<QEX8z-#!isZ4NEX5bTUumaoVUc_r6ez;blC737W<wuX$lub(dw7^
zngt(bCx%Q;rWw(Cp6%m!9M&R0bolqzi(yZ54(oCVlkI<g>i4t|Y>qsc*-Fc#o!5Ay
zu#GxIN~s86Fs~{;VoqiFtBFo8XS)@Ubl(#&>0cpspf*=j7jZ8;feaQtkJq>Ci)AjP
zV5ap*GWl!Z)#-C=w30FMDexb!|FJ@>ci=Iv#0ft)o-i5+eLjVnx+D}C1m>q>_Sd95
zxmqZ_*ihdSo1y=WRO^mgOuGIdUafyG8o0yq1X&d>dyIN!cSO097tbb~l1Vw$`MY-v
zO#^<FX~$-Th63wS$zrVt>#+<jNi~vNX8=&Am|tqlFZ%WuNyn4a=x&-the9BC_w!l@
zgC0cx*Yy5=5%+MsxC>T9`h1qsPnt2r*r1Mm{~V>9E<sxXkt#AdzP!~cA&2SrkFRZB
zF8cXC3$P~$z(0Bi($7)%=g{Uq=2`tqmFAKO)Jf;HV!joS6*lLL#d^I}q0=YD#?0$w
z@Y|iBRV9Qn=2raKh?>{}jG_~KhQ7S*p^!RYZE*R?l4gDf$<qtf8UbT|mZf8#%Pu2%
zd!c=cj0sDuhIZlF$E{wXI@F*fcEhjED--~x4isiXv9D>}u+XYHj;0}w=Io=ifu619
zXA_&;l*tjY&RDNU#d@V4gO)GLQfkrC{#G$gypKBQ{0ycucspiFn$Yqg0vU7mrDC!I
zXJ~1t`^C1s`FzjK;PZ2<{15GtZ&aS1c-7kk@+)^=w!l}m9v24kXXKE%kIz)di!^6n
z>6hCgx8-<KH=<16c5=JWaKV-=#HSKJEYv=I|BYCvQLqmAHW_{&1bnWO7$Xys>^iI8
zFZi9ps<p?7-0t2uD*EQ#^+m===ckTKUx@fyvyUBi!>4vHt9Xeo-9rZhe@Z+jvalVO
z!sJ1MK)$U(MW$gz7gd4B-KD;nNxOR+t9<cK8C69Yk!Q$$41z*&3Qn05C)1voUA@kt
z&-MbUCQz?R?72*tO%@ge0xAf)+|ILiTK&y44L?1O(9#1ke4(af1dMlx>pU2UjUsQe
zN5aA!Z~YP2S934`MV8YuJ)i?(=GKcR;5KK!82sZU@S>+yI?bASp-2SRAPouT22c(^
zDmXYfWZ7mS?EJM~<k0-Cl-(|~^?_YzONe$IU00XEn!Fqmr9ZR4Hy9??<Skf92dXFK
zUNhr!FBZgR?*K32^)8>h=nnpu-%YTHl#^YJLhO@^GowM@ijM7szQv6PvmvZHIyg%O
zVuds4rignBEoTS}SWg07v@JeZh7GBDQR%FpVdX;K+*?vCz8=Iis80q!e~XrfI9M=l
z%{DxCYZFpALGM>I-B^~Kj8sPi^pfvZv~*pu^u^7F|6z4ol0qMapSP{}rz>S=GQ5zQ
zU#eZKOfPcA^Bj%m`StA<XNdO|@wj@sKTbC5sJClOh7(?28RR(sZcK~G)A~tYRA1dd
zI_>sfNesX4R!4<1bVLR~_cC<*;g?2zg5f8pAFLamqR9I)&gq+N)=1*e5&>)!NV53s
zuvyF3i_7F~(3h9~=74hpgWMqp%(}H{bJj;02K(TjSHE6v`v_ye4Iikc*U}sGq=lg0
z4-qCB7CQPiMNe5x%hdl4RygaC8FL<S#F7}7i{>&-^VU@~J4}&VcO^_Vm7B0%wV2E@
z32zQcMwlC}RO*pVI>vx5M=nsi<71>}+8=1>T9fVO+3%LYU0gyvTDV)2Z5s$d&T$l8
z>{Wz)TjoOl%=ffPfz<S*3rgi6<Jpz!dsRU`P7SSJ%;$_Sz!`sq%4n#yYfXd00?36<
z*1MKULrbo30OFz7WW#L8ekXS*2@X@aMdGtGQ+_fvu4H_*L#Q1`XSju@e02g2pQ3B}
za1Gww0?w|aS^*IP7$NGXuRCzH+p$HAL)uSJV-n%~1cA*ZLI1da0XL=HK7O1_6{7Sb
z5S{jO!F2^lVQ_@xB+8xS*xf2?a@gPd2(Xg-#_~%E+$~@qq~4$&rpjp|7QH}J5kjCu
z?gK`2#;0VCwh%H1g}!KP=(fZWX3Ztg5Fnlcxxv5BXmBO!`DzybooX!aj_qz<3-8;Q
za28~j<-)?$EtJiL*<Z(eKM6kLW6WnX+2c4nf12@yh9E6Z+VjZMnF5IlR30Yjx>vKH
z+d<a_jg8zSJ&gH!TSH2c&)nqegSXNiPi}=OXS#ws|7oXj!?WPGNC7$~#W12mbM`*K
zqC;(pE&93ZG^yf;gQs)pmutPoFU**`l8dwk%GIh;&$S!ekbmZ79i1B`jN9haC-3Y2
zpRUGwrtQIz6o=g4_u@2$E=fb$cD36m((H{LE(|Jx<<o2O);R2%yI)G;pT~v1Ou*0_
zW-j>bk18I=8{<0;H$G8P%Y4Od!LkVn@l^9l=70X_3`e8t=*K}+-=oim^#7xM1+TO;
zz3S2HnZ=votfeo!V&b<*V_u#}9xxwo@4fK3A)uzP0T071yG@-N1i;wnmfZ3|mdLMl
zDAFg#r*#Oa20Z|+;Ji}hN5zF^m<=qN_8L8kcK%-noqT)enY>N^z5Wyn{KphPSJMzu
Iso@a*KZ!k4q5uE@

diff --git a/tests/unit/assets/shared/images/arrows.xml b/tests/unit/assets/shared/images/arrows.xml
deleted file mode 100644
index 96a73a388..000000000
--- a/tests/unit/assets/shared/images/arrows.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<TextureAtlas imagePath="arrows.png">
-	<SubTexture name="staticLeft0001" x="0" y="0" width="17" height="17" />
-	<SubTexture name="staticDown0001" x="17" y="0" width="17" height="17" />
-	<SubTexture name="staticUp0001" x="34" y="0" width="17" height="17" />
-	<SubTexture name="staticRight0001" x="51" y="0" width="17" height="17" />
-	<SubTexture name="noteLeft0001" x="0" y="17" width="17" height="17" />
-	<SubTexture name="noteDown0001" x="17" y="17" width="17" height="17" />
-	<SubTexture name="noteUp0001" x="34" y="17" width="17" height="17" />
-	<SubTexture name="noteRight0001" x="51" y="17" width="17" height="17" />
-	<SubTexture name="pressedLeft0001" x="0" y="17" width="17" height="17" />
-	<SubTexture name="pressedDown0001" x="17" y="17" width="17" height="17" />
-	<SubTexture name="pressedUp0001" x="34" y="17" width="17" height="17" />
-	<SubTexture name="pressedRight0001" x="51" y="17" width="17" height="17" />
-	<SubTexture name="pressedLeft0002" x="0" y="34" width="17" height="17" />
-	<SubTexture name="pressedDown0002" x="17" y="34" width="17" height="17" />
-	<SubTexture name="pressedUp0002" x="34" y="34" width="17" height="17" />
-	<SubTexture name="pressedRight0002" x="51" y="34" width="17" height="17" />
-	<SubTexture name="confirmLeft0001" x="0" y="51" width="17" height="17" />
-	<SubTexture name="confirmDown0001" x="17" y="51" width="17" height="17" />
-	<SubTexture name="confirmUp0001" x="34" y="51" width="17" height="17" />
-	<SubTexture name="confirmRight0001" x="51" y="51" width="17" height="17" />
-	<SubTexture name="confirmLeft0002" x="0" y="68" width="17" height="17" />
-	<SubTexture name="confirmDown0002" x="17" y="68" width="17" height="17" />
-	<SubTexture name="confirmUp0002" x="34" y="68" width="17" height="17" />
-	<SubTexture name="confirmRight0002" x="51" y="68" width="17" height="17" />
-</TextureAtlas>

From a243b167b2b1a76c70a7b68296d7e27f4410cff4 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 9 May 2024 22:37:01 -0400
Subject: [PATCH 04/72] Fix up more credits

---
 source/funkin/play/components/HealthIcon.hx         | 2 +-
 source/funkin/ui/debug/charting/ChartEditorState.hx | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/play/components/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx
index 957daa43c..2d7099e8a 100644
--- a/source/funkin/play/components/HealthIcon.hx
+++ b/source/funkin/play/components/HealthIcon.hx
@@ -24,7 +24,7 @@ import funkin.util.MathUtil;
  *     - i.e. `PlayState.instance.iconP1.playAnimation("losing")`
  *   - Scripts can also utilize all functionality that a normal FlxSprite would have access to, such as adding supplimental animations.
  *     - i.e. `PlayState.instance.iconP1.animation.addByPrefix("jumpscare", "jumpscare", 24, false);`
- * @author MasterEric
+ * @author EliteMasterEric
  */
 @:nullSafety
 class HealthIcon extends FunkinSprite
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index b75cd8bf1..a313981f4 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -137,7 +137,7 @@ using Lambda;
  *
  * Some functionality is split into handler classes to help maintain my sanity.
  *
- * @author MasterEric
+ * @author EliteMasterEric
  */
 // @:nullSafety
 

From 5d5cf740204ac0b57712483a5e2a9fb0491b6eba Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 9 May 2024 22:37:21 -0400
Subject: [PATCH 05/72] Reimplement rank-based results animations.

---
 README.md                         |   2 +-
 assets                            |   2 +-
 source/funkin/play/ResultState.hx | 322 +++++++++++++++++++++---------
 source/funkin/util/Constants.hx   |  11 +
 4 files changed, 240 insertions(+), 97 deletions(-)

diff --git a/README.md b/README.md
index 39c098af5..5728a6cb3 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Please check out our [Contributor's guide](./CONTRIBUTORS.md) on how you can act
 
 ## Programming
 - [ninjamuffin99](https://twitter.com/ninja_muffin99) - Lead Programmer
-- [MasterEric](https://twitter.com/EliteMasterEric) - Programmer
+- [EliteMasterEric](https://twitter.com/EliteMasterEric) - Programmer
 - [MtH](https://twitter.com/emmnyaa) - Charting and Additional Programming
 - [GeoKureli](https://twitter.com/Geokureli/) - Additional Programming
 - Our contributors on GitHub
diff --git a/assets b/assets
index fe52d20de..6115eb683 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe52d20de7025d90cadb429dbdedf6d986727088
+Subproject commit 6115eb6837e97b8b3ad82f3ccd2a49a4383ed35b
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 56dd1e80f..7f8bdd77a 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -26,6 +26,7 @@ import funkin.play.components.TallyCounter;
 /**
  * The state for the results screen after a song or week is finished.
  */
+@:nullSafety
 class ResultState extends MusicBeatSubState
 {
   final params:ResultsStateParams;
@@ -42,91 +43,45 @@ class ResultState extends MusicBeatSubState
     super();
 
     this.params = params;
+
+    resultsVariation = calculateVariation(params);
+
+    var fontLetters:String = "AaBbCcDdEeFfGgHhiIJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:1234567890";
+    songName = new FlxBitmapText(FlxBitmapFont.fromMonospace(Paths.image("resultScreen/tardlingSpritesheet"), fontLetters, FlxPoint.get(49, 62)));
+    songName.text = params.title;
+    songName.letterSpacing = -15;
+    songName.angle = -4.4;
+    songName.zIndex = 1000;
+
+    difficulty = new FlxSprite(555);
+    difficulty.zIndex = 1000;
   }
 
   override function create():Void
   {
-    /*
-      if (params.scoreData.sick == params.scoreData.totalNotesHit
-        && params.scoreData.maxCombo == params.scoreData.totalNotesHit) resultsVariation = PERFECT;
-      else if (params.scoreData.missed + params.scoreData.bad + params.scoreData.shit >= params.scoreData.totalNotes * 0.50)
-        resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
-      else
-        resultsVariation = NORMAL;
-     */
-    resultsVariation = NORMAL;
-
-    FunkinSound.playMusic('results$resultsVariation',
+    FunkinSound.playMusic(resultsVariation.getMusicPath(),
       {
         startingVolume: 1.0,
         overrideExisting: true,
         restartTrack: true,
-        loop: resultsVariation != SHIT
+        loop: resultsVariation.shouldMusicLoop()
       });
 
     // Reset the camera zoom on the results screen.
     FlxG.camera.zoom = 1.0;
 
-    // TEMP-ish, just used to sorta "cache" the 3000x3000 image!
-    var cacheBullShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/soundSystem"));
-    add(cacheBullShit);
-
-    var dumb:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/scorePopin"));
-    add(dumb);
-
     var bg:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFECC5C, 0xFFFDC05C], 90);
     bg.scrollFactor.set();
+    bg.zIndex = 10;
     add(bg);
 
     var bgFlash:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFFEB69, 0xFFFFE66A], 90);
     bgFlash.scrollFactor.set();
     bgFlash.visible = false;
+    bg.zIndex = 20;
     add(bgFlash);
 
-    // var bfGfExcellent:FlxAtlasSprite = new FlxAtlasSprite(380, -170, Paths.animateAtlas("resultScreen/resultsBoyfriendExcellent", "shared"));
-    // bfGfExcellent.visible = false;
-    // add(bfGfExcellent);
-    //
-    // var bfPerfect:FlxAtlasSprite = new FlxAtlasSprite(370, -180, Paths.animateAtlas("resultScreen/resultsBoyfriendPerfect", "shared"));
-    // bfPerfect.visible = false;
-    // add(bfPerfect);
-    //
-    // var bfSHIT:FlxAtlasSprite = new FlxAtlasSprite(0, 20, Paths.animateAtlas("resultScreen/resultsBoyfriendSHIT", "shared"));
-    // bfSHIT.visible = false;
-    // add(bfSHIT);
-    //
-    // bfGfExcellent.anim.onComplete = () -> {
-    // bfGfExcellent.anim.curFrame = 28;
-    // bfGfExcellent.anim.play(); // unpauses this anim, since it's on PlayOnce!
-    // };
-    //
-    // bfPerfect.anim.onComplete = () -> {
-    //  bfPerfect.anim.curFrame = 136;
-    //  bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
-    // };
-    //
-    // bfSHIT.anim.onComplete = () -> {
-    //  bfSHIT.anim.curFrame = 150;
-    //  bfSHIT.anim.play(); // unpauses this anim, since it's on PlayOnce!
-    // };
-
-    var gf:FlxSprite = FunkinSprite.createSparrow(625, 325, 'resultScreen/resultGirlfriendGOOD');
-    gf.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false);
-    gf.visible = false;
-    gf.animation.finishCallback = _ -> {
-      gf.animation.play('clap', true, false, 9);
-    };
-    add(gf);
-
-    var boyfriend:FlxSprite = FunkinSprite.createSparrow(640, -200, 'resultScreen/resultBoyfriendGOOD');
-    boyfriend.animation.addByPrefix("fall", "Boyfriend Good Anim0", 24, false);
-    boyfriend.visible = false;
-    boyfriend.animation.finishCallback = function(_) {
-      boyfriend.animation.play('fall', true, false, 14);
-    };
-
-    add(boyfriend);
-
+    // The sound system which falls into place behind the score text. Plays every time!
     var soundSystem:FlxSprite = FunkinSprite.createSparrow(-15, -180, 'resultScreen/soundSystem');
     soundSystem.animation.addByPrefix("idle", "sound system", 24, false);
     soundSystem.visible = false;
@@ -134,9 +89,66 @@ class ResultState extends MusicBeatSubState
       soundSystem.animation.play("idle");
       soundSystem.visible = true;
     });
+    soundSystem.zIndex = 1100;
     add(soundSystem);
 
-    difficulty = new FlxSprite(555);
+    var bfPerfect:Null<FlxAtlasSprite> = null;
+    var bfExcellent:Null<FlxAtlasSprite> = null;
+    var bfGood:Null<FlxSprite> = null;
+    var gfGood:Null<FlxSprite> = null;
+    var bfShit:Null<FlxAtlasSprite> = null;
+
+    switch (resultsVariation)
+    {
+      case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
+        bfPerfect = new FlxAtlasSprite(370, -180, Paths.animateAtlas("resultScreen/results-bf/resultsPERFECT", "shared"));
+        bfPerfect.visible = false;
+        bfPerfect.zIndex = 500;
+        add(bfPerfect);
+
+        bfPerfect.anim.onComplete = () -> {
+          bfPerfect.anim.curFrame = 136;
+          bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
+        };
+
+      case EXCELLENT:
+        bfExcellent = new FlxAtlasSprite(380, -170, Paths.animateAtlas("resultScreen/results-bf/resultsEXCELLENT", "shared"));
+        bfExcellent.visible = false;
+        bfExcellent.zIndex = 500;
+        add(bfExcellent);
+
+        bfExcellent.onAnimationFinish.add((animName) -> {
+          bfExcellent.playAnimation('Loop Start');
+        });
+
+      case GOOD | GREAT:
+        gfGood = FunkinSprite.createSparrow(625, 325, 'resultScreen/results-bf/resultsGOOD/resultGirlfriendGOOD');
+        gfGood.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false);
+        gfGood.visible = false;
+        gfGood.zIndex = 500;
+        gfGood.animation.finishCallback = _ -> {
+          gfGood.animation.play('clap', true, false, 9);
+        };
+        add(gfGood);
+
+        bfGood = FunkinSprite.createSparrow(640, -200, 'resultScreen/results-bf/resultsGOOD/resultBoyfriendGOOD');
+        bfGood.animation.addByPrefix("fall", "Boyfriend Good Anim0", 24, false);
+        bfGood.visible = false;
+        bfGood.zIndex = 501;
+        bfGood.animation.finishCallback = function(_) {
+          bfGood.animation.play('fall', true, false, 14);
+        };
+        add(bfGood);
+
+      case SHIT:
+        bfShit = new FlxAtlasSprite(0, 20, Paths.animateAtlas("resultScreen/results-bf/resultsSHIT", "shared"));
+        bfShit.visible = false;
+        bfShit.zIndex = 500;
+        add(bfShit);
+        bfShit.onAnimationFinish.add((animName) -> {
+          bfShit.playAnimation('Loop Start');
+        });
+    }
 
     var diffSpr:String = switch (PlayState.instance.currentDifficulty)
     {
@@ -157,11 +169,6 @@ class ResultState extends MusicBeatSubState
     difficulty.loadGraphic(Paths.image("resultScreen/" + diffSpr));
     add(difficulty);
 
-    var fontLetters:String = "AaBbCcDdEeFfGgHhiIJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:1234567890";
-    songName = new FlxBitmapText(FlxBitmapFont.fromMonospace(Paths.image("resultScreen/tardlingSpritesheet"), fontLetters, FlxPoint.get(49, 62)));
-    songName.text = params.title;
-    songName.letterSpacing = -15;
-    songName.angle = -4.4;
     add(songName);
 
     var angleRad = songName.angle * Math.PI / 180;
@@ -179,21 +186,25 @@ class ResultState extends MusicBeatSubState
     var blackTopBar:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/topBarBlack"));
     blackTopBar.y = -blackTopBar.height;
     FlxTween.tween(blackTopBar, {y: 0}, 0.4, {ease: FlxEase.quartOut, startDelay: 0.5});
+    blackTopBar.zIndex = 1010;
     add(blackTopBar);
 
     var resultsAnim:FunkinSprite = FunkinSprite.createSparrow(-200, -10, "resultScreen/results");
     resultsAnim.animation.addByPrefix("result", "results instance 1", 24, false);
     resultsAnim.animation.play("result");
+    resultsAnim.zIndex = 1200;
     add(resultsAnim);
 
     var ratingsPopin:FunkinSprite = FunkinSprite.createSparrow(-150, 120, "resultScreen/ratingsPopin");
     ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false);
     ratingsPopin.visible = false;
+    ratingsPopin.zIndex = 1200;
     add(ratingsPopin);
 
     var scorePopin:FunkinSprite = FunkinSprite.createSparrow(-180, 520, "resultScreen/scorePopin");
     scorePopin.animation.addByPrefix("score", "tally score", 24, false);
     scorePopin.visible = false;
+    scorePopin.zIndex = 1200;
     add(scorePopin);
 
     var highscoreNew:FlxSprite = new FlxSprite(310, 570);
@@ -202,11 +213,13 @@ class ResultState extends MusicBeatSubState
     highscoreNew.visible = false;
     highscoreNew.setGraphicSize(Std.int(highscoreNew.width * 0.8));
     highscoreNew.updateHitbox();
+    highscoreNew.zIndex = 1200;
     add(highscoreNew);
 
     var hStuf:Int = 50;
 
     var ratingGrp:FlxTypedGroup<TallyCounter> = new FlxTypedGroup<TallyCounter>();
+    ratingGrp.zIndex = 1200;
     add(ratingGrp);
 
     /**
@@ -238,6 +251,7 @@ class ResultState extends MusicBeatSubState
 
     var score:ResultScore = new ResultScore(35, 305, 10, params.scoreData.score);
     score.visible = false;
+    score.zIndex = 1200;
     add(score);
 
     for (ind => rating in ratingGrp.members)
@@ -275,40 +289,72 @@ class ResultState extends MusicBeatSubState
 
       switch (resultsVariation)
       {
-        // case SHIT:
-        // bfSHIT.visible = true;
-        // bfSHIT.playAnimation("");
+        case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
+          if (bfPerfect == null)
+          {
+            trace("Could not build PERFECT animation!");
+          }
+          else
+          {
+            bfPerfect.visible = true;
+            bfPerfect.playAnimation('');
+          }
 
-        case NORMAL:
-          boyfriend.animation.play('fall');
-          boyfriend.visible = true;
+        case EXCELLENT:
+          if (bfExcellent == null)
+          {
+            trace("Could not build EXCELLENT animation!");
+          }
+          else
+          {
+            bfExcellent.visible = true;
+            bfExcellent.playAnimation('Intro');
+          }
 
-          new FlxTimer().start((1 / 24) * 12, _ -> {
-            bgFlash.visible = true;
-            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
-            new FlxTimer().start((1 / 24) * 2, _ ->
-              {
-                // bgFlash.alpha = 0.5;
+        case SHIT:
+          if (bfShit == null)
+          {
+            trace("Could not build SHIT animation!");
+          }
+          else
+          {
+            bfShit.visible = true;
+            bfShit.playAnimation('Intro');
+          }
 
-                // bgFlash.visible = false;
-              });
-          });
+        case GREAT | GOOD:
+          if (bfGood == null || gfGood == null)
+          {
+            trace("Could not build GOOD animation!");
+          }
+          else
+          {
+            bfGood.animation.play('fall');
+            bfGood.visible = true;
 
-          new FlxTimer().start((1 / 24) * 22, _ -> {
-            // plays about 22 frames (at 24fps timing) after bf spawns in
-            gf.animation.play('clap', true);
-            gf.visible = true;
-          });
-        // case PERFECT:
-        //          bfPerfect.visible = true;
-        //          bfPerfect.playAnimation("");
+            new FlxTimer().start((1 / 24) * 12, _ -> {
+              bgFlash.visible = true;
+              FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
+              new FlxTimer().start((1 / 24) * 2, _ ->
+                {
+                  // bgFlash.alpha = 0.5;
 
-        // bfGfExcellent.visible = true;
-        // bfGfExcellent.playAnimation("");
+                  // bgFlash.visible = false;
+                });
+            });
+
+            new FlxTimer().start((1 / 24) * 22, _ -> {
+              // plays about 22 frames (at 24fps timing) after bf spawns in
+              gfGood.animation.play('clap', true);
+              gfGood.visible = true;
+            });
+          }
         default:
       }
     });
 
+    refresh();
+
     super.create();
   }
 
@@ -401,14 +447,100 @@ class ResultState extends MusicBeatSubState
 
     super.update(elapsed);
   }
+
+  public static function calculateVariation(params:ResultsStateParams):ResultVariations
+  {
+    // Perfect (Platinum) is a Sick Full Clear
+    var isPerfectPlat = (params.scoreData.tallies.sick + params.scoreData.tallies.good) == params.scoreData.tallies.totalNotes
+      && params.scoreData.tallies.sick / params.scoreData.tallies.totalNotes >= Constants.RANK_PERFECT_PLAT_THRESHOLD;
+    if (isPerfectPlat) return ResultVariations.PERFECT_PLATINUM;
+
+    // Perfect (Gold) is an 85% Sick Full Clear
+    var isPerfectGold = (params.scoreData.tallies.sick + params.scoreData.tallies.good) == params.scoreData.tallies.totalNotes
+      && params.scoreData.tallies.sick / params.scoreData.tallies.totalNotes >= Constants.RANK_PERFECT_GOLD_THRESHOLD;
+    if (isPerfectGold) return ResultVariations.PERFECT_GOLD;
+
+    // Else, use the standard grades
+
+    // Clear % (including bad and shit). 1.00 is a full clear but not a full combo
+    var clear = (params.scoreData.tallies.totalNotesHit) / params.scoreData.tallies.totalNotes;
+
+    if (clear == Constants.RANK_PERFECT_THRESHOLD)
+    {
+      return ResultVariations.PERFECT;
+    }
+    else if (clear >= Constants.RANK_EXCELLENT_THRESHOLD)
+    {
+      return ResultVariations.EXCELLENT;
+    }
+    else if (clear >= Constants.RANK_GREAT_THRESHOLD)
+    {
+      return ResultVariations.GREAT;
+    }
+    else if (clear >= Constants.RANK_GOOD_THRESHOLD)
+    {
+      return ResultVariations.GOOD;
+    }
+    else
+    {
+      return ResultVariations.SHIT;
+    }
+  }
 }
 
 enum abstract ResultVariations(String)
 {
+  var PERFECT_PLATINUM;
+  var PERFECT_GOLD;
   var PERFECT;
   var EXCELLENT;
-  var NORMAL;
+  var GREAT;
+  var GOOD;
   var SHIT;
+
+  public function getMusicPath():String
+  {
+    switch (abstract)
+    {
+      case PERFECT_PLATINUM:
+        return 'resultsPERFECT';
+      case PERFECT_GOLD:
+        return 'resultsPERFECT';
+      case PERFECT:
+        return 'resultsPERFECT';
+      case EXCELLENT:
+        return 'resultsNORMAL';
+      case GREAT:
+        return 'resultsNORMAL';
+      case GOOD:
+        return 'resultsNORMAL';
+      case SHIT:
+        return 'resultsSHIT';
+    }
+  }
+
+  public function shouldMusicLoop():Bool
+  {
+    switch (abstract)
+    {
+      case PERFECT_PLATINUM:
+        return true;
+      case PERFECT_GOLD:
+        return true;
+      case PERFECT:
+        return true;
+      case EXCELLENT:
+        return true;
+      case GREAT:
+        return true;
+      case GOOD:
+        return true;
+      case SHIT:
+        return false;
+      default:
+        return false;
+    }
+  }
 }
 
 typedef ResultsStateParams =
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c50f17697..2f3b570b3 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -455,6 +455,17 @@ class Constants
   public static final JUDGEMENT_BAD_COMBO_BREAK:Bool = true;
   public static final JUDGEMENT_SHIT_COMBO_BREAK:Bool = true;
 
+  // % Sick
+  public static final RANK_PERFECT_PLAT_THRESHOLD:Float = 1.0; // % Sick
+  public static final RANK_PERFECT_GOLD_THRESHOLD:Float = 0.85; // % Sick
+
+  // % Hit
+  public static final RANK_PERFECT_THRESHOLD:Float = 1.00;
+  public static final RANK_EXCELLENT_THRESHOLD:Float = 0.90;
+  public static final RANK_GREAT_THRESHOLD:Float = 0.75;
+  public static final RANK_GOOD_THRESHOLD:Float = 0.60;
+
+  // public static final RANK_SHIT_THRESHOLD:Float = 0.00;
   /**
    * FILE EXTENSIONS
    */

From 98cf37b642292fcd7eb8a5913db5c7e08a43b82a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 9 May 2024 22:38:01 -0400
Subject: [PATCH 06/72] Update assets to add music.

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

diff --git a/assets b/assets
index 6115eb683..7df2e5527 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 6115eb6837e97b8b3ad82f3ccd2a49a4383ed35b
+Subproject commit 7df2e552738f8f2278538513a7495cb96d5ed118

From 83c3ff478c27bdc096e7dfcea190753df97526b5 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 10 May 2024 22:09:09 -0400
Subject: [PATCH 07/72] Added Clear % tally to results.

---
 assets                            |   2 +-
 source/funkin/play/ResultState.hx | 381 +++++++++++++++++++++---------
 2 files changed, 266 insertions(+), 117 deletions(-)

diff --git a/assets b/assets
index 7df2e5527..927578f48 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 7df2e552738f8f2278538513a7495cb96d5ed118
+Subproject commit 927578f482b23dc4511fd8203560d631442d91a8
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 7f8bdd77a..df3134b9d 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -12,6 +12,8 @@ import funkin.ui.MusicBeatSubState;
 import flixel.math.FlxRect;
 import flixel.text.FlxBitmapText;
 import funkin.ui.freeplay.FreeplayScore;
+import flixel.text.FlxText;
+import flixel.util.FlxColor;
 import flixel.tweens.FlxEase;
 import funkin.ui.freeplay.FreeplayState;
 import flixel.tweens.FlxTween;
@@ -31,12 +33,27 @@ class ResultState extends MusicBeatSubState
 {
   final params:ResultsStateParams;
 
-  var resultsVariation:ResultVariations;
-  var songName:FlxBitmapText;
-  var difficulty:FlxSprite;
+  final rank:ResultRank;
+  final songName:FlxBitmapText;
+  final difficulty:FlxSprite;
 
-  var maskShaderSongName:LeftMaskShader = new LeftMaskShader();
-  var maskShaderDifficulty:LeftMaskShader = new LeftMaskShader();
+  final maskShaderSongName:LeftMaskShader = new LeftMaskShader();
+  final maskShaderDifficulty:LeftMaskShader = new LeftMaskShader();
+
+  final resultsAnim:FunkinSprite;
+  final ratingsPopin:FunkinSprite;
+  final scorePopin:FunkinSprite;
+
+  final bgFlash:FlxSprite;
+
+  final highscoreNew:FlxSprite;
+  final score:ResultScore;
+
+  var bfPerfect:Null<FlxAtlasSprite> = null;
+  var bfExcellent:Null<FlxAtlasSprite> = null;
+  var bfGood:Null<FlxSprite> = null;
+  var gfGood:Null<FlxSprite> = null;
+  var bfShit:Null<FlxAtlasSprite> = null;
 
   public function new(params:ResultsStateParams)
   {
@@ -44,7 +61,11 @@ class ResultState extends MusicBeatSubState
 
     this.params = params;
 
-    resultsVariation = calculateVariation(params);
+    rank = calculateRank(params);
+    // rank = SHIT;
+
+    // We build a lot of this stuff in the constructor, then place it in create().
+    // This prevents having to do `null` checks everywhere.
 
     var fontLetters:String = "AaBbCcDdEeFfGgHhiIJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:1234567890";
     songName = new FlxBitmapText(FlxBitmapFont.fromMonospace(Paths.image("resultScreen/tardlingSpritesheet"), fontLetters, FlxPoint.get(49, 62)));
@@ -55,18 +76,22 @@ class ResultState extends MusicBeatSubState
 
     difficulty = new FlxSprite(555);
     difficulty.zIndex = 1000;
+
+    bgFlash = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFFEB69, 0xFFFFE66A], 90);
+
+    resultsAnim = FunkinSprite.createSparrow(-200, -10, "resultScreen/results");
+
+    ratingsPopin = FunkinSprite.createSparrow(-150, 120, "resultScreen/ratingsPopin");
+
+    scorePopin = FunkinSprite.createSparrow(-180, 520, "resultScreen/scorePopin");
+
+    highscoreNew = new FlxSprite(310, 570);
+
+    score = new ResultScore(35, 305, 10, params.scoreData.score);
   }
 
   override function create():Void
   {
-    FunkinSound.playMusic(resultsVariation.getMusicPath(),
-      {
-        startingVolume: 1.0,
-        overrideExisting: true,
-        restartTrack: true,
-        loop: resultsVariation.shouldMusicLoop()
-      });
-
     // Reset the camera zoom on the results screen.
     FlxG.camera.zoom = 1.0;
 
@@ -75,10 +100,9 @@ class ResultState extends MusicBeatSubState
     bg.zIndex = 10;
     add(bg);
 
-    var bgFlash:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFFEB69, 0xFFFFE66A], 90);
     bgFlash.scrollFactor.set();
     bgFlash.visible = false;
-    bg.zIndex = 20;
+    bgFlash.zIndex = 20;
     add(bgFlash);
 
     // The sound system which falls into place behind the score text. Plays every time!
@@ -92,13 +116,7 @@ class ResultState extends MusicBeatSubState
     soundSystem.zIndex = 1100;
     add(soundSystem);
 
-    var bfPerfect:Null<FlxAtlasSprite> = null;
-    var bfExcellent:Null<FlxAtlasSprite> = null;
-    var bfGood:Null<FlxSprite> = null;
-    var gfGood:Null<FlxSprite> = null;
-    var bfShit:Null<FlxAtlasSprite> = null;
-
-    switch (resultsVariation)
+    switch (rank)
     {
       case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
         bfPerfect = new FlxAtlasSprite(370, -180, Paths.animateAtlas("resultScreen/results-bf/resultsPERFECT", "shared"));
@@ -107,8 +125,11 @@ class ResultState extends MusicBeatSubState
         add(bfPerfect);
 
         bfPerfect.anim.onComplete = () -> {
-          bfPerfect.anim.curFrame = 136;
-          bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
+          if (bfPerfect != null)
+          {
+            bfPerfect.anim.curFrame = 137;
+            bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
+          }
         };
 
       case EXCELLENT:
@@ -118,7 +139,10 @@ class ResultState extends MusicBeatSubState
         add(bfExcellent);
 
         bfExcellent.onAnimationFinish.add((animName) -> {
-          bfExcellent.playAnimation('Loop Start');
+          if (bfExcellent != null)
+          {
+            bfExcellent.playAnimation('Loop Start');
+          }
         });
 
       case GOOD | GREAT:
@@ -127,7 +151,10 @@ class ResultState extends MusicBeatSubState
         gfGood.visible = false;
         gfGood.zIndex = 500;
         gfGood.animation.finishCallback = _ -> {
-          gfGood.animation.play('clap', true, false, 9);
+          if (gfGood != null)
+          {
+            gfGood.animation.play('clap', true, false, 9);
+          }
         };
         add(gfGood);
 
@@ -136,7 +163,10 @@ class ResultState extends MusicBeatSubState
         bfGood.visible = false;
         bfGood.zIndex = 501;
         bfGood.animation.finishCallback = function(_) {
-          bfGood.animation.play('fall', true, false, 14);
+          if (bfGood != null)
+          {
+            bfGood.animation.play('fall', true, false, 14);
+          }
         };
         add(bfGood);
 
@@ -146,7 +176,10 @@ class ResultState extends MusicBeatSubState
         bfShit.zIndex = 500;
         add(bfShit);
         bfShit.onAnimationFinish.add((animName) -> {
-          bfShit.playAnimation('Loop Start');
+          if (bfShit != null)
+          {
+            bfShit.playAnimation('Loop Start');
+          }
         });
     }
 
@@ -189,25 +222,21 @@ class ResultState extends MusicBeatSubState
     blackTopBar.zIndex = 1010;
     add(blackTopBar);
 
-    var resultsAnim:FunkinSprite = FunkinSprite.createSparrow(-200, -10, "resultScreen/results");
     resultsAnim.animation.addByPrefix("result", "results instance 1", 24, false);
     resultsAnim.animation.play("result");
     resultsAnim.zIndex = 1200;
     add(resultsAnim);
 
-    var ratingsPopin:FunkinSprite = FunkinSprite.createSparrow(-150, 120, "resultScreen/ratingsPopin");
     ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false);
     ratingsPopin.visible = false;
     ratingsPopin.zIndex = 1200;
     add(ratingsPopin);
 
-    var scorePopin:FunkinSprite = FunkinSprite.createSparrow(-180, 520, "resultScreen/scorePopin");
     scorePopin.animation.addByPrefix("score", "tally score", 24, false);
     scorePopin.visible = false;
     scorePopin.zIndex = 1200;
     add(scorePopin);
 
-    var highscoreNew:FlxSprite = new FlxSprite(310, 570);
     highscoreNew.frames = Paths.getSparrowAtlas("resultScreen/highscoreNew");
     highscoreNew.animation.addByPrefix("new", "NEW HIGHSCORE", 24);
     highscoreNew.visible = false;
@@ -249,7 +278,6 @@ class ResultState extends MusicBeatSubState
     var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.scoreData.tallies.missed, 0xFFC68AE6);
     ratingGrp.add(tallyMissed);
 
-    var score:ResultScore = new ResultScore(35, 305, 10, params.scoreData.score);
     score.visible = false;
     score.zIndex = 1200;
     add(score);
@@ -263,7 +291,68 @@ class ResultState extends MusicBeatSubState
       });
     }
 
-    new FlxTimer().start(0.5, _ -> {
+    startRankTallySequence();
+
+    refresh();
+
+    super.create();
+  }
+
+  var rankTallyTimer:Null<FlxTimer> = null;
+  var clearPercentTarget:Int = 100;
+  var clearPercentLerp:Int = 0;
+
+  function startRankTallySequence():Void
+  {
+    clearPercentTarget = Math.floor((params.scoreData.tallies.totalNotesHit) / params.scoreData.tallies.totalNotes * 100);
+    // clearPercentTarget = 97;
+
+    var clearPercentText = new FlxText(FlxG.width / 2, FlxG.height / 2, 0, 'CLEAR: ${clearPercentLerp}%');
+    clearPercentText.setFormat(Paths.font('vcr.ttf'), 64, FlxColor.BLACK, FlxTextAlign.RIGHT);
+    clearPercentText.zIndex = 1000;
+    add(clearPercentText);
+
+    rankTallyTimer = new FlxTimer().start(1 / 24, _ -> {
+      // Tick up.
+      if (clearPercentLerp < clearPercentTarget)
+      {
+        clearPercentLerp++;
+
+        clearPercentText.text = 'CLEAR: ${clearPercentLerp}%';
+        FunkinSound.playOnce(Paths.sound('scrollMenu'));
+      }
+
+      // Don't overshoot.
+      if (clearPercentLerp > clearPercentTarget)
+      {
+        clearPercentLerp = clearPercentTarget;
+      }
+
+      if (clearPercentLerp == clearPercentTarget)
+      {
+        if (rankTallyTimer != null)
+        {
+          rankTallyTimer.destroy();
+          rankTallyTimer = null;
+        }
+
+        // Play confirm sound.
+        FunkinSound.playOnce(Paths.sound('confirmMenu'));
+
+        new FlxTimer().start(1.0, _ -> {
+          remove(clearPercentText);
+
+          afterRankTallySequence();
+        });
+      }
+    }, 0); // 0 = Loop until stopped
+
+    if (ratingsPopin == null)
+    {
+      trace("Could not build ratingsPopin!");
+    }
+    else
+    {
       ratingsPopin.animation.play("idle");
       ratingsPopin.visible = true;
 
@@ -286,76 +375,139 @@ class ResultState extends MusicBeatSubState
           highscoreNew.visible = false;
         }
       };
-
-      switch (resultsVariation)
-      {
-        case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
-          if (bfPerfect == null)
-          {
-            trace("Could not build PERFECT animation!");
-          }
-          else
-          {
-            bfPerfect.visible = true;
-            bfPerfect.playAnimation('');
-          }
-
-        case EXCELLENT:
-          if (bfExcellent == null)
-          {
-            trace("Could not build EXCELLENT animation!");
-          }
-          else
-          {
-            bfExcellent.visible = true;
-            bfExcellent.playAnimation('Intro');
-          }
-
-        case SHIT:
-          if (bfShit == null)
-          {
-            trace("Could not build SHIT animation!");
-          }
-          else
-          {
-            bfShit.visible = true;
-            bfShit.playAnimation('Intro');
-          }
-
-        case GREAT | GOOD:
-          if (bfGood == null || gfGood == null)
-          {
-            trace("Could not build GOOD animation!");
-          }
-          else
-          {
-            bfGood.animation.play('fall');
-            bfGood.visible = true;
-
-            new FlxTimer().start((1 / 24) * 12, _ -> {
-              bgFlash.visible = true;
-              FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
-              new FlxTimer().start((1 / 24) * 2, _ ->
-                {
-                  // bgFlash.alpha = 0.5;
-
-                  // bgFlash.visible = false;
-                });
-            });
-
-            new FlxTimer().start((1 / 24) * 22, _ -> {
-              // plays about 22 frames (at 24fps timing) after bf spawns in
-              gfGood.animation.play('clap', true);
-              gfGood.visible = true;
-            });
-          }
-        default:
-      }
-    });
+    }
 
     refresh();
+  }
 
-    super.create();
+  function afterRankTallySequence():Void
+  {
+    FunkinSound.playMusic(rank.getMusicPath(),
+      {
+        startingVolume: 1.0,
+        overrideExisting: true,
+        restartTrack: true,
+        loop: rank.shouldMusicLoop()
+      });
+
+    FlxG.sound.music.onComplete = () -> {
+      if (rank == SHIT)
+      {
+        FunkinSound.playMusic('bluu',
+          {
+            startingVolume: 0.0,
+            overrideExisting: true,
+            restartTrack: true,
+            loop: true
+          });
+        FlxG.sound.music.fadeIn(10.0, 0.0, 1.0);
+      }
+    }
+
+    switch (rank)
+    {
+      case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
+        if (bfPerfect == null)
+        {
+          trace("Could not build PERFECT animation!");
+        }
+        else
+        {
+          bfPerfect.visible = true;
+          bfPerfect.playAnimation('');
+
+          new FlxTimer().start((1 / 24) * 12, _ -> {
+            bgFlash.visible = true;
+            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
+            new FlxTimer().start((1 / 24) * 2, _ ->
+              {
+                // bgFlash.alpha = 0.5;
+
+                // bgFlash.visible = false;
+              });
+          });
+        }
+
+      case EXCELLENT:
+        if (bfExcellent == null)
+        {
+          trace("Could not build EXCELLENT animation!");
+        }
+        else
+        {
+          bfExcellent.visible = true;
+          bfExcellent.playAnimation('Intro');
+
+          new FlxTimer().start((1 / 24) * 12, _ -> {
+            bgFlash.visible = true;
+            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
+            new FlxTimer().start((1 / 24) * 2, _ ->
+              {
+                // bgFlash.alpha = 0.5;
+
+                // bgFlash.visible = false;
+              });
+          });
+        }
+
+      case SHIT:
+        if (bfShit == null)
+        {
+          trace("Could not build SHIT animation!");
+        }
+        else
+        {
+          bfShit.visible = true;
+          bfShit.playAnimation('Intro');
+
+          new FlxTimer().start((1 / 24) * 12, _ -> {
+            bgFlash.visible = true;
+            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
+            new FlxTimer().start((1 / 24) * 2, _ ->
+              {
+                // bgFlash.alpha = 0.5;
+
+                // bgFlash.visible = false;
+              });
+          });
+        }
+
+      case GREAT | GOOD:
+        if (bfGood == null)
+        {
+          trace("Could not build GOOD animation!");
+        }
+        else
+        {
+          bfGood.animation.play('fall');
+          bfGood.visible = true;
+
+          new FlxTimer().start((1 / 24) * 12, _ -> {
+            bgFlash.visible = true;
+            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
+            new FlxTimer().start((1 / 24) * 2, _ ->
+              {
+                // bgFlash.alpha = 0.5;
+
+                // bgFlash.visible = false;
+              });
+          });
+
+          new FlxTimer().start((1 / 24) * 22, _ -> {
+            // plays about 22 frames (at 24fps timing) after bf spawns in
+            if (gfGood != null)
+            {
+              gfGood.animation.play('clap', true);
+              gfGood.visible = true;
+            }
+            else
+            {
+              trace("Could not build GOOD animation!");
+            }
+          });
+        }
+      default:
+    }
   }
 
   function timerThenSongName():Void
@@ -391,11 +543,8 @@ class ResultState extends MusicBeatSubState
   {
     super.draw();
 
-    if (songName != null)
-    {
-      songName.clipRect = FlxRect.get(Math.max(0, 540 - songName.x), 0, FlxG.width, songName.height);
-      // PROBABLY SHOULD FIX MEMORY FREE OR WHATEVER THE PUT() FUNCTION DOES !!!! FEELS LIKE IT STUTTERS!!!
-    }
+    songName.clipRect = FlxRect.get(Math.max(0, 540 - songName.x), 0, FlxG.width, songName.height);
+    // PROBABLY SHOULD FIX MEMORY FREE OR WHATEVER THE PUT() FUNCTION DOES !!!! FEELS LIKE IT STUTTERS!!!
 
     // if (songName != null && songName.frame != null)
     // maskShaderSongName.frameUV = songName.frame.uv;
@@ -448,17 +597,17 @@ class ResultState extends MusicBeatSubState
     super.update(elapsed);
   }
 
-  public static function calculateVariation(params:ResultsStateParams):ResultVariations
+  public static function calculateRank(params:ResultsStateParams):ResultRank
   {
     // Perfect (Platinum) is a Sick Full Clear
     var isPerfectPlat = (params.scoreData.tallies.sick + params.scoreData.tallies.good) == params.scoreData.tallies.totalNotes
       && params.scoreData.tallies.sick / params.scoreData.tallies.totalNotes >= Constants.RANK_PERFECT_PLAT_THRESHOLD;
-    if (isPerfectPlat) return ResultVariations.PERFECT_PLATINUM;
+    if (isPerfectPlat) return ResultRank.PERFECT_PLATINUM;
 
     // Perfect (Gold) is an 85% Sick Full Clear
     var isPerfectGold = (params.scoreData.tallies.sick + params.scoreData.tallies.good) == params.scoreData.tallies.totalNotes
       && params.scoreData.tallies.sick / params.scoreData.tallies.totalNotes >= Constants.RANK_PERFECT_GOLD_THRESHOLD;
-    if (isPerfectGold) return ResultVariations.PERFECT_GOLD;
+    if (isPerfectGold) return ResultRank.PERFECT_GOLD;
 
     // Else, use the standard grades
 
@@ -467,28 +616,28 @@ class ResultState extends MusicBeatSubState
 
     if (clear == Constants.RANK_PERFECT_THRESHOLD)
     {
-      return ResultVariations.PERFECT;
+      return ResultRank.PERFECT;
     }
     else if (clear >= Constants.RANK_EXCELLENT_THRESHOLD)
     {
-      return ResultVariations.EXCELLENT;
+      return ResultRank.EXCELLENT;
     }
     else if (clear >= Constants.RANK_GREAT_THRESHOLD)
     {
-      return ResultVariations.GREAT;
+      return ResultRank.GREAT;
     }
     else if (clear >= Constants.RANK_GOOD_THRESHOLD)
     {
-      return ResultVariations.GOOD;
+      return ResultRank.GOOD;
     }
     else
     {
-      return ResultVariations.SHIT;
+      return ResultRank.SHIT;
     }
   }
 }
 
-enum abstract ResultVariations(String)
+enum abstract ResultRank(String)
 {
   var PERFECT_PLATINUM;
   var PERFECT_GOLD;

From 6c2d18c72c7350ccefc4d9b15577c6092d663bed Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 11 May 2024 01:05:51 -0400
Subject: [PATCH 08/72] Resurrected difficulty stars, fixed flame animation,
 fixed Random showing an album.

---
 assets                                       |   2 +-
 source/funkin/play/song/Song.hx              |  21 ++++
 source/funkin/ui/freeplay/AlbumRoll.hx       |  44 ++++----
 source/funkin/ui/freeplay/DifficultyStars.hx | 106 +++++++++++++++++++
 source/funkin/ui/freeplay/FreeplayFlames.hx  |  21 +++-
 source/funkin/ui/freeplay/FreeplayState.hx   |  23 ++--
 source/funkin/ui/freeplay/SongMenuItem.hx    |   2 +-
 7 files changed, 183 insertions(+), 36 deletions(-)
 create mode 100644 source/funkin/ui/freeplay/DifficultyStars.hx

diff --git a/assets b/assets
index 927578f48..fd112e293 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 927578f482b23dc4511fd8203560d631442d91a8
+Subproject commit fd112e293ee0f823ee98d5b8bd8a85e934f772f6
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index e71ae3213..23d8d2198 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -399,6 +399,27 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return null;
   }
 
+  /**
+   * Given that this character is selected in the Freeplay menu,
+   * which variations should be available?
+   * @param charId The character ID to query.
+   * @return An array of available variations.
+   */
+  public function getVariationsByCharId(?charId:String):Array<String>
+  {
+    if (charId == null) charId = Constants.DEFAULT_CHARACTER;
+
+    if (variations.contains(charId))
+    {
+      return [charId];
+    }
+    else
+    {
+      // TODO: How to exclude character variations while keeping other custom variations?
+      return variations;
+    }
+  }
+
   /**
    * List all the difficulties in this song.
    *
diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx
index 35facf131..50f4a432c 100644
--- a/source/funkin/ui/freeplay/AlbumRoll.hx
+++ b/source/funkin/ui/freeplay/AlbumRoll.hx
@@ -38,7 +38,7 @@ class AlbumRoll extends FlxSpriteGroup
 
   var newAlbumArt:FlxAtlasSprite;
 
-  // var difficultyStars:DifficultyStars;
+  var difficultyStars:DifficultyStars;
   var _exitMovers:Null<FreeplayState.ExitMoverData>;
 
   var albumData:Album;
@@ -65,9 +65,9 @@ class AlbumRoll extends FlxSpriteGroup
 
     add(newAlbumArt);
 
-    // difficultyStars = new DifficultyStars(140, 39);
-    // difficultyStars.stars.visible = false;
-    // add(difficultyStars);
+    difficultyStars = new DifficultyStars(140, 39);
+    difficultyStars.stars.visible = false;
+    add(difficultyStars);
   }
 
   function onAlbumFinish(animName:String):Void
@@ -86,9 +86,14 @@ class AlbumRoll extends FlxSpriteGroup
   {
     if (albumId == null)
     {
-      // difficultyStars.stars.visible = false;
+      this.visible = false;
+      difficultyStars.stars.visible = false;
       return;
     }
+    else
+    {
+      this.visible = true;
+    }
 
     albumData = AlbumRegistry.instance.fetchEntry(albumId);
 
@@ -144,10 +149,10 @@ class AlbumRoll extends FlxSpriteGroup
     newAlbumArt.visible = true;
     newAlbumArt.playAnimation(animNames.get('$albumId-active'), false, false, false);
 
-    // difficultyStars.stars.visible = false;
+    difficultyStars.stars.visible = false;
     new FlxTimer().start(0.75, function(_) {
       // showTitle();
-      // showStars();
+      showStars();
     });
   }
 
@@ -156,16 +161,17 @@ class AlbumRoll extends FlxSpriteGroup
     newAlbumArt.playAnimation(animNames.get('$albumId-trans'), false, false, false);
   }
 
-  // public function setDifficultyStars(?difficulty:Int):Void
-  // {
-  //   if (difficulty == null) return;
-  //   difficultyStars.difficulty = difficulty;
-  // }
-  // /**
-  //  * Make the album stars visible.
-  //  */
-  // public function showStars():Void
-  // {
-  //   difficultyStars.stars.visible = false; // true;
-  // }
+  public function setDifficultyStars(?difficulty:Int):Void
+  {
+    if (difficulty == null) return;
+    difficultyStars.difficulty = difficulty;
+  }
+
+  /**
+   * Make the album stars visible.
+   */
+  public function showStars():Void
+  {
+    difficultyStars.stars.visible = true; // true;
+  }
 }
diff --git a/source/funkin/ui/freeplay/DifficultyStars.hx b/source/funkin/ui/freeplay/DifficultyStars.hx
new file mode 100644
index 000000000..51526bcbe
--- /dev/null
+++ b/source/funkin/ui/freeplay/DifficultyStars.hx
@@ -0,0 +1,106 @@
+package funkin.ui.freeplay;
+
+import flixel.group.FlxSpriteGroup;
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
+import funkin.graphics.shaders.HSVShader;
+
+class DifficultyStars extends FlxSpriteGroup
+{
+  /**
+   * Internal handler var for difficulty... ranges from 0... to 15
+   * 0 is 1 star... 15 is 0 stars!
+   */
+  var curDifficulty(default, set):Int = 0;
+
+  /**
+   * Range between 0 and 15
+   */
+  public var difficulty(default, set):Int = 1;
+
+  public var stars:FlxAtlasSprite;
+
+  var flames:FreeplayFlames;
+
+  var hsvShader:HSVShader;
+
+  public function new(x:Float, y:Float)
+  {
+    super(x, y);
+
+    hsvShader = new HSVShader();
+
+    flames = new FreeplayFlames(0, 0);
+    add(flames);
+
+    stars = new FlxAtlasSprite(0, 0, Paths.animateAtlas("freeplay/freeplayStars"));
+    stars.anim.play("diff stars");
+    add(stars);
+
+    stars.shader = hsvShader;
+
+    for (memb in flames.members)
+      memb.shader = hsvShader;
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // "loops" the current animation
+    // for clarity, the animation file looks like
+    // frame : stars
+    // 0-99: 1 star
+    // 100-199: 2 stars
+    // ......
+    // 1300-1499: 15 stars
+    // 1500 : 0 stars
+    if (curDifficulty < 15 && stars.anim.curFrame >= (curDifficulty + 1) * 100)
+    {
+      stars.anim.play("diff stars", true, false, curDifficulty * 100);
+    }
+  }
+
+  function set_difficulty(value:Int):Int
+  {
+    difficulty = value;
+
+    if (difficulty <= 0)
+    {
+      difficulty = 0;
+      curDifficulty = 15;
+    }
+    else if (difficulty <= 15)
+    {
+      difficulty = value;
+      curDifficulty = difficulty - 1;
+    }
+    else
+    {
+      difficulty = 15;
+      curDifficulty = difficulty - 1;
+    }
+
+    if (difficulty > 10) flames.flameCount = difficulty - 10;
+    else
+      flames.flameCount = 0;
+
+    return difficulty;
+  }
+
+  function set_curDifficulty(value:Int):Int
+  {
+    curDifficulty = value;
+    if (curDifficulty == 15)
+    {
+      stars.anim.play("diff stars", true, false, 1500);
+      stars.anim.pause();
+    }
+    else
+    {
+      stars.anim.curFrame = Std.int(curDifficulty * 100);
+      stars.anim.play("diff stars", true, false, curDifficulty * 100);
+    }
+
+    return curDifficulty;
+  }
+}
diff --git a/source/funkin/ui/freeplay/FreeplayFlames.hx b/source/funkin/ui/freeplay/FreeplayFlames.hx
index c20d85898..f6b6f5c3d 100644
--- a/source/funkin/ui/freeplay/FreeplayFlames.hx
+++ b/source/funkin/ui/freeplay/FreeplayFlames.hx
@@ -50,8 +50,19 @@ class FreeplayFlames extends FlxSpriteGroup
     }
   }
 
+  var timers:Array<FlxTimer> = [];
+
   function set_flameCount(value:Int):Int
   {
+    // Stop all existing timers.
+    // This fixes a bug where quickly switching difficulties would show flames.
+    for (timer in timers)
+    {
+      timer.active = false;
+      timer.destroy();
+      timers.remove(timer);
+    }
+
     this.flameCount = value;
     var visibleCount:Int = 0;
     for (i in 0...5)
@@ -62,10 +73,18 @@ class FreeplayFlames extends FlxSpriteGroup
       {
         if (!flame.visible)
         {
-          new FlxTimer().start(flameTimer * visibleCount, function(_) {
+          var nextTimer:FlxTimer = new FlxTimer().start(flameTimer * visibleCount, function(currentTimer:FlxTimer) {
+            if (i >= this.flameCount)
+            {
+              trace('EARLY EXIT');
+              return;
+            }
+            timers.remove(currentTimer);
             flame.animation.play("flame", true);
             flame.visible = true;
           });
+          timers.push(nextTimer);
+
           visibleCount++;
         }
       }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 7b7543845..239068288 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -120,8 +120,6 @@ class FreeplayState extends MusicBeatSubState
   var curCapsule:SongMenuItem;
   var curPlaying:Bool = false;
 
-  var displayedVariations:Array<String>;
-
   var dj:DJBoyfriend;
 
   var ostName:FlxText;
@@ -184,10 +182,6 @@ class FreeplayState extends MusicBeatSubState
     // Add a null entry that represents the RANDOM option
     songs.push(null);
 
-    // TODO: This makes custom variations disappear from Freeplay. Figure out a better solution later.
-    // Default character (BF) shows default and Erect variations. Pico shows only Pico variations.
-    displayedVariations = (currentCharacter == 'bf') ? [Constants.DEFAULT_VARIATION, 'erect'] : [currentCharacter];
-
     // programmatically adds the songs via LevelRegistry and SongRegistry
     for (levelId in LevelRegistry.instance.listSortedLevelIds())
     {
@@ -195,7 +189,8 @@ class FreeplayState extends MusicBeatSubState
       {
         var song:Song = SongRegistry.instance.fetchEntry(songId);
 
-        // Only display songs which actually have available charts for the current character.
+        // Only display songs which actually have available difficulties for the current character.
+        var displayedVariations = song.getVariationsByCharId(currentCharacter);
         var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
         if (availableDifficultiesForSong.length == 0) continue;
 
@@ -488,10 +483,6 @@ class FreeplayState extends MusicBeatSubState
 
       albumRoll.playIntro();
 
-      new FlxTimer().start(0.75, function(_) {
-        // albumRoll.showTitle();
-      });
-
       FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut});
 
       diffSelLeft.visible = true;
@@ -1072,6 +1063,9 @@ class FreeplayState extends MusicBeatSubState
       albumRoll.albumId = newAlbumId;
       albumRoll.skipIntro();
     }
+
+    // Set difficulty star count.
+    albumRoll.setDifficultyStars(daSong?.difficultyRating);
   }
 
   // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String)
@@ -1383,11 +1377,12 @@ class FreeplaySongData
 
   public var songName(default, null):String = '';
   public var songCharacter(default, null):String = '';
-  public var songRating(default, null):Int = 0;
+  public var difficultyRating(default, null):Int = 0;
   public var albumId(default, null):Null<String> = null;
 
   public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
-  public var displayedVariations(default, null):Array<String> = [Constants.DEFAULT_VARIATION];
+
+  var displayedVariations:Array<String> = [Constants.DEFAULT_VARIATION];
 
   function set_currentDifficulty(value:String):String
   {
@@ -1417,7 +1412,7 @@ class FreeplaySongData
     if (songDifficulty == null) return;
     this.songName = songDifficulty.songName;
     this.songCharacter = songDifficulty.characters.opponent;
-    this.songRating = songDifficulty.difficultyRating;
+    this.difficultyRating = songDifficulty.difficultyRating;
     if (songDifficulty.album == null)
     {
       FlxG.log.warn('No album for: ${songDifficulty.songName}');
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index f6d85e56e..cf9b52482 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -168,7 +168,7 @@ class SongMenuItem extends FlxSpriteGroup
     songText.text = songData?.songName ?? 'Random';
     // Update capsule character.
     if (songData?.songCharacter != null) setCharacter(songData.songCharacter);
-    updateDifficultyRating(songData?.songRating ?? 0);
+    updateDifficultyRating(songData?.difficultyRating ?? 0);
     // Update opacity, offsets, etc.
     updateSelected();
   }

From c0485fd1a23f0f683b79935245bc89af18d15e4a Mon Sep 17 00:00:00 2001
From: gamerbross <blas333blas333blas@gmail.com>
Date: Sat, 11 May 2024 20:11:51 +0200
Subject: [PATCH 09/72] Fix Freeplay Crash when song is invalid

---
 source/funkin/ui/freeplay/FreeplayState.hx | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 7b7543845..a359010d3 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -29,6 +29,7 @@ import funkin.graphics.shaders.StrokeShader;
 import funkin.input.Controls;
 import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
+import funkin.ui.story.Level;
 import funkin.save.Save;
 import funkin.save.Save.SaveScoreData;
 import funkin.ui.AtlasText;
@@ -191,10 +192,24 @@ class FreeplayState extends MusicBeatSubState
     // programmatically adds the songs via LevelRegistry and SongRegistry
     for (levelId in LevelRegistry.instance.listSortedLevelIds())
     {
-      for (songId in LevelRegistry.instance.parseEntryData(levelId).songs)
+      var level:Level = LevelRegistry.instance.fetchEntry(levelId);
+
+      if (level == null)
+      {
+        trace('[WARN] Could not find level with id (${levelId})');
+        continue;
+      }
+
+      for (songId in level.getSongs())
       {
         var song:Song = SongRegistry.instance.fetchEntry(songId);
 
+        if (song == null)
+        {
+          trace('[WARN] Could not find song with id (${songId})');
+          continue;
+        }
+
         // Only display songs which actually have available charts for the current character.
         var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
         if (availableDifficultiesForSong.length == 0) continue;

From b22dd4d7d44bdd9fda401ac50727e1cf6aae2c28 Mon Sep 17 00:00:00 2001
From: MaybeMaru <97055307+MaybeMaru@users.noreply.github.com>
Date: Mon, 13 May 2024 02:18:57 +0200
Subject: [PATCH 10/72] Update Strumline.hx

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

diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 95e0668be..b520f7b70 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -406,7 +406,7 @@ class Strumline extends FlxSpriteGroup
 
         if (Preferences.downscroll)
         {
-          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
         }
         else
         {
@@ -435,7 +435,7 @@ class Strumline extends FlxSpriteGroup
 
         if (Preferences.downscroll)
         {
-          holdNote.y = this.y - holdNote.height + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - holdNote.height - INITIAL_OFFSET + STRUMLINE_SIZE / 2; // + STRUMLINE_SIZE / 2;
         }
         else
         {
@@ -450,7 +450,7 @@ class Strumline extends FlxSpriteGroup
 
         if (Preferences.downscroll)
         {
-          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
         }
         else
         {

From c270f61cac9b6fc5acca79206f7ccf5732d63dea Mon Sep 17 00:00:00 2001
From: MaybeMaru <97055307+MaybeMaru@users.noreply.github.com>
Date: Mon, 13 May 2024 02:21:43 +0200
Subject: [PATCH 11/72] Update Strumline.hx

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

diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index b520f7b70..a7a051a66 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -435,7 +435,7 @@ class Strumline extends FlxSpriteGroup
 
         if (Preferences.downscroll)
         {
-          holdNote.y = this.y - holdNote.height - INITIAL_OFFSET + STRUMLINE_SIZE / 2; // + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - holdNote.height - INITIAL_OFFSET + STRUMLINE_SIZE / 2;
         }
         else
         {

From c5caa0331e4f167d632065e2a265b96def506bd9 Mon Sep 17 00:00:00 2001
From: MaybeMaru <97055307+MaybeMaru@users.noreply.github.com>
Date: Mon, 13 May 2024 22:17:47 +0200
Subject: [PATCH 12/72] Update Strumline.hx

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

diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index a7a051a66..07d4ab69b 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -435,7 +435,7 @@ class Strumline extends FlxSpriteGroup
 
         if (Preferences.downscroll)
         {
-          holdNote.y = this.y - holdNote.height - INITIAL_OFFSET + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - INITIAL_OFFSET - holdNote.height + STRUMLINE_SIZE / 2;
         }
         else
         {

From b10872e8e89b7e9ab404c57a26a7d8646b1921b9 Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Fri, 17 May 2024 23:51:07 +0200
Subject: [PATCH 13/72] Fix TitleState late start + enter spam crash

---
 source/funkin/ui/title/TitleState.hx | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 49bef5e4a..c9b3619e9 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -67,9 +67,11 @@ class TitleState extends MusicBeatState
     // DEBUG BULLSHIT
 
     // netConnection.addEventListener(MouseEvent.MOUSE_DOWN, overlay_onMouseDown);
-    new FlxTimer().start(1, function(tmr:FlxTimer) {
+    if (!initialized) new FlxTimer().start(1, function(tmr:FlxTimer) {
       startIntro();
     });
+    else
+      startIntro();
   }
 
   function client_onMetaData(metaData:Dynamic)
@@ -118,7 +120,7 @@ class TitleState extends MusicBeatState
 
   function startIntro():Void
   {
-    playMenuMusic();
+    if (!initialized || FlxG.sound.music == null) playMenuMusic();
 
     persistentUpdate = true;
 
@@ -231,7 +233,7 @@ class TitleState extends MusicBeatState
         overrideExisting: true,
         restartTrack: true
       });
-    // Fade from 0.0 to 0.7 over 4 seconds
+    // Fade from 0.0 to 1 over 4 seconds
     if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 1.0);
   }
 

From b6b93bb0c6686b3a89100c3b625dfd912380cda6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 17 May 2024 20:26:34 -0400
Subject: [PATCH 14/72] Added clear percent, rank name, and background text.

---
 .vscode/launch.json                           |   9 +-
 .vscode/settings.json                         |   5 +
 assets                                        |   2 +-
 source/funkin/InitState.hx                    |  24 ++
 source/funkin/play/PlayState.hx               |  18 +-
 source/funkin/play/ResultState.hx             | 288 ++++++++++--------
 .../play/components/ClearPercentCounter.hx    |  96 ++++++
 source/funkin/save/Save.hx                    |   8 +-
 .../funkin/save/migrator/SaveDataMigrator.hx  |  15 +-
 source/funkin/ui/freeplay/FreeplayState.hx    |   4 +-
 source/funkin/ui/mainmenu/MainMenuState.hx    |   3 +-
 11 files changed, 311 insertions(+), 161 deletions(-)
 create mode 100644 source/funkin/play/components/ClearPercentCounter.hx

diff --git a/.vscode/launch.json b/.vscode/launch.json
index 74f72b826..6dc1dc008 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -3,10 +3,17 @@
   "configurations": [
     {
       // Launch in native/CPP on Windows/OSX/Linux
-      "name": "Lime",
+      "name": "Lime Build+Debug",
       "type": "lime",
       "request": "launch"
     },
+    {
+      // Launch in native/CPP on Windows/OSX/Linux
+      "name": "Lime Debug (No Build)",
+      "type": "lime",
+      "request": "launch",
+      "preLaunchTask": null
+    },
     {
       // Launch in browser
       "name": "HTML5 Debug",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a8a67245b..26fe0b042 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -155,6 +155,11 @@
       "target": "hl",
       "args": ["-debug", "-DDIALOGUE"]
     },
+    {
+      "label": "Windows / Debug (Results Screen Test)",
+      "target": "windows",
+      "args": ["-debug", "-DRESULTS"]
+    },
     {
       "label": "Windows / Debug (Straight to Chart Editor)",
       "target": "windows",
diff --git a/assets b/assets
index fd112e293..ce7dabffb 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fd112e293ee0f823ee98d5b8bd8a85e934f772f6
+Subproject commit ce7dabffbebc154c9dda1f01e92dbef83e3405ab
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 00d34fadb..6a52eaf5d 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -214,6 +214,30 @@ class InitState extends FlxState
     #elseif STAGEBUILD
     // -DSTAGEBUILD
     FlxG.switchState(() -> new funkin.ui.debug.stage.StageBuilderState());
+    #elseif RESULTS
+    // -DRESULTS
+    FlxG.switchState(() -> new funkin.play.ResultState(
+      {
+        storyMode: false,
+        title: "CUM SONG",
+        isNewHighscore: true,
+        scoreData:
+          {
+            score: 1_234_567,
+            tallies:
+              {
+                sick: 130,
+                good: 69,
+                bad: 69,
+                shit: 69,
+                missed: 69,
+                combo: 69,
+                maxCombo: 69,
+                totalNotesHit: 140,
+                totalNotes: 2000,
+              }
+          },
+      }));
     #elseif ANIMDEBUG
     // -DANIMDEBUG
     FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState());
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 44ad819c4..3e1d4cac8 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2777,6 +2777,7 @@ class PlayState extends MusicBeatSubState
     deathCounter = 0;
 
     var isNewHighscore = false;
+    var prevScoreData:Null<SaveScoreData> = Save.instance.getSongScore(currentSong.id, currentDifficulty);
 
     if (currentSong != null && currentSong.validScore)
     {
@@ -2796,7 +2797,6 @@ class PlayState extends MusicBeatSubState
               totalNotesHit: Highscore.tallies.totalNotesHit,
               totalNotes: Highscore.tallies.totalNotes,
             },
-          accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
         };
 
       // adds current song data into the tallies for the level (story levels)
@@ -2833,7 +2833,7 @@ class PlayState extends MusicBeatSubState
               score: PlayStatePlaylist.campaignScore,
               tallies:
                 {
-                  // TODO: Sum up the values for the whole level!
+                  // TODO: Sum up the values for the whole week!
                   sick: 0,
                   good: 0,
                   bad: 0,
@@ -2844,7 +2844,6 @@ class PlayState extends MusicBeatSubState
                   totalNotesHit: 0,
                   totalNotes: 0,
                 },
-              accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
             };
 
           if (Save.instance.isLevelHighScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data))
@@ -2930,11 +2929,11 @@ class PlayState extends MusicBeatSubState
       {
         if (rightGoddamnNow)
         {
-          moveToResultsScreen(isNewHighscore);
+          moveToResultsScreen(isNewHighscore, prevScoreData);
         }
         else
         {
-          zoomIntoResultsScreen(isNewHighscore);
+          zoomIntoResultsScreen(isNewHighscore, prevScoreData);
         }
       }
     }
@@ -3008,7 +3007,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Play the camera zoom animation and then move to the results screen once it's done.
    */
-  function zoomIntoResultsScreen(isNewHighscore:Bool):Void
+  function zoomIntoResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void
   {
     trace('WENT TO RESULTS SCREEN!');
 
@@ -3048,7 +3047,7 @@ class PlayState extends MusicBeatSubState
     FlxTween.tween(camHUD, {alpha: 0}, 0.6,
       {
         onComplete: function(_) {
-          moveToResultsScreen(isNewHighscore);
+          moveToResultsScreen(isNewHighscore, prevScoreData);
         }
       });
 
@@ -3081,7 +3080,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Move to the results screen right goddamn now.
    */
-  function moveToResultsScreen(isNewHighscore:Bool):Void
+  function moveToResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void
   {
     persistentUpdate = false;
     vocals.stop();
@@ -3093,6 +3092,8 @@ class PlayState extends MusicBeatSubState
       {
         storyMode: PlayStatePlaylist.isStoryMode,
         title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
+        prevScoreData: prevScoreData,
+        difficultyId: currentDifficulty,
         scoreData:
           {
             score: PlayStatePlaylist.isStoryMode ? PlayStatePlaylist.campaignScore : songScore,
@@ -3108,7 +3109,6 @@ class PlayState extends MusicBeatSubState
                 totalNotesHit: talliesToUse.totalNotesHit,
                 totalNotes: talliesToUse.totalNotes,
               },
-            accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
           },
         isNewHighscore: isNewHighscore
       });
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index df3134b9d..d038b7785 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -24,6 +24,7 @@ import funkin.save.Save;
 import funkin.save.Save.SaveScoreData;
 import funkin.graphics.shaders.LeftMaskShader;
 import funkin.play.components.TallyCounter;
+import funkin.play.components.ClearPercentCounter;
 
 /**
  * The state for the results screen after a song or week is finished.
@@ -109,7 +110,7 @@ class ResultState extends MusicBeatSubState
     var soundSystem:FlxSprite = FunkinSprite.createSparrow(-15, -180, 'resultScreen/soundSystem');
     soundSystem.animation.addByPrefix("idle", "sound system", 24, false);
     soundSystem.visible = false;
-    new FlxTimer().start(0.4, _ -> {
+    new FlxTimer().start(0.3, _ -> {
       soundSystem.animation.play("idle");
       soundSystem.visible = true;
     });
@@ -118,7 +119,7 @@ class ResultState extends MusicBeatSubState
 
     switch (rank)
     {
-      case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
+      case PERFECT | PERFECT_GOLD:
         bfPerfect = new FlxAtlasSprite(370, -180, Paths.animateAtlas("resultScreen/results-bf/resultsPERFECT", "shared"));
         bfPerfect.visible = false;
         bfPerfect.zIndex = 500;
@@ -183,22 +184,7 @@ class ResultState extends MusicBeatSubState
         });
     }
 
-    var diffSpr:String = switch (PlayState.instance.currentDifficulty)
-    {
-      case 'easy':
-        'difEasy';
-      case 'normal':
-        'difNormal';
-      case 'hard':
-        'difHard';
-      case 'erect':
-        'difErect';
-      case 'nightmare':
-        'difNightmare';
-      case _:
-        'difNormal';
-    }
-
+    var diffSpr:String = 'dif${params?.difficultyId ?? 'Normal'}';
     difficulty.loadGraphic(Paths.image("resultScreen/" + diffSpr));
     add(difficulty);
 
@@ -208,7 +194,7 @@ class ResultState extends MusicBeatSubState
     speedOfTween.x = -1.0 * Math.cos(angleRad);
     speedOfTween.y = -1.0 * Math.sin(angleRad);
 
-    timerThenSongName();
+    timerThenSongName(1.0);
 
     songName.shader = maskShaderSongName;
     difficulty.shader = maskShaderDifficulty;
@@ -218,24 +204,40 @@ class ResultState extends MusicBeatSubState
 
     var blackTopBar:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/topBarBlack"));
     blackTopBar.y = -blackTopBar.height;
-    FlxTween.tween(blackTopBar, {y: 0}, 0.4, {ease: FlxEase.quartOut, startDelay: 0.5});
+    FlxTween.tween(blackTopBar, {y: 0}, 0.4, {ease: FlxEase.quartOut});
     blackTopBar.zIndex = 1010;
     add(blackTopBar);
 
     resultsAnim.animation.addByPrefix("result", "results instance 1", 24, false);
-    resultsAnim.animation.play("result");
+    resultsAnim.visible = false;
     resultsAnim.zIndex = 1200;
     add(resultsAnim);
+    new FlxTimer().start(0.3, _ -> {
+      resultsAnim.visible = true;
+      resultsAnim.animation.play("result");
+    });
 
     ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false);
     ratingsPopin.visible = false;
     ratingsPopin.zIndex = 1200;
     add(ratingsPopin);
+    new FlxTimer().start(1.0, _ -> {
+      ratingsPopin.visible = true;
+      ratingsPopin.animation.play("idle");
+    });
 
     scorePopin.animation.addByPrefix("score", "tally score", 24, false);
     scorePopin.visible = false;
     scorePopin.zIndex = 1200;
     add(scorePopin);
+    new FlxTimer().start(1.0, _ -> {
+      scorePopin.visible = true;
+      scorePopin.animation.play("score");
+      scorePopin.animation.finishCallback = anim -> {
+        score.visible = true;
+        score.animateNumbers();
+      };
+    });
 
     highscoreNew.frames = Paths.getSparrowAtlas("resultScreen/highscoreNew");
     highscoreNew.animation.addByPrefix("new", "NEW HIGHSCORE", 24);
@@ -285,13 +287,26 @@ class ResultState extends MusicBeatSubState
     for (ind => rating in ratingGrp.members)
     {
       rating.visible = false;
-      new FlxTimer().start((0.3 * ind) + 0.55, _ -> {
+      new FlxTimer().start((0.3 * ind) + 1.20, _ -> {
         rating.visible = true;
         FlxTween.tween(rating, {curNumber: rating.neededNumber}, 0.5, {ease: FlxEase.quartOut});
       });
     }
 
-    startRankTallySequence();
+    ratingsPopin.animation.finishCallback = anim -> {
+      startRankTallySequence();
+
+      if (params.isNewHighscore ?? false)
+      {
+        highscoreNew.visible = true;
+        highscoreNew.animation.play("new");
+        FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+      }
+      else
+      {
+        highscoreNew.visible = false;
+      }
+    };
 
     refresh();
 
@@ -304,48 +319,43 @@ class ResultState extends MusicBeatSubState
 
   function startRankTallySequence():Void
   {
-    clearPercentTarget = Math.floor((params.scoreData.tallies.totalNotesHit) / params.scoreData.tallies.totalNotes * 100);
-    // clearPercentTarget = 97;
+    clearPercentTarget = Math.floor((params.scoreData.tallies.sick + params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes * 100);
+    clearPercentTarget = 100;
 
-    var clearPercentText = new FlxText(FlxG.width / 2, FlxG.height / 2, 0, 'CLEAR: ${clearPercentLerp}%');
-    clearPercentText.setFormat(Paths.font('vcr.ttf'), 64, FlxColor.BLACK, FlxTextAlign.RIGHT);
-    clearPercentText.zIndex = 1000;
-    add(clearPercentText);
+    clearPercentLerp = Std.int(Math.max(0, clearPercentTarget - 36));
 
-    rankTallyTimer = new FlxTimer().start(1 / 24, _ -> {
-      // Tick up.
-      if (clearPercentLerp < clearPercentTarget)
+    var clearPercentCounter:ClearPercentCounter = new ClearPercentCounter(FlxG.width / 2 + 300, FlxG.height / 2 - 100, clearPercentTarget);
+    clearPercentCounter.curNumber = clearPercentLerp;
+    FlxTween.tween(clearPercentCounter, {curNumber: clearPercentTarget}, 1.5,
       {
-        clearPercentLerp++;
+        ease: FlxEase.quartOut,
+        onUpdate: _ -> {
+          // Only play the tick sound if the number increased.
+          if (clearPercentLerp != clearPercentCounter.curNumber)
+          {
+            clearPercentLerp = clearPercentCounter.curNumber;
+            FunkinSound.playOnce(Paths.sound('scrollMenu'));
+          }
+        },
+        onComplete: _ -> {
+          // Play confirm sound.
+          FunkinSound.playOnce(Paths.sound('confirmMenu'));
 
-        clearPercentText.text = 'CLEAR: ${clearPercentLerp}%';
-        FunkinSound.playOnce(Paths.sound('scrollMenu'));
-      }
+          // Flash background.
+          bgFlash.visible = true;
+          FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
 
-      // Don't overshoot.
-      if (clearPercentLerp > clearPercentTarget)
-      {
-        clearPercentLerp = clearPercentTarget;
-      }
+          displayRankText();
 
-      if (clearPercentLerp == clearPercentTarget)
-      {
-        if (rankTallyTimer != null)
-        {
-          rankTallyTimer.destroy();
-          rankTallyTimer = null;
+          new FlxTimer().start(2.0, _ -> {
+            // remove(clearPercentCounter);
+
+            afterRankTallySequence();
+          });
         }
-
-        // Play confirm sound.
-        FunkinSound.playOnce(Paths.sound('confirmMenu'));
-
-        new FlxTimer().start(1.0, _ -> {
-          remove(clearPercentText);
-
-          afterRankTallySequence();
-        });
-      }
-    }, 0); // 0 = Loop until stopped
+      });
+    clearPercentCounter.zIndex = 450;
+    add(clearPercentCounter);
 
     if (ratingsPopin == null)
     {
@@ -353,18 +363,15 @@ class ResultState extends MusicBeatSubState
     }
     else
     {
-      ratingsPopin.animation.play("idle");
-      ratingsPopin.visible = true;
+      // ratingsPopin.animation.play("idle");
+      // ratingsPopin.visible = true;
 
       ratingsPopin.animation.finishCallback = anim -> {
-        scorePopin.animation.play("score");
-        scorePopin.animation.finishCallback = anim -> {
-          score.visible = true;
-          score.animateNumbers();
-        };
-        scorePopin.visible = true;
+        // scorePopin.animation.play("score");
 
-        if (params.isNewHighscore)
+        // scorePopin.visible = true;
+
+        if (params.isNewHighscore ?? false)
         {
           highscoreNew.visible = true;
           highscoreNew.animation.play("new");
@@ -380,6 +387,23 @@ class ResultState extends MusicBeatSubState
     refresh();
   }
 
+  function displayRankText():Void
+  {
+    var rankTextVert:FunkinSprite = FunkinSprite.create(FlxG.width - 64, 100, rank.getVerTextAsset());
+    rankTextVert.zIndex = 2000;
+    add(rankTextVert);
+
+    for (i in 0...10)
+    {
+      var rankTextBack:FunkinSprite = FunkinSprite.create(FlxG.width / 2 - 80, 50, rank.getHorTextAsset());
+      rankTextBack.y += (rankTextBack.height * i / 2) + 10;
+      rankTextBack.zIndex = 100;
+      add(rankTextBack);
+    }
+
+    refresh();
+  }
+
   function afterRankTallySequence():Void
   {
     FunkinSound.playMusic(rank.getMusicPath(),
@@ -406,7 +430,7 @@ class ResultState extends MusicBeatSubState
 
     switch (rank)
     {
-      case PERFECT | PERFECT_GOLD | PERFECT_PLATINUM:
+      case PERFECT | PERFECT_GOLD:
         if (bfPerfect == null)
         {
           trace("Could not build PERFECT animation!");
@@ -415,17 +439,6 @@ class ResultState extends MusicBeatSubState
         {
           bfPerfect.visible = true;
           bfPerfect.playAnimation('');
-
-          new FlxTimer().start((1 / 24) * 12, _ -> {
-            bgFlash.visible = true;
-            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
-            new FlxTimer().start((1 / 24) * 2, _ ->
-              {
-                // bgFlash.alpha = 0.5;
-
-                // bgFlash.visible = false;
-              });
-          });
         }
 
       case EXCELLENT:
@@ -437,17 +450,6 @@ class ResultState extends MusicBeatSubState
         {
           bfExcellent.visible = true;
           bfExcellent.playAnimation('Intro');
-
-          new FlxTimer().start((1 / 24) * 12, _ -> {
-            bgFlash.visible = true;
-            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
-            new FlxTimer().start((1 / 24) * 2, _ ->
-              {
-                // bgFlash.alpha = 0.5;
-
-                // bgFlash.visible = false;
-              });
-          });
         }
 
       case SHIT:
@@ -459,17 +461,6 @@ class ResultState extends MusicBeatSubState
         {
           bfShit.visible = true;
           bfShit.playAnimation('Intro');
-
-          new FlxTimer().start((1 / 24) * 12, _ -> {
-            bgFlash.visible = true;
-            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
-            new FlxTimer().start((1 / 24) * 2, _ ->
-              {
-                // bgFlash.alpha = 0.5;
-
-                // bgFlash.visible = false;
-              });
-          });
         }
 
       case GREAT | GOOD:
@@ -482,17 +473,6 @@ class ResultState extends MusicBeatSubState
           bfGood.animation.play('fall');
           bfGood.visible = true;
 
-          new FlxTimer().start((1 / 24) * 12, _ -> {
-            bgFlash.visible = true;
-            FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
-            new FlxTimer().start((1 / 24) * 2, _ ->
-              {
-                // bgFlash.alpha = 0.5;
-
-                // bgFlash.visible = false;
-              });
-          });
-
           new FlxTimer().start((1 / 24) * 22, _ -> {
             // plays about 22 frames (at 24fps timing) after bf spawns in
             if (gfGood != null)
@@ -510,7 +490,7 @@ class ResultState extends MusicBeatSubState
     }
   }
 
-  function timerThenSongName():Void
+  function timerThenSongName(timerLength:Float = 3.0):Void
   {
     movingSongStuff = false;
 
@@ -526,7 +506,7 @@ class ResultState extends MusicBeatSubState
     FlxTween.tween(songName, {y: diffYTween - 35 - fuckedupnumber}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.9});
     songName.x = (difficulty.x + difficulty.width) + 20;
 
-    new FlxTimer().start(3, _ -> {
+    new FlxTimer().start(timerLength, _ -> {
       var tempSpeed = FlxPoint.get(speedOfTween.x, speedOfTween.y);
 
       speedOfTween.set(0, 0);
@@ -600,33 +580,29 @@ class ResultState extends MusicBeatSubState
   public static function calculateRank(params:ResultsStateParams):ResultRank
   {
     // Perfect (Platinum) is a Sick Full Clear
-    var isPerfectPlat = (params.scoreData.tallies.sick + params.scoreData.tallies.good) == params.scoreData.tallies.totalNotes
-      && params.scoreData.tallies.sick / params.scoreData.tallies.totalNotes >= Constants.RANK_PERFECT_PLAT_THRESHOLD;
-    if (isPerfectPlat) return ResultRank.PERFECT_PLATINUM;
-
-    // Perfect (Gold) is an 85% Sick Full Clear
-    var isPerfectGold = (params.scoreData.tallies.sick + params.scoreData.tallies.good) == params.scoreData.tallies.totalNotes
-      && params.scoreData.tallies.sick / params.scoreData.tallies.totalNotes >= Constants.RANK_PERFECT_GOLD_THRESHOLD;
+    var isPerfectGold = params.scoreData.tallies.sick == params.scoreData.tallies.totalNotes;
     if (isPerfectGold) return ResultRank.PERFECT_GOLD;
 
     // Else, use the standard grades
 
+    // Grade % (only good and sick), 1.00 is a full combo
+    var grade = (params.scoreData.tallies.sick + params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes;
     // Clear % (including bad and shit). 1.00 is a full clear but not a full combo
     var clear = (params.scoreData.tallies.totalNotesHit) / params.scoreData.tallies.totalNotes;
 
-    if (clear == Constants.RANK_PERFECT_THRESHOLD)
+    if (grade == Constants.RANK_PERFECT_THRESHOLD)
     {
       return ResultRank.PERFECT;
     }
-    else if (clear >= Constants.RANK_EXCELLENT_THRESHOLD)
+    else if (grade >= Constants.RANK_EXCELLENT_THRESHOLD)
     {
       return ResultRank.EXCELLENT;
     }
-    else if (clear >= Constants.RANK_GREAT_THRESHOLD)
+    else if (grade >= Constants.RANK_GREAT_THRESHOLD)
     {
       return ResultRank.GREAT;
     }
-    else if (clear >= Constants.RANK_GOOD_THRESHOLD)
+    else if (grade >= Constants.RANK_GOOD_THRESHOLD)
     {
       return ResultRank.GOOD;
     }
@@ -639,7 +615,6 @@ class ResultState extends MusicBeatSubState
 
 enum abstract ResultRank(String)
 {
-  var PERFECT_PLATINUM;
   var PERFECT_GOLD;
   var PERFECT;
   var EXCELLENT;
@@ -651,8 +626,6 @@ enum abstract ResultRank(String)
   {
     switch (abstract)
     {
-      case PERFECT_PLATINUM:
-        return 'resultsPERFECT';
       case PERFECT_GOLD:
         return 'resultsPERFECT';
       case PERFECT:
@@ -665,6 +638,8 @@ enum abstract ResultRank(String)
         return 'resultsNORMAL';
       case SHIT:
         return 'resultsSHIT';
+      default:
+        return 'resultsNORMAL';
     }
   }
 
@@ -672,8 +647,6 @@ enum abstract ResultRank(String)
   {
     switch (abstract)
     {
-      case PERFECT_PLATINUM:
-        return true;
       case PERFECT_GOLD:
         return true;
       case PERFECT:
@@ -690,6 +663,48 @@ enum abstract ResultRank(String)
         return false;
     }
   }
+
+  public function getHorTextAsset()
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD:
+        return 'resultScreen/rankText/rankScrollPERFECT';
+      case PERFECT:
+        return 'resultScreen/rankText/rankScrollPERFECT';
+      case EXCELLENT:
+        return 'resultScreen/rankText/rankScrollEXCELLENT';
+      case GREAT:
+        return 'resultScreen/rankText/rankScrollGREAT';
+      case GOOD:
+        return 'resultScreen/rankText/rankScrollGOOD';
+      case SHIT:
+        return 'resultScreen/rankText/rankScrollLOSS';
+      default:
+        return 'resultScreen/rankText/rankScrollGOOD';
+    }
+  }
+
+  public function getVerTextAsset()
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD:
+        return 'resultScreen/rankText/rankTextPERFECT';
+      case PERFECT:
+        return 'resultScreen/rankText/rankTextPERFECT';
+      case EXCELLENT:
+        return 'resultScreen/rankText/rankTextEXCELLENT';
+      case GREAT:
+        return 'resultScreen/rankText/rankTextGREAT';
+      case GOOD:
+        return 'resultScreen/rankText/rankTextGOOD';
+      case SHIT:
+        return 'resultScreen/rankText/rankTextLOSS';
+      default:
+        return 'resultScreen/rankText/rankTextGOOD';
+    }
+  }
 }
 
 typedef ResultsStateParams =
@@ -707,10 +722,21 @@ typedef ResultsStateParams =
   /**
    * Whether the displayed score is a new highscore
    */
-  var isNewHighscore:Bool;
+  var ?isNewHighscore:Bool;
+
+  /**
+   * The difficulty ID of the song/week we just played.
+   * @default Normal
+   */
+  var ?difficultyId:String;
 
   /**
    * The score, accuracy, and judgements.
    */
   var scoreData:SaveScoreData;
+
+  /**
+   * The previous score data, used for rank comparision.
+   */
+  var ?prevScoreData:SaveScoreData;
 };
diff --git a/source/funkin/play/components/ClearPercentCounter.hx b/source/funkin/play/components/ClearPercentCounter.hx
new file mode 100644
index 000000000..4c03ec3a9
--- /dev/null
+++ b/source/funkin/play/components/ClearPercentCounter.hx
@@ -0,0 +1,96 @@
+package funkin.play.components;
+
+import funkin.graphics.FunkinSprite;
+import flixel.FlxSprite;
+import flixel.group.FlxGroup.FlxTypedGroup;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+import flixel.math.FlxMath;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import flixel.text.FlxText.FlxTextAlign;
+import funkin.util.MathUtil;
+
+/**
+ * Numerical counters used to display the clear percent.
+ */
+class ClearPercentCounter extends FlxTypedSpriteGroup<FlxSprite>
+{
+  public var curNumber:Int = 0;
+  public var neededNumber:Int = 0;
+
+  public function new(x:Float, y:Float, neededNumber:Int = 0)
+  {
+    super(x, y);
+
+    this.neededNumber = neededNumber;
+
+    var clearPercentText:FunkinSprite = FunkinSprite.create(0, 0, 'resultScreen/clearPercent/clearPercentText');
+    add(clearPercentText);
+
+    if (curNumber == neededNumber) drawNumbers();
+  }
+
+  var tmr:Float = 0;
+
+  override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (curNumber < neededNumber) drawNumbers();
+  }
+
+  function drawNumbers()
+  {
+    var seperatedScore:Array<Int> = [];
+    var tempCombo:Int = Math.round(curNumber);
+
+    var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber))));
+
+    while (tempCombo != 0)
+    {
+      seperatedScore.push(tempCombo % 10);
+      tempCombo = Math.floor(tempCombo / 10);
+    }
+
+    if (seperatedScore.length == 0) seperatedScore.push(0);
+
+    seperatedScore.reverse();
+
+    for (ind => num in seperatedScore)
+    {
+      var digitIndex = ind + 1;
+      if (digitIndex >= members.length)
+      {
+        var xPos = (digitIndex - 1) * (72 * this.scale.x);
+        var yPos = 72;
+        // Three digits = LRL so two different numbers aren't adjacent to each other.
+        var variant:Bool = (fullNumberDigits % 2 != 0) ? (digitIndex % 2 == 0) : (digitIndex % 2 == 1);
+        var numb:ClearPercentNumber = new ClearPercentNumber(xPos, yPos, num);
+        numb.scale.set(this.scale.x, this.scale.y);
+        add(numb);
+      }
+      else
+      {
+        members[digitIndex].animation.play(Std.string(num));
+      }
+    }
+  }
+}
+
+class ClearPercentNumber extends FlxSprite
+{
+  public function new(x:Float, y:Float, digit:Int, variant:Bool = false)
+  {
+    super(x, y);
+
+    frames = Paths.getSparrowAtlas('resultScreen/clearPercent/clearPercentNumber${variant ? 'Right' : 'Left'}');
+
+    for (i in 0...10)
+    {
+      animation.addByPrefix('$i', 'number $i 0', 24, false);
+    }
+
+    animation.play('$digit');
+    updateHitbox();
+  }
+}
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index acbe59edd..08614e307 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -53,7 +53,8 @@ class Save
   public function new(?data:RawSaveData)
   {
     if (data == null) this.data = Save.getDefault();
-    else this.data = data;
+    else
+      this.data = data;
   }
 
   public static function getDefault():RawSaveData
@@ -809,11 +810,6 @@ typedef SaveScoreData =
    * The count of each judgement hit.
    */
   var tallies:SaveScoreTallyData;
-
-  /**
-   * The accuracy percentage.
-   */
-  var accuracy:Float;
 }
 
 typedef SaveScoreTallyData =
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 3ed59e726..9e308cb10 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -118,7 +118,7 @@ class SaveDataMigrator
     var scoreDataEasy:SaveScoreData =
       {
         score: inputSaveData.songScores.get('${levelId}-easy') ?? 0,
-        accuracy: inputSaveData.songCompletion.get('${levelId}-easy') ?? 0.0,
+        // accuracy: inputSaveData.songCompletion.get('${levelId}-easy') ?? 0.0,
         tallies:
           {
             sick: 0,
@@ -137,7 +137,7 @@ class SaveDataMigrator
     var scoreDataNormal:SaveScoreData =
       {
         score: inputSaveData.songScores.get('${levelId}') ?? 0,
-        accuracy: inputSaveData.songCompletion.get('${levelId}') ?? 0.0,
+        // accuracy: inputSaveData.songCompletion.get('${levelId}') ?? 0.0,
         tallies:
           {
             sick: 0,
@@ -156,7 +156,7 @@ class SaveDataMigrator
     var scoreDataHard:SaveScoreData =
       {
         score: inputSaveData.songScores.get('${levelId}-hard') ?? 0,
-        accuracy: inputSaveData.songCompletion.get('${levelId}-hard') ?? 0.0,
+        // accuracy: inputSaveData.songCompletion.get('${levelId}-hard') ?? 0.0,
         tallies:
           {
             sick: 0,
@@ -178,7 +178,6 @@ class SaveDataMigrator
     var scoreDataEasy:SaveScoreData =
       {
         score: 0,
-        accuracy: 0,
         tallies:
           {
             sick: 0,
@@ -196,14 +195,13 @@ class SaveDataMigrator
     for (songId in songIds)
     {
       scoreDataEasy.score = Std.int(Math.max(scoreDataEasy.score, inputSaveData.songScores.get('${songId}-easy') ?? 0));
-      scoreDataEasy.accuracy = Math.max(scoreDataEasy.accuracy, inputSaveData.songCompletion.get('${songId}-easy') ?? 0.0);
+      // scoreDataEasy.accuracy = Math.max(scoreDataEasy.accuracy, inputSaveData.songCompletion.get('${songId}-easy') ?? 0.0);
     }
     result.setSongScore(songIds[0], 'easy', scoreDataEasy);
 
     var scoreDataNormal:SaveScoreData =
       {
         score: 0,
-        accuracy: 0,
         tallies:
           {
             sick: 0,
@@ -221,14 +219,13 @@ class SaveDataMigrator
     for (songId in songIds)
     {
       scoreDataNormal.score = Std.int(Math.max(scoreDataNormal.score, inputSaveData.songScores.get('${songId}') ?? 0));
-      scoreDataNormal.accuracy = Math.max(scoreDataNormal.accuracy, inputSaveData.songCompletion.get('${songId}') ?? 0.0);
+      // scoreDataNormal.accuracy = Math.max(scoreDataNormal.accuracy, inputSaveData.songCompletion.get('${songId}') ?? 0.0);
     }
     result.setSongScore(songIds[0], 'normal', scoreDataNormal);
 
     var scoreDataHard:SaveScoreData =
       {
         score: 0,
-        accuracy: 0,
         tallies:
           {
             sick: 0,
@@ -246,7 +243,7 @@ class SaveDataMigrator
     for (songId in songIds)
     {
       scoreDataHard.score = Std.int(Math.max(scoreDataHard.score, inputSaveData.songScores.get('${songId}-hard') ?? 0));
-      scoreDataHard.accuracy = Math.max(scoreDataHard.accuracy, inputSaveData.songCompletion.get('${songId}-hard') ?? 0.0);
+      // scoreDataHard.accuracy = Math.max(scoreDataHard.accuracy, inputSaveData.songCompletion.get('${songId}-hard') ?? 0.0);
     }
     result.setSongScore(songIds[0], 'hard', scoreDataHard);
   }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 239068288..dbf223dc8 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -998,7 +998,7 @@ class FreeplayState extends MusicBeatSubState
     {
       var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty);
       intendedScore = songScore?.score ?? 0;
-      intendedCompletion = songScore?.accuracy ?? 0.0;
+      intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes);
       rememberedDifficulty = currentDifficulty;
     }
     else
@@ -1196,7 +1196,7 @@ class FreeplayState extends MusicBeatSubState
     {
       var songScore:SaveScoreData = Save.instance.getSongScore(daSongCapsule.songData.songId, currentDifficulty);
       intendedScore = songScore?.score ?? 0;
-      intendedCompletion = songScore?.accuracy ?? 0.0;
+      intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes);
       diffIdsCurrent = daSongCapsule.songData.songDifficulties;
       rememberedSongId = daSongCapsule.songData.songId;
       changeDiff();
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index 466dbbb90..fcff41dfd 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -355,8 +355,7 @@ class MainMenuState extends MusicBeatState
               maxCombo: 0,
               totalNotesHit: 0,
               totalNotes: 0,
-            },
-          accuracy: 0,
+            }
         });
     }
     #end

From e0867643d04dd5624d0684fe8eca0fd7fd2efab9 Mon Sep 17 00:00:00 2001
From: lemz <ismael.amjad07@gmail.com>
Date: Sun, 19 May 2024 01:39:30 +0200
Subject: [PATCH 15/72] fix the setgraphicsize stuff

---
 source/funkin/ui/transition/LoadingState.hx | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 95c378b24..bc26ad97a 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -57,8 +57,7 @@ class LoadingState extends MusicBeatSubState
     funkay.scrollFactor.set();
     funkay.screenCenter();
 
-    loadBar = new FunkinSprite(0, FlxG.height - 20).makeSolidColor(FlxG.width, 10, 0xFFff16d2);
-    loadBar.screenCenter(X);
+    loadBar = new FunkinSprite(0, FlxG.height - 20).makeSolidColor(0, 10, 0xFFff16d2);
     add(loadBar);
 
     initSongsManifest().onComplete(function(lib) {
@@ -163,8 +162,15 @@ class LoadingState extends MusicBeatSubState
       targetShit = FlxMath.remapToRange(callbacks.numRemaining / callbacks.length, 1, 0, 0, 1);
 
       var lerpWidth:Int = Std.int(FlxMath.lerp(loadBar.width, FlxG.width * targetShit, 0.2));
-      loadBar.setGraphicSize(lerpWidth, loadBar.height);
-      loadBar.updateHitbox();
+      // this if-check prevents the setGraphicSize function
+      // from setting the width of the loadBar to the height of the loadBar
+      // this is a behaviour that is implemented in the setGraphicSize function
+      // if the width parameter is equal to 0
+      if (lerpWidth > 0)
+      {
+        loadBar.setGraphicSize(lerpWidth, loadBar.height);
+        loadBar.updateHitbox();
+      }
       FlxG.watch.addQuick('percentage?', callbacks.numRemaining / callbacks.length);
     }
 

From faf7a0643cd83bbf99ac99e302c6aaf2bcdebd30 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 19 May 2024 01:47:36 -0400
Subject: [PATCH 16/72] Tinkered with ghost tapping some, leaving it off for
 now tho.

---
 source/funkin/play/PlayState.hx       | 24 ++++++++++++++----------
 source/funkin/play/notes/Strumline.hx | 14 ++++++++++++++
 2 files changed, 28 insertions(+), 10 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 44ad819c4..5b95c467c 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2298,8 +2298,6 @@ class PlayState extends MusicBeatSubState
     var notesInRange:Array<NoteSprite> = playerStrumline.getNotesMayHit();
     var holdNotesInRange:Array<SustainTrail> = playerStrumline.getHoldNotesHitOrMissed();
 
-    // If there are notes in range, pressing a key will cause a ghost miss.
-
     var notesByDirection:Array<Array<NoteSprite>> = [[], [], [], []];
 
     for (note in notesInRange)
@@ -2321,17 +2319,27 @@ class PlayState extends MusicBeatSubState
 
         // Play the strumline animation.
         playerStrumline.playPress(input.noteDirection);
+        trace('PENALTY Score: ${songScore}');
       }
-      else if (Constants.GHOST_TAPPING && (holdNotesInRange.length + notesInRange.length > 0) && notesInDirection.length == 0)
+      else if (Constants.GHOST_TAPPING && (!playerStrumline.mayGhostTap()) && notesInDirection.length == 0)
       {
-        // Pressed a wrong key with no notes nearby AND with notes in a different direction available.
+        // Pressed a wrong key with notes visible on-screen.
         // Perform a ghost miss (anti-spam).
         ghostNoteMiss(input.noteDirection, notesInRange.length > 0);
 
         // Play the strumline animation.
         playerStrumline.playPress(input.noteDirection);
+        trace('PENALTY Score: ${songScore}');
       }
-      else if (notesInDirection.length > 0)
+      else if (notesInDirection.length == 0)
+      {
+        // Press a key with no penalty.
+
+        // Play the strumline animation.
+        playerStrumline.playPress(input.noteDirection);
+        trace('NO PENALTY Score: ${songScore}');
+      }
+      else
       {
         // Choose the first note, deprioritizing low priority notes.
         var targetNote:Null<NoteSprite> = notesInDirection.find((note) -> !note.lowPriority);
@@ -2341,17 +2349,13 @@ class PlayState extends MusicBeatSubState
         // Judge and hit the note.
         trace('Hit note! ${targetNote.noteData}');
         goodNoteHit(targetNote, input);
+        trace('Score: ${songScore}');
 
         notesInDirection.remove(targetNote);
 
         // Play the strumline animation.
         playerStrumline.playConfirm(input.noteDirection);
       }
-      else
-      {
-        // Play the strumline animation.
-        playerStrumline.playPress(input.noteDirection);
-      }
     }
 
     while (inputReleaseQueue.length > 0)
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 6a18f17d5..220b6723c 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -171,6 +171,20 @@ class Strumline extends FlxSpriteGroup
     updateNotes();
   }
 
+  /**
+   * Returns `true` if no notes are in range of the strumline and the player can spam without penalty.
+   */
+  public function mayGhostTap():Bool
+  {
+    // TODO: Refine this. Only querying "can be hit" is too tight but "is being rendered" is too loose.
+    // Also, if you just hit a note, there should be a (short) period where this is off so you can't spam.
+
+    // If there are any notes on screen, we can't ghost tap.
+    return notes.members.filter(function(note:NoteSprite) {
+      return note != null && note.alive && !note.hasBeenHit;
+    }).length == 0;
+  }
+
   /**
    * Return notes that are within `Constants.HIT_WINDOW` ms of the strumline.
    * @return An array of `NoteSprite` objects.

From 228ac66cc2e9966c0eae1f4bc050477ff9cba93f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 19 May 2024 01:48:51 -0400
Subject: [PATCH 17/72] Credit the song's charter in the pause menu.

---
 assets                                        |  2 +-
 source/funkin/data/song/SongData.hx           |  3 +
 source/funkin/data/song/SongRegistry.hx       |  2 +-
 .../data/song/importer/FNFLegacyImporter.hx   |  2 +-
 source/funkin/play/PauseSubState.hx           | 89 +++++++++++++++++--
 source/funkin/play/song/Song.hx               | 15 ++++
 .../ui/debug/charting/ChartEditorState.hx     |  2 +-
 .../toolboxes/ChartEditorMetadataToolbox.hx   | 16 ++++
 source/funkin/util/Constants.hx               |  5 ++
 9 files changed, 127 insertions(+), 9 deletions(-)

diff --git a/assets b/assets
index fd112e293..778e16705 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fd112e293ee0f823ee98d5b8bd8a85e934f772f6
+Subproject commit 778e16705b30af85087f627594c22f4b5ba6141a
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 26380947a..bd25139a7 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -30,6 +30,9 @@ class SongMetadata implements ICloneable<SongMetadata>
   @:default("Unknown")
   public var artist:String;
 
+  @:optional
+  public var charter:Null<String> = null;
+
   @:optional
   @:default(96)
   public var divisions:Null<Int>; // Optional field
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index 277dcd9e1..a3305c4ec 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -20,7 +20,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
    * Handle breaking changes by incrementing this value
    * and adding migration to the `migrateStageData()` function.
    */
-  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.2";
+  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.3";
 
   public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
 
diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx
index ab2abda8e..fdfac7f72 100644
--- a/source/funkin/data/song/importer/FNFLegacyImporter.hx
+++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx
@@ -36,7 +36,7 @@ class FNFLegacyImporter
   {
     trace('Migrating song metadata from FNF Legacy.');
 
-    var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default');
+    var songMetadata:SongMetadata = new SongMetadata('Import', Constants.DEFAULT_ARTIST, 'default');
 
     var hadError:Bool = false;
 
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index fb9d9b4e2..c345871a9 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -101,6 +101,10 @@ class PauseSubState extends MusicBeatSubState
    */
   static final MUSIC_FINAL_VOLUME:Float = 0.75;
 
+  static final CHARTER_FADE_DELAY:Float = 15.0;
+
+  static final CHARTER_FADE_DURATION:Float = 0.75;
+
   /**
    * Defines which pause music to use.
    */
@@ -163,6 +167,12 @@ class PauseSubState extends MusicBeatSubState
    */
   var metadataDeaths:FlxText;
 
+  /**
+   * A text object which displays the current song's artist.
+   * Fades to the charter after a period before fading back.
+   */
+  var metadataArtist:FlxText;
+
   /**
    * The actual text objects for the menu entries.
    */
@@ -203,6 +213,8 @@ class PauseSubState extends MusicBeatSubState
     regenerateMenu();
 
     transitionIn();
+
+    startCharterTimer();
   }
 
   /**
@@ -222,6 +234,8 @@ class PauseSubState extends MusicBeatSubState
   public override function destroy():Void
   {
     super.destroy();
+    charterFadeTween.destroy();
+    charterFadeTween = null;
     pauseMusic.stop();
   }
 
@@ -270,16 +284,25 @@ class PauseSubState extends MusicBeatSubState
     metadata.scrollFactor.set(0, 0);
     add(metadata);
 
-    var metadataSong:FlxText = new FlxText(20, 15, FlxG.width - 40, 'Song Name - Artist');
+    var metadataSong:FlxText = new FlxText(20, 15, FlxG.width - 40, 'Song Name');
     metadataSong.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
     if (PlayState.instance?.currentChart != null)
     {
-      metadataSong.text = '${PlayState.instance.currentChart.songName} - ${PlayState.instance.currentChart.songArtist}';
+      metadataSong.text = '${PlayState.instance.currentChart.songName}';
     }
     metadataSong.scrollFactor.set(0, 0);
     metadata.add(metadataSong);
 
-    var metadataDifficulty:FlxText = new FlxText(20, 15 + 32, FlxG.width - 40, 'Difficulty: ');
+    metadataArtist = new FlxText(20, metadataSong.y + 32, FlxG.width - 40, 'Artist: ${Constants.DEFAULT_ARTIST}');
+    metadataArtist.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
+    if (PlayState.instance?.currentChart != null)
+    {
+      metadataArtist.text = 'Artist: ${PlayState.instance.currentChart.songArtist}';
+    }
+    metadataArtist.scrollFactor.set(0, 0);
+    metadata.add(metadataArtist);
+
+    var metadataDifficulty:FlxText = new FlxText(20, metadataArtist.y + 32, FlxG.width - 40, 'Difficulty: ');
     metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
     if (PlayState.instance?.currentDifficulty != null)
     {
@@ -288,12 +311,12 @@ class PauseSubState extends MusicBeatSubState
     metadataDifficulty.scrollFactor.set(0, 0);
     metadata.add(metadataDifficulty);
 
-    metadataDeaths = new FlxText(20, 15 + 64, FlxG.width - 40, '${PlayState.instance?.deathCounter} Blue Balls');
+    metadataDeaths = new FlxText(20, metadataDifficulty.y + 32, FlxG.width - 40, '${PlayState.instance?.deathCounter} Blue Balls');
     metadataDeaths.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
     metadataDeaths.scrollFactor.set(0, 0);
     metadata.add(metadataDeaths);
 
-    metadataPractice = new FlxText(20, 15 + 96, FlxG.width - 40, 'PRACTICE MODE');
+    metadataPractice = new FlxText(20, metadataDeaths.y + 32, FlxG.width - 40, 'PRACTICE MODE');
     metadataPractice.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
     metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false;
     metadataPractice.scrollFactor.set(0, 0);
@@ -302,6 +325,62 @@ class PauseSubState extends MusicBeatSubState
     updateMetadataText();
   }
 
+  var charterFadeTween:Null<FlxTween> = null;
+
+  function startCharterTimer():Void
+  {
+    charterFadeTween = FlxTween.tween(metadataArtist, {alpha: 0.0}, CHARTER_FADE_DURATION,
+      {
+        startDelay: CHARTER_FADE_DELAY,
+        ease: FlxEase.quartOut,
+        onComplete: (_) -> {
+          if (PlayState.instance?.currentChart != null)
+          {
+            metadataArtist.text = 'Charter: ${PlayState.instance.currentChart.charter ?? 'Unknown'}';
+          }
+          else
+          {
+            metadataArtist.text = 'Charter: ${Constants.DEFAULT_CHARTER}';
+          }
+
+          FlxTween.tween(metadataArtist, {alpha: 1.0}, CHARTER_FADE_DURATION,
+            {
+              ease: FlxEase.quartOut,
+              onComplete: (_) -> {
+                startArtistTimer();
+              }
+            });
+        }
+      });
+  }
+
+  function startArtistTimer():Void
+  {
+    charterFadeTween = FlxTween.tween(metadataArtist, {alpha: 0.0}, CHARTER_FADE_DURATION,
+      {
+        startDelay: CHARTER_FADE_DELAY,
+        ease: FlxEase.quartOut,
+        onComplete: (_) -> {
+          if (PlayState.instance?.currentChart != null)
+          {
+            metadataArtist.text = 'Artist: ${PlayState.instance.currentChart.songArtist}';
+          }
+          else
+          {
+            metadataArtist.text = 'Artist: ${Constants.DEFAULT_ARTIST}';
+          }
+
+          FlxTween.tween(metadataArtist, {alpha: 1.0}, CHARTER_FADE_DURATION,
+            {
+              ease: FlxEase.quartOut,
+              onComplete: (_) -> {
+                startCharterTimer();
+              }
+            });
+        }
+      });
+  }
+
   /**
    * Perform additional animations to transition the pause menu in when it is first displayed.
    */
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 23d8d2198..5da78e9df 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -120,6 +120,18 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return DEFAULT_ARTIST;
   }
 
+  /**
+   * The artist of the song.
+   */
+  public var charter(get, never):String;
+
+  function get_charter():String
+  {
+    if (_data != null) return _data?.charter ?? 'Unknown';
+    if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.charter ?? 'Unknown';
+    return Constants.DEFAULT_CHARTER;
+  }
+
   /**
    * @param id The ID of the song to load.
    * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded.
@@ -270,6 +282,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
         difficulty.songName = metadata.songName;
         difficulty.songArtist = metadata.artist;
+        difficulty.charter = metadata.charter ?? Constants.DEFAULT_CHARTER;
         difficulty.timeFormat = metadata.timeFormat;
         difficulty.divisions = metadata.divisions;
         difficulty.timeChanges = metadata.timeChanges;
@@ -334,6 +347,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
         {
           difficulty.songName = metadata.songName;
           difficulty.songArtist = metadata.artist;
+          difficulty.charter = metadata.charter ?? Constants.DEFAULT_CHARTER;
           difficulty.timeFormat = metadata.timeFormat;
           difficulty.divisions = metadata.divisions;
           difficulty.timeChanges = metadata.timeChanges;
@@ -586,6 +600,7 @@ class SongDifficulty
 
   public var songName:String = Constants.DEFAULT_SONGNAME;
   public var songArtist:String = Constants.DEFAULT_ARTIST;
+  public var charter:String = Constants.DEFAULT_CHARTER;
   public var timeFormat:SongTimeFormat = Constants.DEFAULT_TIMEFORMAT;
   public var divisions:Null<Int> = null;
   public var looped:Bool = false;
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index a313981f4..980f5db4f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1270,7 +1270,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var result:Null<SongMetadata> = songMetadata.get(selectedVariation);
     if (result == null)
     {
-      result = new SongMetadata('DadBattle', 'Kawai Sprite', selectedVariation);
+      result = new SongMetadata('Default Song Name', Constants.DEFAULT_ARTIST, selectedVariation);
       songMetadata.set(selectedVariation, result);
     }
     return result;
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
index f85307c64..80a421d80 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
@@ -29,6 +29,7 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
 {
   var inputSongName:TextField;
   var inputSongArtist:TextField;
+  var inputSongCharter:TextField;
   var inputStage:DropDown;
   var inputNoteStyle:DropDown;
   var buttonCharacterPlayer:Button;
@@ -89,6 +90,20 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
       }
     };
 
+    inputSongCharter.onChange = function(event:UIEvent) {
+      var valid:Bool = event.target.text != null && event.target.text != '';
+
+      if (valid)
+      {
+        inputSongCharter.removeClass('invalid-value');
+        chartEditorState.currentSongMetadata.charter = event.target.text;
+      }
+      else
+      {
+        chartEditorState.currentSongMetadata.charter = null;
+      }
+    };
+
     inputStage.onChange = function(event:UIEvent) {
       var valid:Bool = event.data != null && event.data.id != null;
 
@@ -176,6 +191,7 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
 
     inputSongName.value = chartEditorState.currentSongMetadata.songName;
     inputSongArtist.value = chartEditorState.currentSongMetadata.artist;
+    inputSongCharter.value = chartEditorState.currentSongMetadata.charter;
     inputStage.value = chartEditorState.currentSongMetadata.playData.stage;
     inputNoteStyle.value = chartEditorState.currentSongMetadata.playData.noteStyle;
     inputBPM.value = chartEditorState.currentSongMetadata.timeChanges[0].bpm;
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 2f3b570b3..4e706c612 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -248,6 +248,11 @@ class Constants
    */
   public static final DEFAULT_ARTIST:String = 'Unknown';
 
+  /**
+   * The default charter for songs.
+   */
+  public static final DEFAULT_CHARTER:String = 'Unknown';
+
   /**
    * The default note style for songs.
    */

From 13595fca700d99c0a472687b20b1ba6170eec58f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 19 May 2024 01:49:06 -0400
Subject: [PATCH 18/72] Changelog entry for chart metadata

---
 source/funkin/data/song/CHANGELOG.md | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/source/funkin/data/song/CHANGELOG.md b/source/funkin/data/song/CHANGELOG.md
index 3cd3af070..4f1c66ade 100644
--- a/source/funkin/data/song/CHANGELOG.md
+++ b/source/funkin/data/song/CHANGELOG.md
@@ -5,6 +5,10 @@ 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.3]
+### Added
+- Added `charter` field to denote authorship of a chart.
+
 ## [2.2.2]
 ### Added
 - Added `playData.previewStart` and `playData.previewEnd` fields to specify when in the song should the song's audio should be played as a preview in Freeplay.

From dcfc51cdcd53cd52b1b5ee34f0df6778afa1f2b9 Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Mon, 20 May 2024 01:37:35 +0200
Subject: [PATCH 19/72] Fix Charting Sustain Trails Inverted

---
 .../ui/debug/charting/components/ChartEditorHoldNoteSprite.hx   | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
index aeb6dd0e4..7c20358a4 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
@@ -36,6 +36,8 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     zoom *= 0.7;
     zoom *= ChartEditorState.GRID_SIZE / Strumline.STRUMLINE_SIZE;
 
+    flipY = false;
+
     setup();
   }
 

From 9a18e3fde6ee779ca391dece4a0d94e79f584501 Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Mon, 20 May 2024 01:38:52 +0200
Subject: [PATCH 20/72] Fix Charting Dragging Sustain Trails

---
 source/funkin/ui/debug/charting/ChartEditorState.hx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index b75cd8bf1..d426abaaf 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -4566,8 +4566,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             }
 
             gridGhostHoldNote.visible = true;
-            gridGhostHoldNote.noteData = gridGhostNote.noteData;
-            gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
+            gridGhostHoldNote.noteData = currentPlaceNoteData;
+            gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection();
             gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true);
 
             gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);

From 1050176b274014a17b7714ef75a26c8a8684cb46 Mon Sep 17 00:00:00 2001
From: richTrash21 <superboss865@gmail.com>
Date: Mon, 20 May 2024 23:52:48 +0400
Subject: [PATCH 21/72] main menu camera fix

---
 source/funkin/ui/debug/DebugMenuSubState.hx | 1 -
 source/funkin/ui/mainmenu/MainMenuState.hx  | 5 ++++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx
index 6d6e73e80..f8b1be9d2 100644
--- a/source/funkin/ui/debug/DebugMenuSubState.hx
+++ b/source/funkin/ui/debug/DebugMenuSubState.hx
@@ -62,7 +62,6 @@ class DebugMenuSubState extends MusicBeatSubState
     #if sys
     createItem("OPEN CRASH LOG FOLDER", openLogFolder);
     #end
-    FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y));
     FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y + 500));
   }
 
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index 7a21a6e8f..9af4e299f 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -49,6 +49,8 @@ class MainMenuState extends MusicBeatState
     DiscordClient.changePresence("In the Menus", null);
     #end
 
+    FlxG.cameras.reset(new FunkinCamera('mainMenu'));
+
     transIn = FlxTransitionableState.defaultTransIn;
     transOut = FlxTransitionableState.defaultTransOut;
 
@@ -170,7 +172,6 @@ class MainMenuState extends MusicBeatState
 
   function resetCamStuff():Void
   {
-    FlxG.cameras.reset(new FunkinCamera('mainMenu'));
     FlxG.camera.follow(camFollow, null, 0.06);
     FlxG.camera.snapToTarget();
   }
@@ -329,6 +330,8 @@ class MainMenuState extends MusicBeatState
       persistentUpdate = false;
 
       FlxG.state.openSubState(new DebugMenuSubState());
+      // reset camera when debug menu is closed
+      subStateClosed.addOnce(_ -> resetCamStuff());
     }
     #end
 

From f3868c2ee8d9dcf9488c349d8509545f0113f010 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 21 May 2024 02:23:21 -0400
Subject: [PATCH 22/72] An attempt at an HTML5 save data fix

---
 hmm.json                                      |  2 +-
 source/funkin/save/Save.hx                    |  6 ++--
 .../funkin/save/migrator/SaveDataMigrator.hx  |  7 ++--
 source/funkin/util/StructureUtil.hx           | 32 ++++++++++++++++---
 source/funkin/util/VersionUtil.hx             | 19 +++++++++++
 5 files changed, 55 insertions(+), 11 deletions(-)

diff --git a/hmm.json b/hmm.json
index c359d7a51..1fe5a923d 100644
--- a/hmm.json
+++ b/hmm.json
@@ -153,7 +153,7 @@
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "8553b800965f225bb14c7ab8f04bfa9cdec362ac",
+      "ref": "bfbe30d81601b3543d80dce580108ad6b7e182c7",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index acbe59edd..dbba4a4c4 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -53,7 +53,8 @@ class Save
   public function new(?data:RawSaveData)
   {
     if (data == null) this.data = Save.getDefault();
-    else this.data = data;
+    else
+      this.data = data;
   }
 
   public static function getDefault():RawSaveData
@@ -714,6 +715,7 @@ class Save
 
 /**
  * An anonymous structure containingg all the user's save data.
+ * Isn't stored with JSON, stored with some sort of Haxe built-in serialization?
  */
 typedef RawSaveData =
 {
@@ -724,8 +726,6 @@ typedef RawSaveData =
   /**
    * A semantic versioning string for the save data format.
    */
-  @:jcustomparse(funkin.data.DataParse.semverVersion)
-  @:jcustomwrite(funkin.data.DataWrite.semverVersion)
   var version:Version;
 
   var api:SaveApiData;
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 3ed59e726..7f597b4ec 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -24,6 +24,8 @@ class SaveDataMigrator
     }
     else
     {
+      // Sometimes the Haxe serializer has issues with the version so we fix it here.
+      version = VersionUtil.repairVersion(version);
       if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
       {
         // Simply import the structured data.
@@ -32,8 +34,9 @@ class SaveDataMigrator
       }
       else
       {
-        trace('[SAVE] Invalid save data version! Returning blank data.');
-        trace(inputData);
+        var message:String = 'Error migrating save data, expected ${Save.SAVE_DATA_VERSION}.';
+        lime.app.Application.current.window.alert(message, "Save Data Failure");
+        trace('[SAVE] ' + message);
         return new Save(Save.getDefault());
       }
     }
diff --git a/source/funkin/util/StructureUtil.hx b/source/funkin/util/StructureUtil.hx
index 2f0c3818a..2a6b345d3 100644
--- a/source/funkin/util/StructureUtil.hx
+++ b/source/funkin/util/StructureUtil.hx
@@ -44,8 +44,15 @@ class StructureUtil
     return Std.isOfType(a, haxe.Constraints.IMap);
   }
 
-  public static function isObject(a:Dynamic):Bool
+  /**
+   * Returns `true` if `a` is an anonymous structure.
+   * I believe this returns `false` even for class instances and arrays.
+   */
+  public static function isStructure(a:Dynamic):Bool
   {
+    // TODO: Is there a difference?
+    // return Reflect.isObject(foo);
+
     switch (Type.typeof(a))
     {
       case TObject:
@@ -55,6 +62,22 @@ class StructureUtil
     }
   }
 
+  /**
+   * Returns true if `a` is an array.
+   *
+   * NOTE: isObject and isInstance also return true,
+   * since they're objects of the Array<> class, so check this first!
+   */
+  public static function isArray(a:Dynamic):Bool
+  {
+    return Std.is(a, Array);
+  }
+
+  public static function isInstance(a:Dynamic):Bool
+  {
+    return Type.getClass(a) != null;
+  }
+
   public static function isPrimitive(a:Dynamic):Bool
   {
     switch (Type.typeof(a))
@@ -89,6 +112,7 @@ class StructureUtil
   {
     if (a == null) return b;
     if (b == null) return null;
+    if (isArray(a) && isArray(b)) return b;
     if (isPrimitive(a) && isPrimitive(b)) return b;
     if (isMap(b))
     {
@@ -101,7 +125,6 @@ class StructureUtil
         return StructureUtil.toMap(a).merge(b);
       }
     }
-    if (!Reflect.isObject(a) || !Reflect.isObject(b)) return b;
     if (Std.isOfType(b, haxe.ds.StringMap))
     {
       if (Std.isOfType(a, haxe.ds.StringMap))
@@ -113,15 +136,14 @@ class StructureUtil
         return StructureUtil.toMap(a).merge(b);
       }
     }
+    if (!isStructure(a) || !isStructure(b)) return b;
 
     var result:DynamicAccess<Dynamic> = Reflect.copy(a);
 
     for (field in Reflect.fields(b))
     {
-      if (Reflect.isObject(b))
+      if (isStructure(b))
       {
-        // Note that isObject also returns true for class instances,
-        // but we just assume that's not a problem here.
         result.set(field, deepMerge(Reflect.field(result, field), Reflect.field(b, field)));
       }
       else
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 247ba19db..8f5550662 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -32,6 +32,25 @@ class VersionUtil
     }
   }
 
+  public static function repairVersion(version:thx.semver.Version):thx.semver.Version
+  {
+    var versionData:thx.semver.Version.SemVer = version;
+
+    if (StructureUtil.isStructure(versionData.version))
+    {
+      // This is bad! versionData.version should be an array!
+      versionData.version = [versionData.version[0], versionData.version[1], versionData.version[2]];
+
+      var fixedVersion:thx.semver.Version = versionData;
+      return fixedVersion;
+    }
+    else
+    {
+      // No need for repair.
+      return version;
+    }
+  }
+
   /**
    * Checks that a given verison number satisisfies a given version rule.
    * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports.

From 6cb58163787b6951fa6fffed01a48e444cd5b5f5 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 21 May 2024 02:49:07 -0400
Subject: [PATCH 23/72] Add freeplay favorites to the save data so they persist
 between sessions.

---
 source/funkin/input/Controls.hx            |  2 +-
 source/funkin/save/Save.hx                 | 44 ++++++++++++++++++++--
 source/funkin/save/changelog.md            |  3 ++
 source/funkin/ui/freeplay/FreeplayState.hx | 25 +++++++++++-
 4 files changed, 68 insertions(+), 6 deletions(-)

diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index 1983d413b..cede0b688 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -707,7 +707,7 @@ class Controls extends FlxActionSet
           case Control.VOLUME_UP: return [PLUS, NUMPADPLUS];
           case Control.VOLUME_DOWN: return [MINUS, NUMPADMINUS];
           case Control.VOLUME_MUTE: return [ZERO, NUMPADZERO];
-          case Control.FULLSCREEN: return [FlxKey.F];
+          case Control.FULLSCREEN: return [FlxKey.F11]; // We use F for other things LOL.
 
         }
       case Duo(true):
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index acbe59edd..7b2d3f511 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -14,8 +14,7 @@ import funkin.util.SerializerUtil;
 @:nullSafety
 class Save
 {
-  // Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null.
-  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.3";
+  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.4";
   public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
   // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
@@ -53,7 +52,8 @@ class Save
   public function new(?data:RawSaveData)
   {
     if (data == null) this.data = Save.getDefault();
-    else this.data = data;
+    else
+      this.data = data;
   }
 
   public static function getDefault():RawSaveData
@@ -77,6 +77,9 @@ class Save
           levels: [],
           songs: [],
         },
+
+      favoriteSongs: [],
+
       options:
         {
           // Reasonable defaults.
@@ -554,6 +557,35 @@ class Save
     return false;
   }
 
+  public function isSongFavorited(id:String):Bool
+  {
+    if (data.favoriteSongs == null)
+    {
+      data.favoriteSongs = [];
+      flush();
+    };
+
+    return data.favoriteSongs.contains(id);
+  }
+
+  public function favoriteSong(id:String):Void
+  {
+    if (!isSongFavorited(id))
+    {
+      data.favoriteSongs.push(id);
+      flush();
+    }
+  }
+
+  public function unfavoriteSong(id:String):Void
+  {
+    if (isSongFavorited(id))
+    {
+      data.favoriteSongs.remove(id);
+      flush();
+    }
+  }
+
   public function getControls(playerId:Int, inputType:Device):Null<SaveControlsData>
   {
     switch (inputType)
@@ -740,6 +772,12 @@ typedef RawSaveData =
    */
   var options:SaveDataOptions;
 
+  /**
+   * The user's favorited songs in the Freeplay menu,
+   * as a list of song IDs.
+   */
+  var favoriteSongs:Array<String>;
+
   var mods:SaveDataMods;
 
   /**
diff --git a/source/funkin/save/changelog.md b/source/funkin/save/changelog.md
index 3fa9839d1..7c9094f2d 100644
--- a/source/funkin/save/changelog.md
+++ b/source/funkin/save/changelog.md
@@ -5,6 +5,9 @@ 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.0.4] - 2024-05-21
+### Added
+- `favoriteSongs:Array<String>` to `Save`
 
 ## [2.0.3] - 2024-01-09
 ### Added
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 239068288..911d07a56 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -699,8 +699,8 @@ class FreeplayState extends MusicBeatSubState
       if (targetSong != null)
       {
         var realShit:Int = curSelected;
-        targetSong.isFav = !targetSong.isFav;
-        if (targetSong.isFav)
+        var isFav = targetSong.toggleFavorite();
+        if (isFav)
         {
           FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4,
             {
@@ -1398,11 +1398,32 @@ class FreeplaySongData
     this.levelId = levelId;
     this.songId = songId;
     this.song = song;
+
+    this.isFav = Save.instance.isSongFavorited(songId);
+
     if (displayedVariations != null) this.displayedVariations = displayedVariations;
 
     updateValues(displayedVariations);
   }
 
+  /**
+   * Toggle whether or not the song is favorited, then flush to save data.
+   * @return Whether or not the song is now favorited.
+   */
+  public function toggleFavorite():Bool
+  {
+    isFav = !isFav;
+    if (isFav)
+    {
+      Save.instance.favoriteSong(this.songId);
+    }
+    else
+    {
+      Save.instance.unfavoriteSong(this.songId);
+    }
+    return isFav;
+  }
+
   function updateValues(variations:Array<String>):Void
   {
     this.songDifficulties = song.listDifficulties(variations, false, false);

From fed6d1146c67b048f2c9b82d3b50a7dd9cd1748f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 21 May 2024 04:02:32 -0400
Subject: [PATCH 24/72] Do some cleanup (replace several utility functions with
 a utility library we already depend on!)

---
 Project.xml                                   |   1 +
 source/funkin/import.hx                       |   1 +
 source/funkin/play/song/Song.hx               |  16 +-
 .../funkin/save/migrator/SaveDataMigrator.hx  |   5 +-
 source/funkin/ui/story/LevelProp.hx           |   5 +-
 source/funkin/util/StructureUtil.hx           | 158 ------------------
 source/funkin/util/tools/ArrayTools.hx        |  66 --------
 7 files changed, 17 insertions(+), 235 deletions(-)
 delete mode 100644 source/funkin/util/StructureUtil.hx

diff --git a/Project.xml b/Project.xml
index fcfcfb9f3..b5630a46a 100644
--- a/Project.xml
+++ b/Project.xml
@@ -128,6 +128,7 @@
 
 
 	<haxelib name="json2object" /> <!-- JSON parsing -->
+	<haxelib name="thx.core" /> <!-- General utility library, "the lodash of Haxe" -->
 	<haxelib name="thx.semver" /> <!-- Version string handling -->
 
 	<haxelib name="hxcpp-debug-server" if="desktop debug" /> <!-- VSCode debug support -->
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 250de99cb..c8431be33 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -11,6 +11,7 @@ import flixel.system.debug.watch.Tracker;
 // These are great.
 using Lambda;
 using StringTools;
+using thx.Arrays;
 using funkin.util.tools.ArraySortTools;
 using funkin.util.tools.ArrayTools;
 using funkin.util.tools.FloatTools;
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 23d8d2198..53408fb34 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -439,12 +439,16 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     // so we have to map it to the actual difficulty names.
     // We also filter out difficulties that don't match the variation or that don't exist.
 
-    var diffFiltered:Array<String> = difficulties.keys().array().map(function(diffId:String):Null<String> {
-      var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
-      if (difficulty == null) return null;
-      if (variationIds.length > 0 && !variationIds.contains(difficulty.variation)) return null;
-      return difficulty.difficulty;
-    }).nonNull().unique();
+    var diffFiltered:Array<String> = difficulties.keys()
+      .array()
+      .map(function(diffId:String):Null<String> {
+        var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
+        if (difficulty == null) return null;
+        if (variationIds.length > 0 && !variationIds.contains(difficulty.variation)) return null;
+        return difficulty.difficulty;
+      })
+      .filterNull()
+      .distinct();
 
     diffFiltered = diffFiltered.filter(function(diffId:String):Bool {
       if (showHidden) return true;
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 7f597b4ec..5398b2119 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -28,8 +28,9 @@ class SaveDataMigrator
       version = VersionUtil.repairVersion(version);
       if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
       {
-        // Simply import the structured data.
-        var save:Save = new Save(StructureUtil.deepMerge(Save.getDefault(), inputData));
+        // Import the structured data.
+        var saveDataWithDefaults:RawSaveData = thx.Objects.deepCombine(Save.getDefault(), inputData);
+        var save:Save = new Save(saveDataWithDefaults);
         return save;
       }
       else
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
index ffc756e1c..5a3efc36a 100644
--- a/source/funkin/ui/story/LevelProp.hx
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -13,11 +13,10 @@ class LevelProp extends Bopper
     // Only reset the prop if the asset path has changed.
     if (propData == null || value?.assetPath != propData?.assetPath)
     {
-      this.visible = (value != null);
-      this.propData = value;
-      danceEvery = this.propData?.danceEvery ?? 0;
       applyData();
     }
+    this.visible = (value != null);
+    danceEvery = this.propData?.danceEvery ?? 0;
 
     return this.propData;
   }
diff --git a/source/funkin/util/StructureUtil.hx b/source/funkin/util/StructureUtil.hx
deleted file mode 100644
index 2a6b345d3..000000000
--- a/source/funkin/util/StructureUtil.hx
+++ /dev/null
@@ -1,158 +0,0 @@
-package funkin.util;
-
-import funkin.util.tools.MapTools;
-import haxe.DynamicAccess;
-
-/**
- * Utilities for working with anonymous structures.
- */
-class StructureUtil
-{
-  /**
-   * Merge two structures, with the second overwriting the first.
-   * Performs a SHALLOW clone, where child structures are not merged.
-   * @param a The base structure.
-   * @param b The new structure.
-   * @return The merged structure.
-   */
-  public static function merge(a:Dynamic, b:Dynamic):Dynamic
-  {
-    var result:DynamicAccess<Dynamic> = Reflect.copy(a);
-
-    for (field in Reflect.fields(b))
-    {
-      result.set(field, Reflect.field(b, field));
-    }
-
-    return result;
-  }
-
-  public static function toMap(a:Dynamic):haxe.ds.Map<String, Dynamic>
-  {
-    var result:haxe.ds.Map<String, Dynamic> = [];
-
-    for (field in Reflect.fields(a))
-    {
-      result.set(field, Reflect.field(a, field));
-    }
-
-    return result;
-  }
-
-  public static function isMap(a:Dynamic):Bool
-  {
-    return Std.isOfType(a, haxe.Constraints.IMap);
-  }
-
-  /**
-   * Returns `true` if `a` is an anonymous structure.
-   * I believe this returns `false` even for class instances and arrays.
-   */
-  public static function isStructure(a:Dynamic):Bool
-  {
-    // TODO: Is there a difference?
-    // return Reflect.isObject(foo);
-
-    switch (Type.typeof(a))
-    {
-      case TObject:
-        return true;
-      default:
-        return false;
-    }
-  }
-
-  /**
-   * Returns true if `a` is an array.
-   *
-   * NOTE: isObject and isInstance also return true,
-   * since they're objects of the Array<> class, so check this first!
-   */
-  public static function isArray(a:Dynamic):Bool
-  {
-    return Std.is(a, Array);
-  }
-
-  public static function isInstance(a:Dynamic):Bool
-  {
-    return Type.getClass(a) != null;
-  }
-
-  public static function isPrimitive(a:Dynamic):Bool
-  {
-    switch (Type.typeof(a))
-    {
-      case TInt | TFloat | TBool:
-        return true;
-      case TClass(c):
-        return false;
-      case TEnum(e):
-        return false;
-      case TObject:
-        return false;
-      case TFunction:
-        return false;
-      case TNull:
-        return true;
-      case TUnknown:
-        return false;
-      default:
-        return false;
-    }
-  }
-
-  /**
-   * Merge two structures, with the second overwriting the first.
-   * Performs a DEEP clone, where child structures are also merged recursively.
-   * @param a The base structure.
-   * @param b The new structure.
-   * @return The merged structure.
-   */
-  public static function deepMerge(a:Dynamic, b:Dynamic):Dynamic
-  {
-    if (a == null) return b;
-    if (b == null) return null;
-    if (isArray(a) && isArray(b)) return b;
-    if (isPrimitive(a) && isPrimitive(b)) return b;
-    if (isMap(b))
-    {
-      if (isMap(a))
-      {
-        return MapTools.merge(a, b);
-      }
-      else
-      {
-        return StructureUtil.toMap(a).merge(b);
-      }
-    }
-    if (Std.isOfType(b, haxe.ds.StringMap))
-    {
-      if (Std.isOfType(a, haxe.ds.StringMap))
-      {
-        return MapTools.merge(a, b);
-      }
-      else
-      {
-        return StructureUtil.toMap(a).merge(b);
-      }
-    }
-    if (!isStructure(a) || !isStructure(b)) return b;
-
-    var result:DynamicAccess<Dynamic> = Reflect.copy(a);
-
-    for (field in Reflect.fields(b))
-    {
-      if (isStructure(b))
-      {
-        result.set(field, deepMerge(Reflect.field(result, field), Reflect.field(b, field)));
-      }
-      else
-      {
-        // If we're here, b[field] is a primitive.
-        result.set(field, Reflect.field(b, field));
-      }
-    }
-
-    return result;
-  }
-}
diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx
index caf8e8aab..0fe245e3a 100644
--- a/source/funkin/util/tools/ArrayTools.hx
+++ b/source/funkin/util/tools/ArrayTools.hx
@@ -5,72 +5,6 @@ package funkin.util.tools;
  */
 class ArrayTools
 {
-  /**
-   * Returns a copy of the array with all duplicate elements removed.
-   * @param array The array to remove duplicates from.
-   * @return A copy of the array with all duplicate elements removed.
-   */
-  public static function unique<T>(array:Array<T>):Array<T>
-  {
-    var result:Array<T> = [];
-    for (element in array)
-    {
-      if (!result.contains(element))
-      {
-        result.push(element);
-      }
-    }
-    return result;
-  }
-
-  /**
-   * Returns a copy of the array with all `null` elements removed.
-   * @param array The array to remove `null` elements from.
-   * @return A copy of the array with all `null` elements removed.
-   */
-  public static function nonNull<T>(array:Array<Null<T>>):Array<T>
-  {
-    var result:Array<T> = [];
-    for (element in array)
-    {
-      if (element != null)
-      {
-        result.push(element);
-      }
-    }
-    return result;
-  }
-
-  /**
-   * Return the first element of the array that satisfies the predicate, or null if none do.
-   * @param input The array to search
-   * @param predicate The predicate to call
-   * @return The result
-   */
-  public static function find<T>(input:Array<T>, predicate:T->Bool):Null<T>
-  {
-    for (element in input)
-    {
-      if (predicate(element)) return element;
-    }
-    return null;
-  }
-
-  /**
-   * Return the index of the first element of the array that satisfies the predicate, or `-1` if none do.
-   * @param input The array to search
-   * @param predicate The predicate to call
-   * @return The index of the result
-   */
-  public static function findIndex<T>(input:Array<T>, predicate:T->Bool):Int
-  {
-    for (index in 0...input.length)
-    {
-      if (predicate(input[index])) return index;
-    }
-    return -1;
-  }
-
   /*
    * Push an element to the array if it is not already present.
    * @param input The array to push to

From 6d3b58cecdb3b3881e261a3137518926835fd7a5 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 21 May 2024 04:02:53 -0400
Subject: [PATCH 25/72] Fix some additional compiling issues.

---
 source/funkin/save/migrator/SaveDataMigrator.hx | 3 +--
 source/funkin/util/VersionUtil.hx               | 2 +-
 source/funkin/util/macro/InlineMacro.hx         | 2 +-
 3 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 5398b2119..650666c5c 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -3,7 +3,6 @@ package funkin.save.migrator;
 import funkin.save.Save;
 import funkin.save.migrator.RawSaveData_v1_0_0;
 import thx.semver.Version;
-import funkin.util.StructureUtil;
 import funkin.util.VersionUtil;
 
 @:nullSafety
@@ -29,7 +28,7 @@ class SaveDataMigrator
       if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
       {
         // Import the structured data.
-        var saveDataWithDefaults:RawSaveData = thx.Objects.deepCombine(Save.getDefault(), inputData);
+        var saveDataWithDefaults:RawSaveData = cast thx.Objects.deepCombine(Save.getDefault(), inputData);
         var save:Save = new Save(saveDataWithDefaults);
         return save;
       }
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 8f5550662..18d7eafa6 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -36,7 +36,7 @@ class VersionUtil
   {
     var versionData:thx.semver.Version.SemVer = version;
 
-    if (StructureUtil.isStructure(versionData.version))
+    if (thx.Types.isAnonymousObject(versionData.version))
     {
       // This is bad! versionData.version should be an array!
       versionData.version = [versionData.version[0], versionData.version[1], versionData.version[2]];
diff --git a/source/funkin/util/macro/InlineMacro.hx b/source/funkin/util/macro/InlineMacro.hx
index b0e7ed184..c40257409 100644
--- a/source/funkin/util/macro/InlineMacro.hx
+++ b/source/funkin/util/macro/InlineMacro.hx
@@ -23,7 +23,7 @@ class InlineMacro
     var fields:Array<haxe.macro.Expr.Field> = haxe.macro.Context.getBuildFields();
 
     // Find the field with the given name.
-    var targetField:Null<haxe.macro.Expr.Field> = fields.find(function(f) return f.name == field
+    var targetField:Null<haxe.macro.Expr.Field> = thx.Arrays.find(fields, function(f) return f.name == field
       && (MacroUtil.isFieldStatic(f) == isStatic));
 
     // If the field was not found, throw an error.

From f17f6393041a54e4b15418059f6a9ff9dc2a39cb Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 22 May 2024 01:07:20 -0400
Subject: [PATCH 26/72] Finish implementing smaller numbers.

---
 assets                                        |  2 +-
 source/funkin/InitState.hx                    |  4 +-
 source/funkin/play/ResultState.hx             | 75 ++++++++++++++++---
 .../play/components/ClearPercentCounter.hx    | 73 ++++++++++++++----
 4 files changed, 124 insertions(+), 30 deletions(-)

diff --git a/assets b/assets
index ce7dabffb..b6d930109 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit ce7dabffbebc154c9dda1f01e92dbef83e3405ab
+Subproject commit b6d930109eb69cfd368145e893d81ac1e97ed004
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 6a52eaf5d..d17554d11 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -227,14 +227,14 @@ class InitState extends FlxState
             tallies:
               {
                 sick: 130,
-                good: 69,
+                good: 70,
                 bad: 69,
                 shit: 69,
                 missed: 69,
                 combo: 69,
                 maxCombo: 69,
                 totalNotesHit: 140,
-                totalNotes: 2000,
+                totalNotes: 200 // 0,
               }
           },
       }));
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index d038b7785..fdcd0cc39 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -37,6 +37,7 @@ class ResultState extends MusicBeatSubState
   final rank:ResultRank;
   final songName:FlxBitmapText;
   final difficulty:FlxSprite;
+  final clearPercentSmall:ClearPercentCounter;
 
   final maskShaderSongName:LeftMaskShader = new LeftMaskShader();
   final maskShaderDifficulty:LeftMaskShader = new LeftMaskShader();
@@ -78,6 +79,10 @@ class ResultState extends MusicBeatSubState
     difficulty = new FlxSprite(555);
     difficulty.zIndex = 1000;
 
+    clearPercentSmall = new ClearPercentCounter(FlxG.width / 2 + 300, FlxG.height / 2 - 100, 100, true);
+    clearPercentSmall.zIndex = 1000;
+    clearPercentSmall.visible = false;
+
     bgFlash = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFFEB69, 0xFFFFE66A], 90);
 
     resultsAnim = FunkinSprite.createSparrow(-200, -10, "resultScreen/results");
@@ -194,7 +199,7 @@ class ResultState extends MusicBeatSubState
     speedOfTween.x = -1.0 * Math.cos(angleRad);
     speedOfTween.y = -1.0 * Math.sin(angleRad);
 
-    timerThenSongName(1.0);
+    timerThenSongName(1.0, false);
 
     songName.shader = maskShaderSongName;
     difficulty.shader = maskShaderDifficulty;
@@ -319,13 +324,15 @@ class ResultState extends MusicBeatSubState
 
   function startRankTallySequence():Void
   {
-    clearPercentTarget = Math.floor((params.scoreData.tallies.sick + params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes * 100);
-    clearPercentTarget = 100;
+    var clearPercentFloat = (params.scoreData.tallies.sick + params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes * 100;
+    clearPercentTarget = Math.floor(clearPercentFloat);
+    // Prevent off-by-one errors.
 
     clearPercentLerp = Std.int(Math.max(0, clearPercentTarget - 36));
 
-    var clearPercentCounter:ClearPercentCounter = new ClearPercentCounter(FlxG.width / 2 + 300, FlxG.height / 2 - 100, clearPercentTarget);
-    clearPercentCounter.curNumber = clearPercentLerp;
+    trace('Clear percent target: ' + clearPercentFloat + ', round: ' + clearPercentTarget);
+
+    var clearPercentCounter:ClearPercentCounter = new ClearPercentCounter(FlxG.width / 2 + 300, FlxG.height / 2 - 100, clearPercentLerp);
     FlxTween.tween(clearPercentCounter, {curNumber: clearPercentTarget}, 1.5,
       {
         ease: FlxEase.quartOut,
@@ -345,10 +352,25 @@ class ResultState extends MusicBeatSubState
           bgFlash.visible = true;
           FlxTween.tween(bgFlash, {alpha: 0}, 0.4);
 
+          // Just to be sure that the lerp didn't mess things up.
+          clearPercentCounter.curNumber = clearPercentTarget;
+
+          clearPercentCounter.flash(true);
+          new FlxTimer().start(0.4, _ -> {
+            clearPercentCounter.flash(false);
+          });
+
           displayRankText();
 
           new FlxTimer().start(2.0, _ -> {
-            // remove(clearPercentCounter);
+            FlxTween.tween(clearPercentCounter, {alpha: 0}, 0.5,
+              {
+                startDelay: 0.5,
+                ease: FlxEase.quartOut,
+                onComplete: _ -> {
+                  remove(clearPercentCounter);
+                }
+              });
 
             afterRankTallySequence();
           });
@@ -406,6 +428,8 @@ class ResultState extends MusicBeatSubState
 
   function afterRankTallySequence():Void
   {
+    showSmallClearPercent();
+
     FunkinSound.playMusic(rank.getMusicPath(),
       {
         startingVolume: 1.0,
@@ -490,7 +514,7 @@ class ResultState extends MusicBeatSubState
     }
   }
 
-  function timerThenSongName(timerLength:Float = 3.0):Void
+  function timerThenSongName(timerLength:Float = 3.0, autoScroll:Bool = true):Void
   {
     movingSongStuff = false;
 
@@ -501,10 +525,17 @@ class ResultState extends MusicBeatSubState
     difficulty.y = -difficulty.height;
     FlxTween.tween(difficulty, {y: diffYTween}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.8});
 
+    if (clearPercentSmall != null)
+    {
+      clearPercentSmall.x = (difficulty.x + difficulty.width) + 60;
+      clearPercentSmall.y = -clearPercentSmall.height;
+      FlxTween.tween(clearPercentSmall, {y: 122 - 5}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.8});
+    }
+
     songName.y = -songName.height;
     var fuckedupnumber = (10) * (songName.text.length / 15);
-    FlxTween.tween(songName, {y: diffYTween - 35 - fuckedupnumber}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.9});
-    songName.x = (difficulty.x + difficulty.width) + 20;
+    FlxTween.tween(songName, {y: diffYTween - 25 - fuckedupnumber}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.9});
+    songName.x = clearPercentSmall.x + clearPercentSmall.width - 30;
 
     new FlxTimer().start(timerLength, _ -> {
       var tempSpeed = FlxPoint.get(speedOfTween.x, speedOfTween.y);
@@ -512,10 +543,29 @@ class ResultState extends MusicBeatSubState
       speedOfTween.set(0, 0);
       FlxTween.tween(speedOfTween, {x: tempSpeed.x, y: tempSpeed.y}, 0.7, {ease: FlxEase.quadIn});
 
-      movingSongStuff = true;
+      movingSongStuff = (autoScroll);
     });
   }
 
+  function showSmallClearPercent():Void
+  {
+    if (clearPercentSmall != null)
+    {
+      add(clearPercentSmall);
+      clearPercentSmall.visible = true;
+      clearPercentSmall.flash(true);
+      new FlxTimer().start(0.4, _ -> {
+        clearPercentSmall.flash(false);
+      });
+
+      clearPercentSmall.curNumber = clearPercentTarget;
+      clearPercentSmall.zIndex = 1000;
+      refresh();
+    }
+
+    movingSongStuff = true;
+  }
+
   var movingSongStuff:Bool = false;
   var speedOfTween:FlxPoint = FlxPoint.get(-1, 1);
 
@@ -523,7 +573,8 @@ class ResultState extends MusicBeatSubState
   {
     super.draw();
 
-    songName.clipRect = FlxRect.get(Math.max(0, 540 - songName.x), 0, FlxG.width, songName.height);
+    songName.clipRect = FlxRect.get(Math.max(0, 520 - songName.x), 0, FlxG.width, songName.height);
+
     // PROBABLY SHOULD FIX MEMORY FREE OR WHATEVER THE PUT() FUNCTION DOES !!!! FEELS LIKE IT STUTTERS!!!
 
     // if (songName != null && songName.frame != null)
@@ -539,8 +590,10 @@ class ResultState extends MusicBeatSubState
     {
       songName.x += speedOfTween.x;
       difficulty.x += speedOfTween.x;
+      clearPercentSmall.x += speedOfTween.x;
       songName.y += speedOfTween.y;
       difficulty.y += speedOfTween.y;
+      clearPercentSmall.y += speedOfTween.y;
 
       if (songName.x + songName.width < 100)
       {
diff --git a/source/funkin/play/components/ClearPercentCounter.hx b/source/funkin/play/components/ClearPercentCounter.hx
index 4c03ec3a9..d296b0b0b 100644
--- a/source/funkin/play/components/ClearPercentCounter.hx
+++ b/source/funkin/play/components/ClearPercentCounter.hx
@@ -1,6 +1,7 @@
 package funkin.play.components;
 
 import funkin.graphics.FunkinSprite;
+import funkin.graphics.shaders.PureColor;
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
@@ -9,25 +10,54 @@ import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.text.FlxText.FlxTextAlign;
 import funkin.util.MathUtil;
+import flixel.util.FlxColor;
 
 /**
  * Numerical counters used to display the clear percent.
  */
 class ClearPercentCounter extends FlxTypedSpriteGroup<FlxSprite>
 {
-  public var curNumber:Int = 0;
-  public var neededNumber:Int = 0;
+  public var curNumber(default, set):Int = 0;
 
-  public function new(x:Float, y:Float, neededNumber:Int = 0)
+  var numberChanged:Bool = false;
+
+  function set_curNumber(val:Int):Int
+  {
+    numberChanged = true;
+    return curNumber = val;
+  }
+
+  var small:Bool = false;
+  var flashShader:PureColor;
+
+  public function new(x:Float, y:Float, startingNumber:Int = 0, small:Bool = false)
   {
     super(x, y);
 
-    this.neededNumber = neededNumber;
+    flashShader = new PureColor(FlxColor.WHITE);
+    flashShader.colorSet = true;
 
-    var clearPercentText:FunkinSprite = FunkinSprite.create(0, 0, 'resultScreen/clearPercent/clearPercentText');
+    curNumber = startingNumber;
+
+    this.small = small;
+
+    var clearPercentText:FunkinSprite = FunkinSprite.create(0, 0, 'resultScreen/clearPercent/clearPercentText${small ? 'Small' : ''}');
+    clearPercentText.x = small ? 40 : 0;
     add(clearPercentText);
 
-    if (curNumber == neededNumber) drawNumbers();
+    drawNumbers();
+  }
+
+  /**
+   * Make the counter flash turn white or stop being all white.
+   * @param enabled Whether the counter should be white.
+   */
+  public function flash(enabled:Bool):Void
+  {
+    for (member in members)
+    {
+      member.shader = enabled ? flashShader : null;
+    }
   }
 
   var tmr:Float = 0;
@@ -36,7 +66,7 @@ class ClearPercentCounter extends FlxTypedSpriteGroup<FlxSprite>
   {
     super.update(elapsed);
 
-    if (curNumber < neededNumber) drawNumbers();
+    if (numberChanged) drawNumbers();
   }
 
   function drawNumbers()
@@ -44,8 +74,6 @@ class ClearPercentCounter extends FlxTypedSpriteGroup<FlxSprite>
     var seperatedScore:Array<Int> = [];
     var tempCombo:Int = Math.round(curNumber);
 
-    var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber))));
-
     while (tempCombo != 0)
     {
       seperatedScore.push(tempCombo % 10);
@@ -59,19 +87,32 @@ class ClearPercentCounter extends FlxTypedSpriteGroup<FlxSprite>
     for (ind => num in seperatedScore)
     {
       var digitIndex = ind + 1;
+      // If there's only one digit, move it to the right
+      // If there's three digits, move them all to the left
+      var digitOffset = (seperatedScore.length == 1) ? 1 : (seperatedScore.length == 3) ? -1 : 0;
+      var digitSize = small ? 32 : 72;
+      var digitHeightOffset = small ? -4 : 0;
+
+      var xPos = (digitIndex - 1 + digitOffset) * (digitSize * this.scale.x);
+      xPos += small ? -24 : 0;
+      var yPos = (digitIndex - 1 + digitOffset) * (digitHeightOffset * this.scale.y);
+      yPos += small ? 0 : 72;
+
       if (digitIndex >= members.length)
       {
-        var xPos = (digitIndex - 1) * (72 * this.scale.x);
-        var yPos = 72;
-        // Three digits = LRL so two different numbers aren't adjacent to each other.
-        var variant:Bool = (fullNumberDigits % 2 != 0) ? (digitIndex % 2 == 0) : (digitIndex % 2 == 1);
-        var numb:ClearPercentNumber = new ClearPercentNumber(xPos, yPos, num);
+        // Three digits = LLR because the 1 and 0 won't be the same anyway.
+        var variant:Bool = (seperatedScore.length == 3) ? (digitIndex >= 2) : (digitIndex >= 1);
+        // var variant:Bool = (seperatedScore.length % 2 != 0) ? (digitIndex % 2 == 0) : (digitIndex % 2 == 1);
+        var numb:ClearPercentNumber = new ClearPercentNumber(xPos, yPos, num, variant, this.small);
         numb.scale.set(this.scale.x, this.scale.y);
         add(numb);
       }
       else
       {
         members[digitIndex].animation.play(Std.string(num));
+        // Reset the position of the number
+        members[digitIndex].x = xPos + this.x;
+        members[digitIndex].y = yPos + this.y;
       }
     }
   }
@@ -79,11 +120,11 @@ class ClearPercentCounter extends FlxTypedSpriteGroup<FlxSprite>
 
 class ClearPercentNumber extends FlxSprite
 {
-  public function new(x:Float, y:Float, digit:Int, variant:Bool = false)
+  public function new(x:Float, y:Float, digit:Int, variant:Bool, small:Bool)
   {
     super(x, y);
 
-    frames = Paths.getSparrowAtlas('resultScreen/clearPercent/clearPercentNumber${variant ? 'Right' : 'Left'}');
+    frames = Paths.getSparrowAtlas('resultScreen/clearPercent/clearPercentNumber${small ? 'Small' : variant ? 'Right' : 'Left'}');
 
     for (i in 0...10)
     {

From d84e832c6c9152abab106290dab72e5792fc6808 Mon Sep 17 00:00:00 2001
From: sector-a <82838084+sector-a@users.noreply.github.com>
Date: Wed, 22 May 2024 12:57:57 +0300
Subject: [PATCH 27/72] Make texts update on difficulty change in Story Menu

---
 source/funkin/ui/story/StoryMenuState.hx | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 0c2214529..820ac2ad1 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -466,6 +466,9 @@ class StoryMenuState extends MusicBeatState
       // Disable the funny music thing for now.
       // funnyMusicThing();
     }
+
+    updateText();
+    refresh();
   }
 
   final FADE_OUT_TIME:Float = 1.5;

From 9afc314a0d20f29d69beba70d91d23cc37922660 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 22 May 2024 15:20:53 -0400
Subject: [PATCH 28/72] Fix an issue with git modules

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

diff --git a/.gitmodules b/.gitmodules
index be5e0aaa8..2d5c11067 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,5 @@
 [submodule "assets"]
 	path = assets
-	url = https://github.com/FunkinCrew/funkin.assets
-[submodule "art"]
+	url = https://github.com/FunkinCrew/Funkin-Assets-secret
 	path = art
-	url = https://github.com/FunkinCrew/funkin.art
+	url = https://github.com/FunkinCrew/Funkin-Art-secret

From a2ee359e466746646c278d0adbef0c1fd08d21c1 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 23 May 2024 16:31:18 -0400
Subject: [PATCH 29/72] fix for songs overlapping each other on desktop

---
 source/funkin/audio/FunkinSound.hx | 20 +++++++++++++++++---
 1 file changed, 17 insertions(+), 3 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 5a49e29ee..aaddda9dc 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -364,10 +364,20 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 
       if (music != null)
       {
+        for (future in partialQueue)
+        {
+          future = cast Future.withError("Music was overridden by another partial load");
+        }
+        partialQueue = [];
+        partialQueue.push(music);
+
+        @:nullSafety(Off)
         music.onComplete(function(partialMusic:Null<FunkinSound>) {
-          @:nullSafety(Off)
-          FlxG.sound.music = partialMusic;
-          FlxG.sound.list.remove(FlxG.sound.music);
+          if (partialQueue.pop() == music)
+          {
+            FlxG.sound.music = partialMusic;
+            FlxG.sound.list.remove(FlxG.sound.music);
+          }
         });
 
         return true;
@@ -396,6 +406,8 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     }
   }
 
+  static var partialQueue:Array<Future<Null<FunkinSound>>> = [];
+
   /**
    * Creates a new `FunkinSound` object synchronously.
    *
@@ -461,6 +473,8 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    * @param looped Whether the sound file should loop
    * @param autoDestroy Whether the sound file should be destroyed after it finishes playing
    * @param autoPlay Whether the sound file should play immediately
+   * @param onComplete Callback when the sound finishes playing
+   * @param onLoad Callback when the sound finishes loading
    * @return A FunkinSound object
    */
   public static function loadPartial(path:String, start:Float = 0, end:Float = 1, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false,

From 44880fa5697ddf16a62211cd4089d63371acaf46 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 00:06:26 -0400
Subject: [PATCH 30/72] Implement placeholder GREAT animation

---
 source/funkin/InitState.hx        |  2 +-
 source/funkin/play/ResultState.hx | 29 +++++++++++++++++++++++++++--
 2 files changed, 28 insertions(+), 3 deletions(-)

diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index d17554d11..a945c10c5 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -227,7 +227,7 @@ class InitState extends FlxState
             tallies:
               {
                 sick: 130,
-                good: 70,
+                good: 25,
                 bad: 69,
                 shit: 69,
                 missed: 69,
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index fdcd0cc39..ee7c8eade 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -53,6 +53,7 @@ class ResultState extends MusicBeatSubState
 
   var bfPerfect:Null<FlxAtlasSprite> = null;
   var bfExcellent:Null<FlxAtlasSprite> = null;
+  var bfGreat:Null<FlxAtlasSprite> = null;
   var bfGood:Null<FlxSprite> = null;
   var gfGood:Null<FlxSprite> = null;
   var bfShit:Null<FlxAtlasSprite> = null;
@@ -151,7 +152,20 @@ class ResultState extends MusicBeatSubState
           }
         });
 
-      case GOOD | GREAT:
+      case GREAT:
+        bfGreat = new FlxAtlasSprite(640, 200, Paths.animateAtlas("resultScreen/results-bf/resultsGREAT", "shared"));
+        bfGreat.visible = false;
+        bfGreat.zIndex = 500;
+        add(bfGreat);
+
+        bfGreat.onAnimationFinish.add((animName) -> {
+          if (bfGreat != null)
+          {
+            bfGreat.playAnimation('Loop Start');
+          }
+        });
+
+      case GOOD:
         gfGood = FunkinSprite.createSparrow(625, 325, 'resultScreen/results-bf/resultsGOOD/resultGirlfriendGOOD');
         gfGood.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false);
         gfGood.visible = false;
@@ -476,6 +490,17 @@ class ResultState extends MusicBeatSubState
           bfExcellent.playAnimation('Intro');
         }
 
+      case GREAT:
+        if (bfGreat == null)
+        {
+          trace("Could not build GREAT animation!");
+        }
+        else
+        {
+          bfGreat.visible = true;
+          bfGreat.playAnimation('Intro');
+        }
+
       case SHIT:
         if (bfShit == null)
         {
@@ -487,7 +512,7 @@ class ResultState extends MusicBeatSubState
           bfShit.playAnimation('Intro');
         }
 
-      case GREAT | GOOD:
+      case GOOD:
         if (bfGood == null)
         {
           trace("Could not build GOOD animation!");

From 2db99b3cb41bef826bd3a286ac0d942476dc5eee Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 00:06:52 -0400
Subject: [PATCH 31/72] Update submodule

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

diff --git a/assets b/assets
index b6d930109..2a57e3406 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit b6d930109eb69cfd368145e893d81ac1e97ed004
+Subproject commit 2a57e34061f6034236663851332319c5dedab259

From 0e920237940d894ffb9be77f67f4d73a8463d1f3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 00:21:45 -0400
Subject: [PATCH 32/72] Add credits for all charts + tweak charts for Guns, Lit
 Up, Winter Horrorland

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

diff --git a/assets b/assets
index 778e16705..826be3bf1 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 778e16705b30af85087f627594c22f4b5ba6141a
+Subproject commit 826be3bf1e635e6b61c8c11bd3ece51f8a2b3061

From 62e04b3372fe1b9f7256585a23ed38a5b51db98f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 13:24:11 -0400
Subject: [PATCH 33/72] Update Changelog

---
 CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 40 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7f830047..10bbfe5f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,46 @@ All notable changes 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).
 
+## [0.4.0] - 2024-05-??
+### Added
+- 2 new Erect remixes, Eggnog and Satin Panties. Check them out from
+- Improvements to the Freeplay screen, with song difficulty ratings and player rank displays.
+- Reworked the Results screen, with additional animations and audio based on your performance.
+- Added a Charter field to the chart format, to allow for crediting the creator of a level's chart.
+  - You can see who charted a song from the Pause menu.
+### Changed
+- Tweaked the charts for several songs:
+  - Winter Horrorland
+  - Stress
+  - Lit Up
+- Custom note styles are now properly supported for songs; add new notestyles via JSON, then select it for use from the Chart Editor Metadata toolbox. (thanks Keoiki!)
+- Health icons now support a Winning frame without requiring a spritesheet, simply include a third frame in the icon file. (thanks gamerbross!)
+  - Remember that for more complex behaviors such as animations or transitions, you should use an XML file to define each frame.
+### Fixed
+- Fixed a bug where pressing the volume keys would stop the Toy commercial (thanks gamerbross!)
+- Fixed a bug where the Chart Editor would crash when losing (thanks gamerbross!)
+- Made improvements to compiling documentation (thanks gedehari!)
+- Fixed a crash on Linux caused by an old version of hxCodec (thanks Noobz4Life!)
+- Optimized animation handling for characters (thanks richTrash21!)
+
+## [0.3.3] - 2024-05-14
+### Changed
+- Cleaned up some code in `PlayAnimationSongEvent.hx` (thanks BurgerBalls!)
+### Fixed
+- Fix Web Loading Bar (thanks lemz1!)
+- Don't allow any more inputs when exiting freeplay (thanks gamerbros!)
+- Fixed using mouse wheel to scroll on freeplay (thanks JugieNoob!)
+- Fixed the reset's of the health icons, score, and notes when re-entering gameplay from gameover (thanks ImCodist!)
+- Fixed the chart editor character selector's hitbox width (thanks MadBear422!)
+- Fixed camera stutter once a wipe transition to the Main Menu completes (thanks ImCodist!)
+- Fixed an issue where hold note would be invisible for a single frame (thanks ImCodist!)
+- Fix tween accumulation on title screen when pressing Y multiple times (thanks TheGaloXx!)
+- Fix for a game over easter egg so you don't accidentally exit it when viewing
+- Fix a crash when querying FlxG.state in the crash handler
+- Fix an issue where the Freeplay menu never displays 100% clear
+- Chart debug key now properly returns you to the previous chart editor session if you were playtesting a chart (thanks nebulazorua!)
+- Hopefully fixed Freeplay crashes on AMD gpu's
+
 ## [0.3.2] - 2024-05-03
 ### Added
 - Added `,` and `.` keybinds to the Chart Editor. These place Focus Camera events at the playhead, for the opponent and player respectively.

From c8930b598025f6f9faff795aeb3280ae3480c6b6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 13:45:37 -0400
Subject: [PATCH 34/72] Attempt to repair local submodules

---
 .gitmodules | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitmodules b/.gitmodules
index 2d5c11067..452c0089b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,5 +1,6 @@
 [submodule "assets"]
 	path = assets
 	url = https://github.com/FunkinCrew/Funkin-Assets-secret
+[submodule "art"]
 	path = art
 	url = https://github.com/FunkinCrew/Funkin-Art-secret

From 98505e58eca59a084bdd5fc98765ee53aca7c926 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 14:04:55 -0400
Subject: [PATCH 35/72] Take two at fixing submodules

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

diff --git a/assets b/assets
index 66572f85d..52e007f5b 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 66572f85d826ce2ec1d45468c12733b161237ffa
+Subproject commit 52e007f5b682ee7b9d252edba78a88780510d32b

From dd3e241f0c08ae5239ba081c1022acc96e993abe Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 24 May 2024 16:44:12 -0400
Subject: [PATCH 36/72] Fix some merge conflicts

---
 source/funkin/input/Controls.hx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index 31551dec9..345791eef 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -997,7 +997,7 @@ class Controls extends FlxActionSet
     for (control in Control.createAll())
     {
       var inputs:Array<Int> = Reflect.field(data, control.getName());
-      inputs = inputs.unique();
+      inputs = inputs.distinct();
       if (inputs != null)
       {
         if (inputs.length == 0) {
@@ -1050,7 +1050,7 @@ class Controls extends FlxActionSet
       if (inputs.length == 0) {
         inputs = [FlxKey.NONE];
       } else {
-        inputs = inputs.unique();
+        inputs = inputs.distinct();
       }
 
       Reflect.setField(data, control.getName(), inputs);

From 07959d3e88898048cc1037a43310c20223cf18b1 Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Sat, 25 May 2024 01:08:17 +0200
Subject: [PATCH 37/72] Fix Unscripted Stage Log Trace

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

diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 118516bec..2df3a87da 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -117,7 +117,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
         var entry:T = createEntry(entryId);
         if (entry != null)
         {
-          trace('  Loaded entry data: ${entry}');
+          trace('  Loaded entry data: ${entry.id}');
           entries.set(entry.id, entry);
         }
       }

From 004ed32fad81f096a925b3e3068e55e27fd33a3d Mon Sep 17 00:00:00 2001
From: Axuko <eharding1510@gmail.com>
Date: Mon, 27 May 2024 22:34:28 -0700
Subject: [PATCH 38/72] Remove alot of unused imoprts

---
 source/funkin/api/newgrounds/NGUnsafe.hx             |  4 ----
 source/funkin/api/newgrounds/NGio.hx                 |  8 --------
 source/funkin/audio/FunkinSound.hx                   |  3 +--
 source/funkin/audio/VoicesGroup.hx                   |  2 --
 source/funkin/audio/visualize/ABotVis.hx             |  4 ----
 source/funkin/audio/visualize/PolygonVisGroup.hx     |  1 -
 source/funkin/audio/visualize/SpectogramSprite.hx    | 12 ------------
 source/funkin/audio/visualize/VisShit.hx             |  5 -----
 source/funkin/audio/visualize/dsp/FFT.hx             |  2 --
 source/funkin/audio/waveform/WaveformData.hx         |  2 --
 source/funkin/audio/waveform/WaveformSprite.hx       |  2 --
 .../data/dialogue/conversation/ConversationData.hx   |  2 --
 .../dialogue/conversation/ConversationRegistry.hx    |  1 -
 13 files changed, 1 insertion(+), 47 deletions(-)

diff --git a/source/funkin/api/newgrounds/NGUnsafe.hx b/source/funkin/api/newgrounds/NGUnsafe.hx
index 9616dfe18..77e44bd1d 100644
--- a/source/funkin/api/newgrounds/NGUnsafe.hx
+++ b/source/funkin/api/newgrounds/NGUnsafe.hx
@@ -1,9 +1,5 @@
 package funkin.api.newgrounds;
 
-import flixel.util.FlxSignal;
-import flixel.util.FlxTimer;
-import lime.app.Application;
-import openfl.display.Stage;
 #if newgrounds
 import io.newgrounds.NG;
 import io.newgrounds.NGLite;
diff --git a/source/funkin/api/newgrounds/NGio.hx b/source/funkin/api/newgrounds/NGio.hx
index c1f8ad3ba..3f5fc078a 100644
--- a/source/funkin/api/newgrounds/NGio.hx
+++ b/source/funkin/api/newgrounds/NGio.hx
@@ -2,19 +2,11 @@ package funkin.api.newgrounds;
 
 #if newgrounds
 import flixel.util.FlxSignal;
-import flixel.util.FlxTimer;
 import io.newgrounds.NG;
 import io.newgrounds.NGLite;
-import io.newgrounds.components.ScoreBoardComponent.Period;
 import io.newgrounds.objects.Error;
-import io.newgrounds.objects.Medal;
 import io.newgrounds.objects.Score;
-import io.newgrounds.objects.ScoreBoard;
-import io.newgrounds.objects.events.Response;
-import io.newgrounds.objects.events.Result.GetCurrentVersionResult;
-import io.newgrounds.objects.events.Result.GetVersionResult;
 import lime.app.Application;
-import openfl.display.Stage;
 #end
 
 /**
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index df05cc3ef..939b17f28 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -11,10 +11,9 @@ import funkin.audio.waveform.WaveformDataParser;
 import funkin.data.song.SongData.SongMusicData;
 import funkin.data.song.SongRegistry;
 import funkin.util.tools.ICloneable;
-import openfl.Assets;
 import openfl.media.SoundMixer;
+
 #if (openfl >= "8.0.0")
-import openfl.utils.AssetType;
 #end
 
 /**
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 5037ee1d0..9a1e0e0c1 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -1,9 +1,7 @@
 package funkin.audio;
 
-import funkin.audio.FunkinSound;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import funkin.audio.waveform.WaveformData;
-import funkin.audio.waveform.WaveformDataParser;
 
 class VoicesGroup extends SoundGroup
 {
diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx
index ca77dd58a..b94f20b38 100644
--- a/source/funkin/audio/visualize/ABotVis.hx
+++ b/source/funkin/audio/visualize/ABotVis.hx
@@ -1,13 +1,9 @@
 package funkin.audio.visualize;
 
-import funkin.audio.visualize.dsp.FFT;
 import flixel.FlxSprite;
-import flixel.addons.plugin.taskManager.FlxTask;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
-import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
-import funkin.util.MathUtil;
 import funkin.vis.dsp.SpectralAnalyzer;
 import funkin.vis.audioclip.frontends.LimeAudioClip;
 
diff --git a/source/funkin/audio/visualize/PolygonVisGroup.hx b/source/funkin/audio/visualize/PolygonVisGroup.hx
index cc68f4ae0..bff845796 100644
--- a/source/funkin/audio/visualize/PolygonVisGroup.hx
+++ b/source/funkin/audio/visualize/PolygonVisGroup.hx
@@ -1,6 +1,5 @@
 package funkin.audio.visualize;
 
-import funkin.audio.visualize.PolygonSpectogram;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.sound.FlxSound;
 
diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx
index 636c0726a..615e80d95 100644
--- a/source/funkin/audio/visualize/SpectogramSprite.hx
+++ b/source/funkin/audio/visualize/SpectogramSprite.hx
@@ -8,8 +8,6 @@ import flixel.sound.FlxSound;
 import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram.VISTYPE;
 import funkin.audio.visualize.VisShit.CurAudioInfo;
-import funkin.audio.visualize.dsp.FFT;
-import lime.system.ThreadPool;
 import lime.utils.Int16Array;
 
 using Lambda;
@@ -38,8 +36,6 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
     lengthOfShit = amnt;
 
     regenLineShit();
-
-    // makeGraphic(200, 200, FlxColor.BLACK);
   }
 
   public function regenLineShit():Void
@@ -89,8 +85,6 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
   {
     checkAndSetBuffer();
 
-    // vis.checkAndSetBuffer();
-
     if (setBuffer)
     {
       var samplesToGen:Int = Std.int(sampleRate * seconds);
@@ -191,7 +185,6 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
           // a value between 10hz and 100Khz
           var hzPicker:Float = Math.pow(10, powedShit);
 
-          // var sampleApprox:Int = Std.int(FlxMath.remapToRange(i, 0, group.members.length, startingSample, startingSample + samplesToGen));
           var remappedFreq:Int = Std.int(FlxMath.remapToRange(hzPicker, 0, 10000, 0, freqShit[0].length - 1));
 
           group.members[i].x = prevLine.x;
@@ -211,8 +204,6 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
           var line = FlxPoint.get(prevLine.x - group.members[i].x, prevLine.y - group.members[i].y);
 
           // dont draw a line until i figure out a nicer way to view da spikes and shit idk lol!
-          // group.members[i].setGraphicSize(Std.int(Math.max(line.length, 1)), Std.int(1));
-          // group.members[i].angle = line.degrees;
         }
       }
     }
@@ -261,9 +252,6 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
 
           group.members[Std.int(remappedSample)].x = prevLine.x;
           group.members[Std.int(remappedSample)].y = prevLine.y;
-          // group.members[0].y = prevLine.y;
-
-          // FlxSpriteUtil.drawLine(this, prevLine.x, prevLine.y, width * remappedSample, left * height / 2 + height / 2);
           prevLine.x = (curAud.balanced * swagheight / 2 + swagheight / 2) + x;
           prevLine.y = (Std.int(remappedSample) / lengthOfShit * daHeight) + y;
 
diff --git a/source/funkin/audio/visualize/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx
index 204ced1e1..ba235fe89 100644
--- a/source/funkin/audio/visualize/VisShit.hx
+++ b/source/funkin/audio/visualize/VisShit.hx
@@ -3,7 +3,6 @@ package funkin.audio.visualize;
 import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
 import funkin.audio.visualize.dsp.FFT;
-import lime.system.ThreadPool;
 import lime.utils.Int16Array;
 import funkin.util.MathUtil;
 
@@ -73,9 +72,6 @@ class VisShit
 
       freqOutput.push([]);
 
-      // if (FlxG.keys.justPressed.M)
-      // trace(FFT.rfft(chunk).map(z -> z.scale(1 / fs).magnitude));
-
       // find spectral peaks and their instantaneous frequencies
       for (k => s in freqs)
       {
@@ -91,7 +87,6 @@ class VisShit
         if (freq < maxFreq) freqOutput[indexOfArray].push(power);
         //
       }
-      // haxe.Log.trace("", null);
 
       indexOfArray++;
       // move to next (overlapping) chunk
diff --git a/source/funkin/audio/visualize/dsp/FFT.hx b/source/funkin/audio/visualize/dsp/FFT.hx
index dc75acb81..40ee9cb8c 100644
--- a/source/funkin/audio/visualize/dsp/FFT.hx
+++ b/source/funkin/audio/visualize/dsp/FFT.hx
@@ -1,7 +1,5 @@
 package funkin.audio.visualize.dsp;
 
-import funkin.audio.visualize.dsp.Complex;
-
 using funkin.audio.visualize.dsp.OffsetArray;
 using funkin.audio.visualize.dsp.Signal;
 
diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx
index 1f649b472..a939f91bf 100644
--- a/source/funkin/audio/waveform/WaveformData.hx
+++ b/source/funkin/audio/waveform/WaveformData.hx
@@ -1,7 +1,5 @@
 package funkin.audio.waveform;
 
-import funkin.util.MathUtil;
-
 @:nullSafety
 class WaveformData
 {
diff --git a/source/funkin/audio/waveform/WaveformSprite.hx b/source/funkin/audio/waveform/WaveformSprite.hx
index 32ced2fbd..8eaba8117 100644
--- a/source/funkin/audio/waveform/WaveformSprite.hx
+++ b/source/funkin/audio/waveform/WaveformSprite.hx
@@ -1,7 +1,5 @@
 package funkin.audio.waveform;
 
-import funkin.audio.waveform.WaveformData;
-import funkin.audio.waveform.WaveformDataParser;
 import funkin.graphics.rendering.MeshRender;
 import flixel.util.FlxColor;
 
diff --git a/source/funkin/data/dialogue/conversation/ConversationData.hx b/source/funkin/data/dialogue/conversation/ConversationData.hx
index 30e3f451b..650519836 100644
--- a/source/funkin/data/dialogue/conversation/ConversationData.hx
+++ b/source/funkin/data/dialogue/conversation/ConversationData.hx
@@ -1,7 +1,5 @@
 package funkin.data.dialogue.conversation;
 
-import funkin.data.animation.AnimationData;
-
 /**
  * A type definition for the data for a specific conversation.
  * It includes things like what dialogue boxes to use, what text to display, and what animations to play.
diff --git a/source/funkin/data/dialogue/conversation/ConversationRegistry.hx b/source/funkin/data/dialogue/conversation/ConversationRegistry.hx
index ca072897f..fad1e43ad 100644
--- a/source/funkin/data/dialogue/conversation/ConversationRegistry.hx
+++ b/source/funkin/data/dialogue/conversation/ConversationRegistry.hx
@@ -1,7 +1,6 @@
 package funkin.data.dialogue.conversation;
 
 import funkin.play.cutscene.dialogue.Conversation;
-import funkin.data.dialogue.conversation.ConversationData;
 import funkin.play.cutscene.dialogue.ScriptedConversation;
 
 class ConversationRegistry extends BaseRegistry<Conversation, ConversationData>

From 9a47666fc7f5e51dc3b58df2023ec63d6a61202f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 28 May 2024 01:37:30 -0400
Subject: [PATCH 39/72] Fix a merge issue.

---
 source/funkin/input/Controls.hx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index 31551dec9..345791eef 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -997,7 +997,7 @@ class Controls extends FlxActionSet
     for (control in Control.createAll())
     {
       var inputs:Array<Int> = Reflect.field(data, control.getName());
-      inputs = inputs.unique();
+      inputs = inputs.distinct();
       if (inputs != null)
       {
         if (inputs.length == 0) {
@@ -1050,7 +1050,7 @@ class Controls extends FlxActionSet
       if (inputs.length == 0) {
         inputs = [FlxKey.NONE];
       } else {
-        inputs = inputs.unique();
+        inputs = inputs.distinct();
       }
 
       Reflect.setField(data, control.getName(), inputs);

From d4b2e9496382ef967d425d7fe92ca4a816dafe04 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 28 May 2024 02:17:55 -0400
Subject: [PATCH 40/72] assets submod

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

diff --git a/assets b/assets
index 8a8239cb5..371cce1fd 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 8a8239cb50b5277fb0cfce041b3d8a9dfc780c35
+Subproject commit 371cce1fdc44914ddc3a5327e996cece4e676715

From cf61b9ef90810451c1a186c0388325f4d012bb9a Mon Sep 17 00:00:00 2001
From: gamerbross <55158797+gamerbross@users.noreply.github.com>
Date: Tue, 28 May 2024 14:51:16 +0200
Subject: [PATCH 41/72] Add toString to Stage + Revert "Fix Unscripted Stage
 Log Trace"

---
 source/funkin/data/BaseRegistry.hx | 2 +-
 source/funkin/play/stage/Stage.hx  | 5 +++++
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 2df3a87da..118516bec 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -117,7 +117,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
         var entry:T = createEntry(entryId);
         if (entry != null)
         {
-          trace('  Loaded entry data: ${entry.id}');
+          trace('  Loaded entry data: ${entry}');
           entries.set(entry.id, entry);
         }
       }
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index eb9eb1810..a6a4293a0 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -852,6 +852,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
     }
   }
 
+  public override function toString():String
+  {
+    return 'Stage($id)';
+  }
+
   static function _fetchData(id:String):Null<StageData>
   {
     return StageRegistry.instance.parseEntryDataWithMigration(id, StageRegistry.instance.fetchEntryVersion(id));

From 1ae30283a3a0d2a3bafb6d4da84d906764e80ed6 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 28 May 2024 15:34:09 -0400
Subject: [PATCH 42/72] re add the xmlns schema stuff

---
 Project.xml | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/Project.xml b/Project.xml
index 24cdac270..dce45546f 100644
--- a/Project.xml
+++ b/Project.xml
@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
-<project>
+<project xmlns="http://lime.openfl.org/project/1.0.4" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+xsi:schemaLocation="http://lime.openfl.org/project/1.0.4 http://lime.openfl.org/xsd/project-1.0.4.xsd">
 	<!-- _________________________ Application Settings _________________________ -->
 	<app title="Friday Night Funkin'" file="Funkin" packageName="com.funkin.fnf" package="com.funkin.fnf" main="Main" version="0.3.3" company="ninjamuffin99" />
 	<!--Switch Export with Unique ApplicationID and Icon-->
@@ -14,6 +15,7 @@
 
 	<!--Minimum without FLX_NO_GAMEPAD: 11.8, without FLX_NO_NATIVE_CURSOR: 11.2-->
 	<set name="SWF_VERSION" value="11.8" />
+	<
 	<!-- ____________________________ Window Settings ___________________________ -->
 	<!--These window settings apply to all targets-->
 	<window width="1280" height="720" fps="60" background="#000000" hardware="true" vsync="false" />
@@ -28,7 +30,7 @@
 	<set name="BUILD_DIR" value="export/debug" if="debug" />
 	<set name="BUILD_DIR" value="export/release" unless="debug" />
 	<set name="BUILD_DIR" value="export/32bit" if="32bit" />
-	<classpath name="source" />
+	<source path="source" />
 	<assets path="assets/preload" rename="assets" exclude="*.ogg|*.wav" if="web" />
 	<assets path="assets/preload" rename="assets" exclude="*.mp3|*.wav" unless="web" />
 	<define name="PRELOAD_ALL" unless="web" />

From 01b6a11ddbbacc034f73b61451b78587b3536b7d Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 28 May 2024 22:57:01 -0400
Subject: [PATCH 43/72] flxpartialsound lock to current version

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

diff --git a/hmm.json b/hmm.json
index 6b119c52f..5260d5229 100644
--- a/hmm.json
+++ b/hmm.json
@@ -4,7 +4,7 @@
       "name": "FlxPartialSound",
       "type": "git",
       "dir": null,
-      "ref": "main",
+      "ref": "8bb8ed50f520d9cd64a65414b119b8718924b93a",
       "url": "https://github.com/FunkinCrew/FlxPartialSound.git"
     },
     {
@@ -178,4 +178,4 @@
       "url": "https://github.com/FunkinCrew/thx.semver"
     }
   ]
-}
\ No newline at end of file
+}

From 2d300039ae42988c570af0f36225c1732a4fb09c Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 28 May 2024 23:53:50 -0400
Subject: [PATCH 44/72] promises + error out partial sounds that attempt to
 load multiple times

---
 source/funkin/audio/FunkinSound.hx | 32 +++++++++++++++++-------------
 1 file changed, 18 insertions(+), 14 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index aaddda9dc..39a26aac1 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -360,24 +360,24 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     if (shouldLoadPartial)
     {
       var music = FunkinSound.loadPartial(pathToUse, params.partialParams?.start ?? 0, params.partialParams?.end ?? 1, params?.startingVolume ?? 1.0,
-        params.loop ?? true, false, true, params.onComplete, params.onLoad);
+        params.loop ?? true, false, false, params.onComplete);
 
       if (music != null)
       {
-        for (future in partialQueue)
+        while (partialQueue.length > 0)
         {
-          future = cast Future.withError("Music was overridden by another partial load");
+          @:nullSafety(Off)
+          partialQueue.pop().error("Cancel loading partial sound");
         }
-        partialQueue = [];
+
         partialQueue.push(music);
 
         @:nullSafety(Off)
-        music.onComplete(function(partialMusic:Null<FunkinSound>) {
-          if (partialQueue.pop() == music)
-          {
-            FlxG.sound.music = partialMusic;
-            FlxG.sound.list.remove(FlxG.sound.music);
-          }
+        music.future.onComplete(function(partialMusic:Null<FunkinSound>) {
+          FlxG.sound.music = partialMusic;
+          FlxG.sound.list.remove(FlxG.sound.music);
+
+          if (params.onLoad != null) params.onLoad();
         });
 
         return true;
@@ -406,7 +406,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     }
   }
 
-  static var partialQueue:Array<Future<Null<FunkinSound>>> = [];
+  static var partialQueue:Array<Promise<Null<FunkinSound>>> = [];
 
   /**
    * Creates a new `FunkinSound` object synchronously.
@@ -478,7 +478,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    * @return A FunkinSound object
    */
   public static function loadPartial(path:String, start:Float = 0, end:Float = 1, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false,
-      autoPlay:Bool = true, ?onComplete:Void->Void, ?onLoad:Void->Void):Future<Null<FunkinSound>>
+      autoPlay:Bool = true, ?onComplete:Void->Void, ?onLoad:Void->Void):Promise<Null<FunkinSound>>
   {
     var promise:lime.app.Promise<Null<FunkinSound>> = new lime.app.Promise<Null<FunkinSound>>();
 
@@ -488,12 +488,16 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 
     var soundRequest = FlxPartialSound.partialLoadFromFile(path, start, end);
 
-    soundRequest.onComplete(function(partialSound) {
+    promise.future.onError(function(e) {
+      soundRequest.error("Sound loading was errored or cancelled");
+    });
+
+    soundRequest.future.onComplete(function(partialSound) {
       var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, onComplete, onLoad);
       promise.complete(snd);
     });
 
-    return promise.future;
+    return promise;
   }
 
   @:nullSafety(Off)

From 1f64c7fcc9757004925fdb641f5b9f19be248e5f Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 28 May 2024 23:54:23 -0400
Subject: [PATCH 45/72] update hmm flxpartialsound

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

diff --git a/hmm.json b/hmm.json
index 5260d5229..91d2f08bb 100644
--- a/hmm.json
+++ b/hmm.json
@@ -4,7 +4,7 @@
       "name": "FlxPartialSound",
       "type": "git",
       "dir": null,
-      "ref": "8bb8ed50f520d9cd64a65414b119b8718924b93a",
+      "ref": "44aa7eb",
       "url": "https://github.com/FunkinCrew/FlxPartialSound.git"
     },
     {

From d97d77566e1e30800bb3c5f8ba973af38a8ded0b Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 29 May 2024 00:53:32 -0400
Subject: [PATCH 46/72] Make song score lerp faster

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

diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 820ac2ad1..c1a001e5d 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -306,7 +306,7 @@ class StoryMenuState extends MusicBeatState
   {
     Conductor.instance.update();
 
-    highScoreLerp = Std.int(MathUtil.smoothLerp(highScoreLerp, highScore, elapsed, 0.5));
+    highScoreLerp = Std.int(MathUtil.smoothLerp(highScoreLerp, highScore, elapsed, 0.25));
 
     scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}';
 

From fb752ddd7860248c208fc612a4630f4be2bcee1c Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 29 May 2024 17:05:20 -0400
Subject: [PATCH 47/72] remove random < in project.xml lol

---
 Project.xml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/Project.xml b/Project.xml
index 16e4b9854..fcd29a25e 100644
--- a/Project.xml
+++ b/Project.xml
@@ -15,7 +15,6 @@ xsi:schemaLocation="http://lime.openfl.org/project/1.0.4 http://lime.openfl.org/
 
 	<!--Minimum without FLX_NO_GAMEPAD: 11.8, without FLX_NO_NATIVE_CURSOR: 11.2-->
 	<set name="SWF_VERSION" value="11.8" />
-	<
 	<!-- ____________________________ Window Settings ___________________________ -->
 	<!--These window settings apply to all targets-->
 	<window width="1280" height="720" fps="60" background="#000000" hardware="true" vsync="false" />

From 8d7591a796f9c0c392eb66c8e494d5f9a7a5e36a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 29 May 2024 21:43:54 -0400
Subject: [PATCH 48/72] Fix an issue where Story Menu props wouldn't render.

---
 source/funkin/ui/story/LevelProp.hx | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
index 5a3efc36a..0547404a1 100644
--- a/source/funkin/ui/story/LevelProp.hx
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -11,12 +11,15 @@ class LevelProp extends Bopper
   function set_propData(value:LevelPropData):LevelPropData
   {
     // Only reset the prop if the asset path has changed.
-    if (propData == null || value?.assetPath != propData?.assetPath)
+    if (propData == null || !(thx.Dynamics.equals(value, propData)))
     {
+      this.propData = value;
+
+      this.visible = this.propData != null;
+      danceEvery = this.propData?.danceEvery ?? 0;
+
       applyData();
     }
-    this.visible = (value != null);
-    danceEvery = this.propData?.danceEvery ?? 0;
 
     return this.propData;
   }

From 174c595837a63fef473ad191576e51862c240cc0 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 29 May 2024 22:49:57 -0400
Subject: [PATCH 49/72] Fix crash caused by improperly canceling a tween

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

diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index c345871a9..8c45fac65 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -234,7 +234,7 @@ class PauseSubState extends MusicBeatSubState
   public override function destroy():Void
   {
     super.destroy();
-    charterFadeTween.destroy();
+    charterFadeTween.cancel();
     charterFadeTween = null;
     pauseMusic.stop();
   }

From 68e9937b43994f7fb1b21347bad3cd02ef195815 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 30 May 2024 04:33:26 +0100
Subject: [PATCH 50/72] interval shake class

---
 source/funkin/effects/IntervalShake.hx | 240 +++++++++++++++++++++++++
 1 file changed, 240 insertions(+)
 create mode 100644 source/funkin/effects/IntervalShake.hx

diff --git a/source/funkin/effects/IntervalShake.hx b/source/funkin/effects/IntervalShake.hx
new file mode 100644
index 000000000..545739cc3
--- /dev/null
+++ b/source/funkin/effects/IntervalShake.hx
@@ -0,0 +1,240 @@
+package funkin.effects;
+
+import flixel.FlxObject;
+import flixel.util.FlxDestroyUtil.IFlxDestroyable;
+import flixel.util.FlxPool;
+import flixel.util.FlxTimer;
+import flixel.math.FlxPoint;
+import flixel.util.FlxAxes;
+import flixel.tweens.FlxEase.EaseFunction;
+import flixel.math.FlxMath;
+
+/**
+ * pretty much a copy of FlxFlicker geared towards making sprites
+ * shake around at a set interval and slow down over time.
+ */
+class IntervalShake implements IFlxDestroyable
+{
+  static var _pool:FlxPool<IntervalShake> = new FlxPool<IntervalShake>(IntervalShake.new);
+
+  /**
+   * Internal map for looking up which objects are currently shaking and getting their shake data.
+   */
+  static var _boundObjects:Map<FlxObject, IntervalShake> = new Map<FlxObject, IntervalShake>();
+
+  /**
+   * An effect that shakes the sprite on a set interval and a starting intensity that goes down over time.
+   *
+   * @param   Object               The object to shake.
+   * @param   Duration             How long to shake for (in seconds). `0` means "forever".
+   * @param   Interval             In what interval to update the shake position. Set to `FlxG.elapsed` if `<= 0`!
+   * @param   StartIntensity       The starting intensity of the shake.
+   * @param   EndIntensity         The ending intensity of the shake.
+   * @param   Ease                 Control the easing of the intensity over the shake.
+   * @param   CompletionCallback   Callback on shake completion
+   * @param   ProgressCallback     Callback on each shake interval
+   * @return The `IntervalShake` object. `IntervalShake`s are pooled internally, so beware of storing references.
+   */
+  public static function shake(Object:FlxObject, Duration:Float = 1, Interval:Float = 0.04, StartIntensity:Float = 0, EndIntensity:Float = 0,
+      Ease:EaseFunction, ?CompletionCallback:IntervalShake->Void, ?ProgressCallback:IntervalShake->Void):IntervalShake
+  {
+    if (isShaking(Object))
+    {
+      // if (ForceRestart)
+      // {
+      //   stopShaking(Object);
+      // }
+      // else
+      // {
+      // Ignore this call if object is already flickering.
+      return _boundObjects[Object];
+      // }
+    }
+
+    if (Interval <= 0)
+    {
+      Interval = FlxG.elapsed;
+    }
+
+    var shake:IntervalShake = _pool.get();
+    shake.start(Object, Duration, Interval, StartIntensity, EndIntensity, Ease, CompletionCallback, ProgressCallback);
+    return _boundObjects[Object] = shake;
+  }
+
+  /**
+   * Returns whether the object is shaking or not.
+   *
+   * @param   Object The object to test.
+   */
+  public static function isShaking(Object:FlxObject):Bool
+  {
+    return _boundObjects.exists(Object);
+  }
+
+  /**
+   * Stops shaking the object.
+   *
+   * @param   Object The object to stop shaking.
+   */
+  public static function stopShaking(Object:FlxObject):Void
+  {
+    var boundShake:IntervalShake = _boundObjects[Object];
+    if (boundShake != null)
+    {
+      boundShake.stop();
+    }
+  }
+
+  /**
+   * The shaking object.
+   */
+  public var object(default, null):FlxObject;
+
+  /**
+   * The shaking timer. You can check how many seconds has passed since shaking started etc.
+   */
+  public var timer(default, null):FlxTimer;
+
+  /**
+   * The starting intensity of the shake.
+   */
+  public var startIntensity(default, null):Float;
+
+  /**
+   * The ending intensity of the shake.
+   */
+  public var endIntensity(default, null):Float;
+
+  /**
+   * How long to shake for (in seconds). `0` means "forever".
+   */
+  public var duration(default, null):Float;
+
+  /**
+   * The interval of the shake.
+   */
+  public var interval(default, null):Float;
+
+  /**
+   * Defines on what axes to `shake()`. Default value is `XY` / both.
+   */
+  public var axes(default, null):FlxAxes;
+
+  /**
+   * Defines the initial position of the object at the beginning of the shake effect.
+   */
+  public var initialOffset(default, null):FlxPoint;
+
+  /**
+   * The callback that will be triggered after the shake has completed.
+   */
+  public var completionCallback(default, null):IntervalShake->Void;
+
+  /**
+   * The callback that will be triggered every time the object shakes.
+   */
+  public var progressCallback(default, null):IntervalShake->Void;
+
+  /**
+   * The easing of the intensity over the shake.
+   */
+  public var ease(default, null):EaseFunction;
+
+  /**
+   * Nullifies the references to prepare object for reuse and avoid memory leaks.
+   */
+  public function destroy():Void
+  {
+    object = null;
+    timer = null;
+    ease = null;
+    completionCallback = null;
+    progressCallback = null;
+  }
+
+  /**
+   * Starts shaking behavior.
+   */
+  function start(Object:FlxObject, Duration:Float = 1, Interval:Float = 0.04, StartIntensity:Float = 0, EndIntensity:Float = 0, Ease:EaseFunction,
+      ?CompletionCallback:IntervalShake->Void, ?ProgressCallback:IntervalShake->Void):Void
+  {
+    object = Object;
+    duration = Duration;
+    interval = Interval;
+    completionCallback = CompletionCallback;
+    startIntensity = StartIntensity;
+    endIntensity = EndIntensity;
+    initialOffset = new FlxPoint(Object.x, Object.y);
+    ease = Ease;
+    axes = FlxAxes.XY;
+    _secondsSinceStart = 0;
+    timer = new FlxTimer().start(interval, shakeProgress, Std.int(duration / interval));
+  }
+
+  /**
+   * Prematurely ends shaking.
+   */
+  public function stop():Void
+  {
+    timer.cancel();
+    // object.visible = true;
+    object.x = initialOffset.x;
+    object.y = initialOffset.y;
+    release();
+  }
+
+  /**
+   * Unbinds the object from shaking and releases it into pool for reuse.
+   */
+  function release():Void
+  {
+    _boundObjects.remove(object);
+    _pool.put(this);
+  }
+
+  public var _secondsSinceStart(default, null):Float = 0;
+
+  public var scale(default, null):Float = 0;
+
+  /**
+   * Just a helper function for shake() to update object's position.
+   */
+  function shakeProgress(timer:FlxTimer):Void
+  {
+    _secondsSinceStart += interval;
+    scale = _secondsSinceStart / duration;
+    if (ease != null)
+    {
+      scale = 1 - ease(scale);
+      // trace(scale);
+    }
+
+    var curIntensity:Float = 0;
+    curIntensity = FlxMath.lerp(endIntensity, startIntensity, scale);
+
+    if (axes.x) object.x = initialOffset.x + FlxG.random.float((-curIntensity) * object.width, (curIntensity) * object.width);
+    if (axes.y) object.y = initialOffset.y + FlxG.random.float((-curIntensity) * object.width, (curIntensity) * object.width);
+
+    // object.visible = !object.visible;
+
+    if (progressCallback != null) progressCallback(this);
+
+    if (timer.loops > 0 && timer.loopsLeft == 0)
+    {
+      object.x = initialOffset.x;
+      object.y = initialOffset.y;
+      if (completionCallback != null)
+      {
+        completionCallback(this);
+      }
+
+      if (this.timer == timer) release();
+    }
+  }
+
+  /**
+   * Internal constructor. Use static methods.
+   */
+  @:keep
+  function new() {}
+}

From e7079452fb4e54cc26d8c1cd132d70d0103a5d37 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 30 May 2024 04:34:00 +0100
Subject: [PATCH 51/72] freeplay visual changes + base rank visuals

---
 source/funkin/ui/freeplay/CapsuleText.hx   | 135 +++++++
 source/funkin/ui/freeplay/DJBoyfriend.hx   |  35 ++
 source/funkin/ui/freeplay/FreeplayState.hx | 396 +++++++++++++++++-
 source/funkin/ui/freeplay/SongMenuItem.hx  | 450 ++++++++++++++++++++-
 4 files changed, 992 insertions(+), 24 deletions(-)

diff --git a/source/funkin/ui/freeplay/CapsuleText.hx b/source/funkin/ui/freeplay/CapsuleText.hx
index 3a520e015..c3fd51d1f 100644
--- a/source/funkin/ui/freeplay/CapsuleText.hx
+++ b/source/funkin/ui/freeplay/CapsuleText.hx
@@ -4,6 +4,12 @@ import openfl.filters.BitmapFilterQuality;
 import flixel.text.FlxText;
 import flixel.group.FlxSpriteGroup;
 import funkin.graphics.shaders.GaussianBlurShader;
+import funkin.graphics.shaders.LeftMaskShader;
+import flixel.math.FlxRect;
+import flixel.tweens.FlxEase;
+import flixel.util.FlxTimer;
+import flixel.tweens.FlxTween;
+import openfl.display.BlendMode;
 
 class CapsuleText extends FlxSpriteGroup
 {
@@ -13,6 +19,15 @@ class CapsuleText extends FlxSpriteGroup
 
   public var text(default, set):String;
 
+  var maskShaderSongName:LeftMaskShader = new LeftMaskShader();
+
+  public var clipWidth(default, set):Int = 255;
+
+  public var tooLong:Bool = false;
+
+  // 255, 27 normal
+  // 220, 27 favourited
+
   public function new(x:Float, y:Float, songTitle:String, size:Float)
   {
     super(x, y);
@@ -36,6 +51,30 @@ class CapsuleText extends FlxSpriteGroup
     return text;
   }
 
+  // ???? none
+  // 255, 27 normal
+  // 220, 27 favourited
+
+  function set_clipWidth(value:Int):Int
+  {
+    resetText();
+    if (whiteText.width > value)
+    {
+      tooLong = true;
+
+      blurredText.clipRect = new FlxRect(0, 0, value, blurredText.height);
+      whiteText.clipRect = new FlxRect(0, 0, value, whiteText.height);
+    }
+    else
+    {
+      tooLong = false;
+
+      blurredText.clipRect = null;
+      whiteText.clipRect = null;
+    }
+    return clipWidth = value;
+  }
+
   function set_text(value:String):String
   {
     if (value == null) return value;
@@ -51,6 +90,102 @@ class CapsuleText extends FlxSpriteGroup
       new openfl.filters.GlowFilter(0x00ccff, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM),
       // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW)
     ];
+
     return text = value;
   }
+
+  var moveTimer:FlxTimer = new FlxTimer();
+  var moveTween:FlxTween;
+
+  public function initMove():Void
+  {
+    moveTimer.start(0.6, (timer) -> {
+      moveTextRight();
+    });
+  }
+
+  function moveTextRight():Void
+  {
+    var distToMove:Float = whiteText.width - clipWidth;
+    moveTween = FlxTween.tween(whiteText.offset, {x: distToMove}, 2,
+      {
+        onUpdate: function(_) {
+          whiteText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height);
+          blurredText.offset = whiteText.offset;
+          blurredText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, blurredText.height);
+        },
+        onComplete: function(_) {
+          moveTimer.start(0.3, (timer) -> {
+            moveTextLeft();
+          });
+        },
+        ease: FlxEase.sineInOut
+      });
+  }
+
+  function moveTextLeft():Void
+  {
+    moveTween = FlxTween.tween(whiteText.offset, {x: 0}, 2,
+      {
+        onUpdate: function(_) {
+          whiteText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height);
+          blurredText.offset = whiteText.offset;
+          blurredText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, blurredText.height);
+        },
+        onComplete: function(_) {
+          moveTimer.start(0.3, (timer) -> {
+            moveTextRight();
+          });
+        },
+        ease: FlxEase.sineInOut
+      });
+  }
+
+  public function resetText():Void
+  {
+    if (moveTween != null) moveTween.cancel();
+    if (moveTimer != null) moveTimer.cancel();
+    whiteText.offset.x = 0;
+    whiteText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height);
+    blurredText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height);
+  }
+
+  var flickerState:Bool = false;
+  var flickerTimer:FlxTimer;
+
+  public function flickerText():Void
+  {
+    resetText();
+    flickerTimer = new FlxTimer().start(1 / 24, flickerProgress, 19);
+  }
+
+  function flickerProgress(timer:FlxTimer):Void
+  {
+    if (flickerState == true)
+    {
+      whiteText.blend = BlendMode.ADD;
+      blurredText.blend = BlendMode.ADD;
+      blurredText.color = 0xFFFFFFFF;
+      whiteText.color = 0xFFFFFFFF;
+      whiteText.textField.filters = [
+        new openfl.filters.GlowFilter(0xFFFFFF, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM),
+        // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW)
+      ];
+    }
+    else
+    {
+      blurredText.color = 0xFF00aadd;
+      whiteText.color = 0xFFDDDDDD;
+      whiteText.textField.filters = [
+        new openfl.filters.GlowFilter(0xDDDDDD, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM),
+        // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW)
+      ];
+    }
+    flickerState = !flickerState;
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+  }
 }
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 5f1144fab..248526aaf 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -82,6 +82,8 @@ class DJBoyfriend extends FlxAtlasSprite
     return anims;
   }
 
+  var lowPumpLoopPoint:Int = 4;
+
   public override function update(elapsed:Float):Void
   {
     super.update(elapsed);
@@ -114,6 +116,14 @@ class DJBoyfriend extends FlxAtlasSprite
       case Confirm:
         if (getCurrentAnimation() != 'Boyfriend DJ confirm') playFlashAnimation('Boyfriend DJ confirm', false);
         timeSinceSpook = 0;
+      case PumpIntro:
+        if (getCurrentAnimation() != 'Boyfriend DJ fist pump') playFlashAnimation('Boyfriend DJ fist pump', false);
+        if (getCurrentAnimation() == 'Boyfriend DJ fist pump' && anim.curFrame >= 4)
+        {
+          anim.play("Boyfriend DJ fist pump", true, false, 0);
+        }
+      case FistPump:
+
       case Spook:
         if (getCurrentAnimation() != 'bf dj afk')
         {
@@ -174,6 +184,12 @@ class DJBoyfriend extends FlxAtlasSprite
         currentState = Idle;
       case "Boyfriend DJ confirm":
 
+      case "Boyfriend DJ fist pump":
+        currentState = Idle;
+
+      case "Boyfriend DJ loss reaction 1":
+        currentState = Idle;
+
       case "Boyfriend DJ watchin tv OG":
         var frame:Int = FlxG.random.bool(33) ? 112 : 166;
 
@@ -275,6 +291,23 @@ class DJBoyfriend extends FlxAtlasSprite
     currentState = Confirm;
   }
 
+  public function fistPump():Void
+  {
+    currentState = PumpIntro;
+  }
+
+  public function pumpFist():Void
+  {
+    currentState = FistPump;
+    anim.play("Boyfriend DJ fist pump", true, false, 4);
+  }
+
+  public function pumpFistBad():Void
+  {
+    currentState = FistPump;
+    anim.play("Boyfriend DJ loss reaction 1", true, false, 4);
+  }
+
   public inline function addOffset(name:String, x:Float = 0, y:Float = 0)
   {
     animOffsets[name] = [x, y];
@@ -331,6 +364,8 @@ enum DJBoyfriendState
   Intro;
   Idle;
   Confirm;
+  PumpIntro;
+  FistPump;
   Spook;
   TV;
 }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 7b7543845..a665f0756 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1,5 +1,6 @@
 package funkin.ui.freeplay;
 
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.addons.ui.FlxInputText;
 import flixel.FlxCamera;
@@ -10,6 +11,7 @@ import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.input.touch.FlxTouch;
 import flixel.math.FlxAngle;
 import flixel.math.FlxPoint;
+import openfl.display.BlendMode;
 import flixel.system.debug.watch.Tracker.TrackerProfile;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
@@ -38,6 +40,8 @@ import funkin.ui.transition.LoadingState;
 import funkin.ui.transition.StickerSubState;
 import funkin.util.MathUtil;
 import lime.utils.Assets;
+import flixel.tweens.misc.ShakeTween;
+import funkin.effects.IntervalShake;
 
 /**
  * Parameters used to initialize the FreeplayState.
@@ -135,6 +139,29 @@ class FreeplayState extends MusicBeatSubState
   public static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
   public static var rememberedSongId:Null<String> = 'tutorial';
 
+  var funnyCam:FunkinCamera;
+  var rankCamera:FunkinCamera;
+  var rankBg:FunkinSprite;
+  var rankVignette:FlxSprite;
+
+  var backingTextYeah:FlxAtlasSprite;
+  var orangeBackShit:FunkinSprite;
+  var alsoOrangeLOL:FunkinSprite;
+  var pinkBack:FunkinSprite;
+  var confirmGlow:FlxSprite;
+  var confirmGlow2:FlxSprite;
+  var confirmTextGlow:FlxSprite;
+
+  var moreWays:BGScrollingText;
+  var funnyScroll:BGScrollingText;
+  var txtNuts:BGScrollingText;
+  var funnyScroll2:BGScrollingText;
+  var moreWays2:BGScrollingText;
+  var funnyScroll3:BGScrollingText;
+
+  var bgDad:FlxSprite;
+  var cardGlow:FlxSprite;
+
   public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
   {
     currentCharacter = params?.character ?? Constants.DEFAULT_CHARACTER;
@@ -216,17 +243,17 @@ class FreeplayState extends MusicBeatSubState
     trace(FlxG.camera.initialZoom);
     trace(FlxCamera.defaultZoom);
 
-    var pinkBack:FunkinSprite = FunkinSprite.create('freeplay/pinkBack');
+    pinkBack = FunkinSprite.create('freeplay/pinkBack');
     pinkBack.color = 0xFFFFD4E9; // sets it to pink!
     pinkBack.x -= pinkBack.width;
 
     FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut});
     add(pinkBack);
 
-    var orangeBackShit:FunkinSprite = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFFEDA00);
+    orangeBackShit = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFFEDA00);
     add(orangeBackShit);
 
-    var alsoOrangeLOL:FunkinSprite = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFFFD400);
+    alsoOrangeLOL = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFFFD400);
     add(alsoOrangeLOL);
 
     exitMovers.set([pinkBack, orangeBackShit, alsoOrangeLOL],
@@ -241,13 +268,30 @@ class FreeplayState extends MusicBeatSubState
     orangeBackShit.visible = false;
     alsoOrangeLOL.visible = false;
 
+    confirmTextGlow = new FlxSprite(-8, 115).loadGraphic(Paths.image('freeplay/glowingText'));
+    confirmTextGlow.blend = BlendMode.ADD;
+    confirmTextGlow.visible = false;
+
+    confirmGlow = new FlxSprite(-30, 240).loadGraphic(Paths.image('freeplay/confirmGlow'));
+    confirmGlow.blend = BlendMode.ADD;
+
+    confirmGlow2 = new FlxSprite(confirmGlow.x, confirmGlow.y).loadGraphic(Paths.image('freeplay/confirmGlow2'));
+
+    confirmGlow.visible = false;
+    confirmGlow2.visible = false;
+
+    add(confirmGlow2);
+    add(confirmGlow);
+
+    add(confirmTextGlow);
+
     var grpTxtScrolls:FlxGroup = new FlxGroup();
     add(grpTxtScrolls);
     grpTxtScrolls.visible = false;
 
     FlxG.debugger.addTrackerProfile(new TrackerProfile(BGScrollingText, ['x', 'y', 'speed', 'size']));
 
-    var moreWays:BGScrollingText = new BGScrollingText(0, 160, 'HOT BLOODED IN MORE WAYS THAN ONE', FlxG.width, true, 43);
+    moreWays = new BGScrollingText(0, 160, 'HOT BLOODED IN MORE WAYS THAN ONE', FlxG.width, true, 43);
     moreWays.funnyColor = 0xFFFFF383;
     moreWays.speed = 6.8;
     grpTxtScrolls.add(moreWays);
@@ -258,7 +302,7 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.4,
       });
 
-    var funnyScroll:BGScrollingText = new BGScrollingText(0, 220, 'BOYFRIEND', FlxG.width / 2, false, 60);
+    funnyScroll = new BGScrollingText(0, 220, 'BOYFRIEND', FlxG.width / 2, false, 60);
     funnyScroll.funnyColor = 0xFFFF9963;
     funnyScroll.speed = -3.8;
     grpTxtScrolls.add(funnyScroll);
@@ -271,7 +315,7 @@ class FreeplayState extends MusicBeatSubState
         wait: 0
       });
 
-    var txtNuts:BGScrollingText = new BGScrollingText(0, 285, 'PROTECT YO NUTS', FlxG.width / 2, true, 43);
+    txtNuts = new BGScrollingText(0, 285, 'PROTECT YO NUTS', FlxG.width / 2, true, 43);
     txtNuts.speed = 3.5;
     grpTxtScrolls.add(txtNuts);
     exitMovers.set([txtNuts],
@@ -280,7 +324,7 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.4,
       });
 
-    var funnyScroll2:BGScrollingText = new BGScrollingText(0, 335, 'BOYFRIEND', FlxG.width / 2, false, 60);
+    funnyScroll2 = new BGScrollingText(0, 335, 'BOYFRIEND', FlxG.width / 2, false, 60);
     funnyScroll2.funnyColor = 0xFFFF9963;
     funnyScroll2.speed = -3.8;
     grpTxtScrolls.add(funnyScroll2);
@@ -291,7 +335,7 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.5,
       });
 
-    var moreWays2:BGScrollingText = new BGScrollingText(0, 397, 'HOT BLOODED IN MORE WAYS THAN ONE', FlxG.width, true, 43);
+    moreWays2 = new BGScrollingText(0, 397, 'HOT BLOODED IN MORE WAYS THAN ONE', FlxG.width, true, 43);
     moreWays2.funnyColor = 0xFFFFF383;
     moreWays2.speed = 6.8;
     grpTxtScrolls.add(moreWays2);
@@ -302,7 +346,7 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.4
       });
 
-    var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y + 10, 'BOYFRIEND', FlxG.width / 2, 60);
+    funnyScroll3 = new BGScrollingText(0, orangeBackShit.y + 10, 'BOYFRIEND', FlxG.width / 2, 60);
     funnyScroll3.funnyColor = 0xFFFEA400;
     funnyScroll3.speed = -3.8;
     grpTxtScrolls.add(funnyScroll3);
@@ -313,6 +357,24 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.3
       });
 
+    backingTextYeah = new FlxAtlasSprite(640, 370, Paths.animateAtlas("freeplay/backing-text-yeah"),
+      {
+        FrameRate: 24.0,
+        Reversed: false,
+        // ?OnComplete:Void -> Void,
+        ShowPivot: false,
+        Antialiasing: true,
+        ScrollFactor: new FlxPoint(1, 1),
+      });
+
+    add(backingTextYeah);
+
+    cardGlow = new FlxSprite(-30, -30).loadGraphic(Paths.image('freeplay/cardGlow'));
+    cardGlow.blend = BlendMode.ADD;
+    cardGlow.visible = false;
+
+    add(cardGlow);
+
     dj = new DJBoyfriend(640, 366);
     exitMovers.set([dj],
       {
@@ -325,7 +387,7 @@ class FreeplayState extends MusicBeatSubState
 
     add(dj);
 
-    var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
+    bgDad = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
     bgDad.setGraphicSize(0, FlxG.height);
     bgDad.updateHitbox();
     bgDad.shader = new AngleMask();
@@ -342,10 +404,14 @@ class FreeplayState extends MusicBeatSubState
       });
 
     add(bgDad);
-    FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.75}, 0.7, {ease: FlxEase.quintOut});
+    FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.76}, 0.7, {ease: FlxEase.quintOut});
 
     blackOverlayBullshitLOLXD.shader = bgDad.shader;
 
+    rankBg = new FunkinSprite(0, 0);
+    rankBg.makeSolidColor(FlxG.width, FlxG.height, 0xD3000000);
+    add(rankBg);
+
     grpSongs = new FlxTypedGroup<Alphabet>();
     add(grpSongs);
 
@@ -527,18 +593,35 @@ class FreeplayState extends MusicBeatSubState
       orangeBackShit.visible = true;
       alsoOrangeLOL.visible = true;
       grpTxtScrolls.visible = true;
+
+      cardGlow.visible = true;
+      FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut});
     });
 
     generateSongList(null, false);
 
     // dedicated camera for the state so we don't need to fuk around with camera scrolls from the mainmenu / elsewhere
-    var funnyCam:FunkinCamera = new FunkinCamera('freeplayFunny', 0, 0, FlxG.width, FlxG.height);
+    funnyCam = new FunkinCamera('freeplayFunny', 0, 0, FlxG.width, FlxG.height);
     funnyCam.bgColor = FlxColor.TRANSPARENT;
     FlxG.cameras.add(funnyCam, false);
 
+    rankVignette = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/rankVignette'));
+    rankVignette.scale.set(2, 2);
+    rankVignette.updateHitbox();
+    rankVignette.blend = BlendMode.ADD;
+    // rankVignette.cameras = [rankCamera];
+    add(rankVignette);
+    rankVignette.alpha = 0;
+
     forEach(function(bs) {
       bs.cameras = [funnyCam];
     });
+
+    rankCamera = new FunkinCamera('rankCamera', 0, 0, FlxG.width, FlxG.height);
+    rankCamera.bgColor = FlxColor.TRANSPARENT;
+    FlxG.cameras.add(rankCamera, false);
+    rankBg.cameras = [rankCamera];
+    rankBg.alpha = 0;
   }
 
   var currentFilter:SongFilter = null;
@@ -585,6 +668,7 @@ class FreeplayState extends MusicBeatSubState
 
     for (cap in grpCapsules.members)
     {
+      cap.songText.resetText();
       cap.kill();
     }
 
@@ -602,9 +686,11 @@ class FreeplayState extends MusicBeatSubState
     };
     randomCapsule.y = randomCapsule.intendedY(0) + 10;
     randomCapsule.targetPos.x = randomCapsule.x;
-    randomCapsule.alpha = 0.5;
+    randomCapsule.alpha = 0;
     randomCapsule.songText.visible = false;
     randomCapsule.favIcon.visible = false;
+    randomCapsule.ranking.visible = false;
+    randomCapsule.blurredRanking.visible = false;
     randomCapsule.initJumpIn(0, force);
     randomCapsule.hsvShader = hsvShader;
     grpCapsules.add(randomCapsule);
@@ -627,8 +713,12 @@ class FreeplayState extends MusicBeatSubState
       funnyMenu.favIcon.visible = tempSongs[i].isFav;
       funnyMenu.hsvShader = hsvShader;
 
+      funnyMenu.newText.animation.curAnim.curFrame = 45 - ((i * 4) % 45);
+
       funnyMenu.forcePosition();
 
+      funnyMenu.checkClip();
+
       grpCapsules.add(funnyMenu);
     }
 
@@ -682,6 +772,210 @@ class FreeplayState extends MusicBeatSubState
     return songsToFilter;
   }
 
+  function rankAnimStart()
+  {
+    dj.fistPump();
+    // rankCamera.fade(FlxColor.BLACK, 0.5, true);
+    rankCamera.fade(0xFF000000, 0.5, true, null, true);
+    FlxG.sound.music.volume = 0;
+    rankBg.alpha = 1;
+
+    originalPos.x = grpCapsules.members[curSelected].x;
+    originalPos.y = grpCapsules.members[curSelected].y;
+
+    grpCapsules.members[curSelected].ranking.alpha = 0;
+    grpCapsules.members[curSelected].blurredRanking.alpha = 0;
+
+    rankCamera.zoom = 1.85;
+    FlxTween.tween(rankCamera, {"zoom": 1.8}, 0.6, {ease: FlxEase.sineIn});
+
+    funnyCam.zoom = 1.15;
+    FlxTween.tween(funnyCam, {"zoom": 1.1}, 0.6, {ease: FlxEase.sineIn});
+
+    grpCapsules.members[curSelected].cameras = [rankCamera];
+    grpCapsules.members[curSelected].targetPos.set((FlxG.width / 2) - (grpCapsules.members[curSelected].width / 2),
+      (FlxG.height / 2) - (grpCapsules.members[curSelected].height / 2));
+
+    new FlxTimer().start(0.5, _ -> {
+      grpCapsules.members[curSelected].doLerp = false;
+      rankDisplayNew();
+    });
+  }
+
+  function rankDisplayNew()
+  {
+    grpCapsules.members[curSelected].ranking.alpha = 1;
+    grpCapsules.members[curSelected].blurredRanking.alpha = 1;
+
+    grpCapsules.members[curSelected].ranking.scale.set(20, 20);
+    grpCapsules.members[curSelected].blurredRanking.scale.set(20, 20);
+    // var tempr:Int = FlxG.random.int(0, 4);
+
+    // grpCapsules.members[curSelected].ranking.rank = tempr;
+    grpCapsules.members[curSelected].ranking.animation.play(grpCapsules.members[curSelected].ranking.animation.curAnim.name, true);
+    FlxTween.tween(grpCapsules.members[curSelected].ranking, {"scale.x": 1, "scale.y": 1}, 0.1);
+
+    grpCapsules.members[curSelected].blurredRanking.animation.play(grpCapsules.members[curSelected].blurredRanking.animation.curAnim.name, true);
+    FlxTween.tween(grpCapsules.members[curSelected].blurredRanking, {"scale.x": 1, "scale.y": 1}, 0.1);
+
+    new FlxTimer().start(0.1, _ -> {
+      trace(grpCapsules.members[curSelected].ranking.rank);
+      switch (grpCapsules.members[curSelected].tempr)
+      {
+        case 0:
+          FunkinSound.playOnce(Paths.sound('ranks/rankinbad'));
+        case 4:
+          FunkinSound.playOnce(Paths.sound('ranks/rankinperfect'));
+        case 5:
+          FunkinSound.playOnce(Paths.sound('ranks/rankinperfect'));
+        default:
+          FunkinSound.playOnce(Paths.sound('ranks/rankinnormal'));
+      }
+      rankCamera.zoom = 1.3;
+      // FlxTween.tween(rankCamera, {"zoom": 1.4}, 0.3, {ease: FlxEase.elasticOut});
+
+      FlxTween.tween(rankCamera, {"zoom": 1.5}, 0.3, {ease: FlxEase.backInOut});
+
+      grpCapsules.members[curSelected].x -= 10;
+      grpCapsules.members[curSelected].y -= 20;
+
+      FlxTween.tween(funnyCam, {"zoom": 1.05}, 0.3, {ease: FlxEase.elasticOut});
+
+      grpCapsules.members[curSelected].capsule.angle = -3;
+      FlxTween.tween(grpCapsules.members[curSelected].capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut});
+
+      IntervalShake.shake(grpCapsules.members[curSelected].capsule, 0.3, 1 / 30, 0.1, 0, FlxEase.quadOut);
+    });
+
+    new FlxTimer().start(0.4, _ -> {
+      FlxTween.tween(funnyCam, {"zoom": 1}, 0.8, {ease: FlxEase.sineIn});
+      FlxTween.tween(rankCamera, {"zoom": 1.2}, 0.8, {ease: FlxEase.backIn});
+      // IntervalShake.shake(grpCapsules.members[curSelected], 0.8 + 0.5, 1 / 24, 0, 2, FlxEase.quadIn);
+      FlxTween.tween(grpCapsules.members[curSelected], {x: originalPos.x - 7, y: originalPos.y - 80}, 0.8 + 0.5, {ease: FlxEase.quartIn});
+    });
+
+    new FlxTimer().start(0.6, _ -> {
+      rankAnimSlam();
+      // IntervalShake.shake(grpCapsules.members[curSelected].capsule, 0.3, 1 / 30, 0, 0.3, FlxEase.quartIn);
+    });
+  }
+
+  function rankAnimSlam()
+  {
+    // FlxTween.tween(rankCamera, {"zoom": 1.9}, 0.5, {ease: FlxEase.backOut});
+    FlxTween.tween(rankBg, {alpha: 0}, 0.5, {ease: FlxEase.expoIn});
+
+    // FlxTween.tween(grpCapsules.members[curSelected], {angle: 5}, 0.5, {ease: FlxEase.backIn});
+
+    switch (grpCapsules.members[curSelected].tempr)
+    {
+      case 0:
+        FunkinSound.playOnce(Paths.sound('ranks/loss'));
+      case 1:
+        FunkinSound.playOnce(Paths.sound('ranks/good'));
+      case 2:
+        FunkinSound.playOnce(Paths.sound('ranks/great'));
+      case 3:
+        FunkinSound.playOnce(Paths.sound('ranks/excellent'));
+      case 4:
+        FunkinSound.playOnce(Paths.sound('ranks/perfect'));
+      case 5:
+        FunkinSound.playOnce(Paths.sound('ranks/perfect'));
+      default:
+        FunkinSound.playOnce(Paths.sound('ranks/loss'));
+    }
+
+    FlxTween.tween(grpCapsules.members[curSelected], {"targetPos.x": originalPos.x, "targetPos.y": originalPos.y}, 0.5, {ease: FlxEase.expoOut});
+    new FlxTimer().start(0.5, _ -> {
+      funnyCam.shake(0.0045, 0.35);
+
+      if (grpCapsules.members[curSelected].tempr == 0)
+      {
+        dj.pumpFistBad();
+      }
+      else
+      {
+        dj.pumpFist();
+      }
+
+      rankCamera.zoom = 0.8;
+      funnyCam.zoom = 0.8;
+      FlxTween.tween(rankCamera, {"zoom": 1}, 1, {ease: FlxEase.elasticOut});
+      FlxTween.tween(funnyCam, {"zoom": 1}, 0.8, {ease: FlxEase.elasticOut});
+
+      for (index => capsule in grpCapsules.members)
+      {
+        var distFromSelected:Float = Math.abs(index - curSelected) - 1;
+
+        if (distFromSelected < 5)
+        {
+          if (index == curSelected)
+          {
+            FlxTween.cancelTweensOf(capsule);
+            // capsule.targetPos.x += 50;
+            capsule.fadeAnim();
+
+            rankVignette.color = capsule.getTrailColor();
+            rankVignette.alpha = 1;
+            FlxTween.tween(rankVignette, {alpha: 0}, 0.6, {ease: FlxEase.expoOut});
+
+            capsule.doLerp = false;
+            capsule.setPosition(originalPos.x, originalPos.y);
+            IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12, 0, FlxEase.quadOut, function(_) {
+              capsule.doLerp = true;
+              capsule.cameras = [funnyCam];
+            }, null);
+
+            // FlxTween.tween(capsule, {"targetPos.x": capsule.targetPos.x - 50}, 0.6,
+            //   {
+            //     ease: FlxEase.backInOut,
+            //     onComplete: function(_) {
+            //       capsule.cameras = [funnyCam];
+            //     }
+            //   });
+            FlxTween.tween(capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut});
+          }
+          if (index > curSelected)
+          {
+            // capsule.color = FlxColor.RED;
+            new FlxTimer().start(distFromSelected / 20, _ -> {
+              capsule.doLerp = false;
+
+              capsule.capsule.angle = FlxG.random.float(-10 + (distFromSelected * 2), 10 - (distFromSelected * 2));
+              FlxTween.tween(capsule.capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut});
+
+              IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12 / (distFromSelected + 1), 0, FlxEase.quadOut, function(_) {
+                capsule.doLerp = true;
+              });
+            });
+          }
+
+          if (index < curSelected)
+          {
+            // capsule.color = FlxColor.BLUE;
+            new FlxTimer().start(distFromSelected / 20, _ -> {
+              capsule.doLerp = false;
+
+              capsule.capsule.angle = FlxG.random.float(-10 + (distFromSelected * 2), 10 - (distFromSelected * 2));
+              FlxTween.tween(capsule.capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut});
+
+              IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12 / (distFromSelected + 1), 0, FlxEase.quadOut, function(_) {
+                capsule.doLerp = true;
+              });
+            });
+          }
+        }
+
+        index += 1;
+      }
+    });
+
+    new FlxTimer().start(2, _ -> {
+      // dj.fistPump();
+      FlxG.sound.music.fadeIn(4.0, 0.0, 1.0);
+    });
+  }
+
   var touchY:Float = 0;
   var touchX:Float = 0;
   var dxTouch:Float = 0;
@@ -698,10 +992,48 @@ class FreeplayState extends MusicBeatSubState
 
   var busy:Bool = false; // Set to true once the user has pressed enter to select a song.
 
+  var originalPos:FlxPoint = new FlxPoint();
+
   override function update(elapsed:Float):Void
   {
     super.update(elapsed);
 
+    if (FlxG.keys.justPressed.T)
+    {
+      rankAnimStart();
+    }
+
+    if (FlxG.keys.justPressed.H)
+    {
+      rankDisplayNew();
+    }
+
+    if (FlxG.keys.justPressed.G)
+    {
+      rankAnimSlam();
+    }
+
+    if (FlxG.keys.justPressed.I)
+    {
+      confirmTextGlow.y -= 1;
+      trace(confirmTextGlow.x, confirmTextGlow.y);
+    }
+    if (FlxG.keys.justPressed.J)
+    {
+      confirmTextGlow.x -= 1;
+      trace(confirmTextGlow.x, confirmTextGlow.y);
+    }
+    if (FlxG.keys.justPressed.L)
+    {
+      confirmTextGlow.x += 1;
+      trace(confirmTextGlow.x, confirmTextGlow.y);
+    }
+    if (FlxG.keys.justPressed.K)
+    {
+      confirmTextGlow.y += 1;
+      trace(confirmTextGlow.x, confirmTextGlow.y);
+    }
+
     if (FlxG.keys.justPressed.F)
     {
       var targetSong = grpCapsules.members[curSelected]?.songData;
@@ -1145,6 +1477,42 @@ class FreeplayState extends MusicBeatSubState
     FunkinSound.playOnce(Paths.sound('confirmMenu'));
     dj.confirm();
 
+    grpCapsules.members[curSelected].songText.flickerText();
+
+    // FlxTween.color(bgDad, 0.33, 0xFFFFFFFF, 0xFF555555, {ease: FlxEase.quadOut});
+    FlxTween.color(pinkBack, 0.33, 0xFFFFD0D5, 0xFF171831, {ease: FlxEase.quadOut});
+    orangeBackShit.visible = false;
+    alsoOrangeLOL.visible = false;
+
+    confirmGlow.visible = true;
+    confirmGlow2.visible = true;
+
+    backingTextYeah.anim.play("BF back card confirm raw", false, false, 0);
+    confirmGlow2.alpha = 0;
+    confirmGlow.alpha = 0;
+
+    FlxTween.tween(confirmGlow2, {alpha: 0.5}, 0.33,
+      {
+        ease: FlxEase.quadOut,
+        onComplete: function(_) {
+          confirmGlow2.alpha = 0.6;
+          confirmGlow.alpha = 1;
+          confirmTextGlow.visible = true;
+          confirmTextGlow.alpha = 1;
+          FlxTween.tween(confirmTextGlow, {alpha: 0.4}, 0.5);
+          FlxTween.tween(confirmGlow, {alpha: 0}, 0.5);
+        }
+      });
+
+    // confirmGlow
+
+    moreWays.visible = false;
+    funnyScroll.visible = false;
+    txtNuts.visible = false;
+    funnyScroll2.visible = false;
+    moreWays2.visible = false;
+    funnyScroll3.visible = false;
+
     new FlxTimer().start(1, function(tmr:FlxTimer) {
       Paths.setCurrentLevel(cap.songData.levelId);
       LoadingState.loadPlayState(
@@ -1383,6 +1751,7 @@ class FreeplaySongData
 
   public var songName(default, null):String = '';
   public var songCharacter(default, null):String = '';
+  public var songStartingBpm(default, null):Float = 0;
   public var songRating(default, null):Int = 0;
   public var albumId(default, null):Null<String> = null;
 
@@ -1415,6 +1784,7 @@ class FreeplaySongData
 
     var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, variations);
     if (songDifficulty == null) return;
+    this.songStartingBpm = songDifficulty.getStartingBPM();
     this.songName = songDifficulty.songName;
     this.songCharacter = songDifficulty.characters.opponent;
     this.songRating = songDifficulty.difficultyRating;
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index f6d85e56e..0f72199ba 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -14,6 +14,13 @@ import flixel.text.FlxText;
 import flixel.util.FlxTimer;
 import funkin.util.MathUtil;
 import funkin.graphics.shaders.Grayscale;
+import funkin.graphics.shaders.GaussianBlurShader;
+import openfl.display.BlendMode;
+import funkin.graphics.FunkinSprite;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import flixel.addons.effects.FlxTrail;
+import flixel.util.FlxColor;
 
 class SongMenuItem extends FlxSpriteGroup
 {
@@ -31,9 +38,10 @@ class SongMenuItem extends FlxSpriteGroup
 
   public var songText:CapsuleText;
   public var favIcon:FlxSprite;
-  public var ranking:FlxSprite;
+  public var ranking:FreeplayRank;
+  public var blurredRanking:FreeplayRank;
 
-  var ranks:Array<String> = ["fail", "average", "great", "excellent", "perfect"];
+  var ranks:Array<String> = ["fail", "average", "great", "excellent", "perfect", "perfectsick"];
 
   public var targetPos:FlxPoint = new FlxPoint();
   public var doLerp:Bool = false;
@@ -47,6 +55,22 @@ class SongMenuItem extends FlxSpriteGroup
   public var hsvShader(default, set):HSVShader;
 
   // var diffRatingSprite:FlxSprite;
+  public var bpmText:FlxSprite;
+  public var difficultyText:FlxSprite;
+  public var weekType:FlxSprite;
+
+  public var newText:FlxSprite;
+
+  // public var weekType:FlxSprite;
+  public var bigNumbers:Array<CapsuleNumber> = [];
+
+  public var smallNumbers:Array<CapsuleNumber> = [];
+
+  public var weekNumbers:Array<CapsuleNumber> = [];
+
+  var impactThing:FunkinSprite;
+
+  public var tempr:Int;
 
   public function new(x:Float, y:Float)
   {
@@ -59,12 +83,64 @@ class SongMenuItem extends FlxSpriteGroup
     // capsule.animation
     add(capsule);
 
+    bpmText = new FlxSprite(144, 87).loadGraphic(Paths.image('freeplay/freeplayCapsule/bpmtext'));
+    bpmText.setGraphicSize(Std.int(bpmText.width * 0.9));
+    add(bpmText);
+
+    difficultyText = new FlxSprite(414, 87).loadGraphic(Paths.image('freeplay/freeplayCapsule/difficultytext'));
+    difficultyText.setGraphicSize(Std.int(difficultyText.width * 0.9));
+    add(difficultyText);
+
+    weekType = new FlxSprite(291, 87);
+    weekType.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/weektypes');
+
+    weekType.animation.addByPrefix('WEEK', 'WEEK text instance 1', 24, false);
+    weekType.animation.addByPrefix('WEEKEND', 'WEEKEND text instance 1', 24, false);
+
+    weekType.setGraphicSize(Std.int(weekType.width * 0.9));
+    add(weekType);
+
+    newText = new FlxSprite(454, 9);
+    newText.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/new');
+    newText.animation.addByPrefix('newAnim', 'NEW notif', 24, true);
+    newText.animation.play('newAnim', true);
+    newText.setGraphicSize(Std.int(newText.width * 0.9));
+
+    newText.visible = false;
+
+    add(newText);
+
+    // var debugNumber2:CapsuleNumber = new CapsuleNumber(0, 0, true, 2);
+    // add(debugNumber2);
+
+    for (i in 0...2)
+    {
+      var bigNumber:CapsuleNumber = new CapsuleNumber(466 + (i * 30), 32, true, 0);
+      add(bigNumber);
+
+      bigNumbers.push(bigNumber);
+    }
+
+    for (i in 0...3)
+    {
+      var smallNumber:CapsuleNumber = new CapsuleNumber(185 + (i * 11), 88.5, false, 0);
+      add(smallNumber);
+
+      smallNumbers.push(smallNumber);
+    }
+
     // doesn't get added, simply is here to help with visibility of things for the pop in!
     grpHide = new FlxGroup();
 
     var rank:String = FlxG.random.getObject(ranks);
 
-    ranking = new FlxSprite(capsule.width * 0.84, 30);
+    tempr = FlxG.random.int(0, 5);
+    ranking = new FreeplayRank(420, 41, tempr);
+    add(ranking);
+
+    blurredRanking = new FreeplayRank(ranking.x, ranking.y, tempr);
+    blurredRanking.shader = new GaussianBlurShader(1);
+    add(blurredRanking);
     // ranking.loadGraphic(Paths.image('freeplay/ranks/' + rank));
     // ranking.scale.x = ranking.scale.y = realScaled;
     // ranking.alpha = 0.75;
@@ -73,11 +149,11 @@ class SongMenuItem extends FlxSpriteGroup
     // add(ranking);
     // grpHide.add(ranking);
 
-    switch (rank)
-    {
-      case 'perfect':
-        ranking.x -= 10;
-    }
+    // switch (rank)
+    // {
+    //   case 'perfect':
+    //     ranking.x -= 10;
+    // }
 
     grayscaleShader = new Grayscale(1);
 
@@ -93,7 +169,7 @@ class SongMenuItem extends FlxSpriteGroup
     grpHide.add(songText);
 
     // TODO: Use value from metadata instead of random.
-    updateDifficultyRating(FlxG.random.int(0, 15));
+    updateDifficultyRating(FlxG.random.int(0, 20));
 
     pixelIcon = new FlxSprite(160, 35);
 
@@ -103,21 +179,216 @@ class SongMenuItem extends FlxSpriteGroup
     add(pixelIcon);
     grpHide.add(pixelIcon);
 
-    favIcon = new FlxSprite(400, 40);
+    favIcon = new FlxSprite(380, 40);
     favIcon.frames = Paths.getSparrowAtlas('freeplay/favHeart');
     favIcon.animation.addByPrefix('fav', 'favorite heart', 24, false);
     favIcon.animation.play('fav');
     favIcon.setGraphicSize(50, 50);
     favIcon.visible = false;
+    favIcon.blend = BlendMode.ADD;
     add(favIcon);
-    // grpHide.add(favIcon);
+
+    var weekNumber:CapsuleNumber = new CapsuleNumber(355, 88.5, false, 0);
+    add(weekNumber);
+
+    weekNumbers.push(weekNumber);
 
     setVisibleGrp(false);
   }
 
+  // no way to grab weeks rn, so this needs to be done :/
+  // negative values mean weekends
+  function checkWeek(name:String):Void
+  {
+    // trace(name);
+    var weekNum:Int = 0;
+    switch (name)
+    {
+      case 'bopeebo' | 'fresh' | 'dadbattle':
+        weekNum = 1;
+      case 'spookeez' | 'south' | 'monster':
+        weekNum = 2;
+      case 'pico' | 'philly-nice' | 'blammed':
+        weekNum = 3;
+      case "satin-panties" | 'high' | 'milf':
+        weekNum = 4;
+      case "cocoa" | 'eggnog' | 'winter-horrorland':
+        weekNum = 5;
+      case 'senpai' | 'roses' | 'thorns':
+        weekNum = 6;
+      case 'ugh' | 'guns' | 'stress':
+        weekNum = 7;
+      case 'darnell' | 'lit-up' | '2hot' | 'blazin':
+        weekNum = -1;
+      default:
+        weekNum = 0;
+    }
+
+    weekNumbers[0].digit = Std.int(Math.abs(weekNum));
+
+    if (weekNum == 0)
+    {
+      weekType.visible = false;
+      weekNumbers[0].visible = false;
+    }
+    else
+    {
+      weekType.visible = true;
+      weekNumbers[0].visible = true;
+    }
+    if (weekNum > 0)
+    {
+      weekType.animation.play('WEEK', true);
+    }
+    else
+    {
+      weekType.animation.play('WEEKEND', true);
+      weekNumbers[0].offset.x -= 35;
+    }
+  }
+
+  // 255, 27 normal
+  // 220, 27 favourited
+  public function checkClip():Void
+  {
+    var clipSize:Int = 290;
+    var clipType:Int = 0;
+
+    if (ranking.visible == true) clipType += 1;
+    if (favIcon.visible == true) clipType += 1;
+    switch (clipType)
+    {
+      case 2:
+        clipSize = 220;
+      case 1:
+        clipSize = 255;
+    }
+    songText.clipWidth = clipSize;
+  }
+
+  function updateBPM(newBPM:Int):Void
+  {
+    trace(newBPM);
+
+    var shiftX:Float = 191;
+    var tempShift:Float = 0;
+
+    if (Math.floor(newBPM / 100) == 1)
+    {
+      shiftX = 186;
+    }
+
+    for (i in 0...smallNumbers.length)
+    {
+      smallNumbers[i].x = this.x + (shiftX + (i * 11));
+      switch (i)
+      {
+        case 0:
+          if (newBPM < 100)
+          {
+            smallNumbers[i].digit = 0;
+          }
+          else
+          {
+            smallNumbers[i].digit = Math.floor(newBPM / 100) % 10;
+          }
+
+        case 1:
+          if (newBPM < 10)
+          {
+            smallNumbers[i].digit = 0;
+          }
+          else
+          {
+            smallNumbers[i].digit = Math.floor(newBPM / 10) % 10;
+
+            if (Math.floor(newBPM / 10) % 10 == 1) tempShift = -4;
+          }
+        case 2:
+          smallNumbers[i].digit = newBPM % 10;
+        default:
+          trace('why the fuck is this being called');
+      }
+      smallNumbers[i].x += tempShift;
+    }
+    // diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}'));
+    // diffRatingSprite.visible = false;
+  }
+
+  var evilTrail:FlxTrail;
+
+  public function fadeAnim()
+  {
+    impactThing = new FunkinSprite(0, 0);
+    impactThing.frames = capsule.frames;
+    impactThing.frame = capsule.frame;
+    impactThing.updateHitbox();
+    // impactThing.x = capsule.x;
+    // impactThing.y = capsule.y;
+    // picoFade.stamp(this, 0, 0);
+    impactThing.alpha = 0;
+    impactThing.zIndex = capsule.zIndex - 3;
+    add(impactThing);
+    FlxTween.tween(impactThing.scale, {x: 2.5, y: 2.5}, 0.5);
+    // FlxTween.tween(impactThing, {alpha: 0}, 0.5);
+
+    evilTrail = new FlxTrail(impactThing, null, 15, 2, 0.01, 0.069);
+    evilTrail.blend = BlendMode.ADD;
+    evilTrail.zIndex = capsule.zIndex - 5;
+    FlxTween.tween(evilTrail, {alpha: 0}, 0.6,
+      {
+        ease: FlxEase.quadOut,
+        onComplete: function(_) {
+          remove(evilTrail);
+        }
+      });
+    add(evilTrail);
+
+    switch (tempr)
+    {
+      case 0:
+        evilTrail.color = 0xFF6044FF;
+      case 1:
+        evilTrail.color = 0xFFEF8764;
+      case 2:
+        evilTrail.color = 0xFFEAF6FF;
+      case 3:
+        evilTrail.color = 0xFFFDCB42;
+      case 4:
+        evilTrail.color = 0xFFFF58B4;
+      case 5:
+        evilTrail.color = 0xFFFFB619;
+    }
+  }
+
+  public function getTrailColor():FlxColor
+  {
+    return evilTrail.color;
+  }
+
   function updateDifficultyRating(newRating:Int):Void
   {
     var ratingPadded:String = newRating < 10 ? '0$newRating' : '$newRating';
+
+    for (i in 0...bigNumbers.length)
+    {
+      switch (i)
+      {
+        case 0:
+          if (newRating > 10)
+          {
+            bigNumbers[i].digit = 0;
+          }
+          else
+          {
+            bigNumbers[i].digit = Math.floor(newRating / 10);
+          }
+        case 1:
+          bigNumbers[i].digit = newRating % 10;
+        default:
+          trace('why the fuck is this being called');
+      }
+    }
     // diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}'));
     // diffRatingSprite.visible = false;
   }
@@ -169,8 +440,11 @@ class SongMenuItem extends FlxSpriteGroup
     // Update capsule character.
     if (songData?.songCharacter != null) setCharacter(songData.songCharacter);
     updateDifficultyRating(songData?.songRating ?? 0);
+    updateBPM(Std.int(songData?.songStartingBpm) ?? 0);
     // Update opacity, offsets, etc.
     updateSelected();
+
+    checkWeek(songData?.songId);
   }
 
   /**
@@ -289,6 +563,28 @@ class SongMenuItem extends FlxSpriteGroup
 
   override function update(elapsed:Float):Void
   {
+    if (impactThing != null) impactThing.angle = capsule.angle;
+
+    // if (FlxG.keys.justPressed.I)
+    // {
+    //   newText.y -= 1;
+    //   trace(this.x - newText.x, this.y - newText.y);
+    // }
+    // if (FlxG.keys.justPressed.J)
+    // {
+    //   newText.x -= 1;
+    //   trace(this.x - newText.x, this.y - newText.y);
+    // }
+    // if (FlxG.keys.justPressed.L)
+    // {
+    //   newText.x += 1;
+    //   trace(this.x - newText.x, this.y - newText.y);
+    // }
+    // if (FlxG.keys.justPressed.K)
+    // {
+    //   newText.y += 1;
+    //   trace(this.x - newText.x, this.y - newText.y);
+    // }
     if (doJumpIn)
     {
       frameInTicker += elapsed;
@@ -358,5 +654,137 @@ class SongMenuItem extends FlxSpriteGroup
     capsule.animation.play(this.selected ? "selected" : "unselected");
     ranking.alpha = this.selected ? 1 : 0.7;
     ranking.color = this.selected ? 0xFFFFFFFF : 0xFFAAAAAA;
+
+    if (selected)
+    {
+      if (songText.tooLong == true) songText.initMove();
+    }
+    else
+    {
+      if (songText.tooLong == true) songText.resetText();
+    }
+  }
+}
+
+class FreeplayRank extends FlxSprite
+{
+  public var rank(default, set):Int = 0;
+
+  var numToRank:Array<String> = ["LOSS", "GOOD", "GREAT", "EXCELLENT", "PERFECT", "PERFECTSICK"];
+
+  function set_rank(val):Int
+  {
+    animation.play(numToRank[val], true, false);
+
+    centerOffsets(false);
+
+    switch (val)
+    {
+      case 0:
+      // offset.x -= 1;
+      case 1:
+        // offset.x -= 1;
+        offset.y -= 8;
+      case 2:
+        // offset.x -= 1;
+        offset.y -= 8;
+      case 3:
+      // offset.y += 5;
+      case 4:
+      // offset.y += 5;
+      default:
+        centerOffsets(false);
+    }
+    updateHitbox();
+    return val;
+  }
+
+  public var baseY:Float = 0;
+  public var baseX:Float = 0;
+
+  public function new(x:Float, y:Float, ?initRank:Int = 0)
+  {
+    super(x, y);
+
+    frames = Paths.getSparrowAtlas('freeplay/rankbadges');
+
+    animation.addByPrefix('PERFECT', 'PERFECT rank0', 24, false);
+    animation.addByPrefix('EXCELLENT', 'EXCELLENT rank0', 24, false);
+    animation.addByPrefix('GOOD', 'GOOD rank0', 24, false);
+    animation.addByPrefix('PERFECTSICK', 'PERFECT rank GOLD', 24, false);
+    animation.addByPrefix('GREAT', 'GREAT rank0', 24, false);
+    animation.addByPrefix('LOSS', 'LOSS rank0', 24, false);
+
+    blend = BlendMode.ADD;
+
+    this.rank = initRank;
+
+    animation.play(numToRank[initRank], true);
+
+    // setGraphicSize(Std.int(width * 0.9));
+    scale.set(0.9, 0.9);
+    updateHitbox();
+  }
+}
+
+class CapsuleNumber extends FlxSprite
+{
+  public var digit(default, set):Int = 0;
+
+  function set_digit(val):Int
+  {
+    animation.play(numToString[val], true, false, 0);
+
+    centerOffsets(false);
+
+    switch (val)
+    {
+      case 1:
+        offset.x -= 4;
+      case 3:
+        offset.x -= 1;
+
+      case 6:
+
+      case 4:
+      // offset.y += 5;
+      case 9:
+      // offset.y += 5;
+      default:
+        centerOffsets(false);
+    }
+    return val;
+  }
+
+  public var baseY:Float = 0;
+  public var baseX:Float = 0;
+
+  var numToString:Array<String> = ["ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE"];
+
+  public function new(x:Float, y:Float, big:Bool = false, ?initDigit:Int = 0)
+  {
+    super(x, y);
+
+    if (big)
+    {
+      frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/bignumbers');
+    }
+    else
+    {
+      frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/smallnumbers');
+    }
+
+    for (i in 0...10)
+    {
+      var stringNum:String = numToString[i];
+      animation.addByPrefix(stringNum, '$stringNum', 24, false);
+    }
+
+    this.digit = initDigit;
+
+    animation.play(numToString[initDigit], true);
+
+    setGraphicSize(Std.int(width * 0.9));
+    updateHitbox();
   }
 }

From 5f9019a2a3f9283fa96684b91ce17cd8e1ff5b23 Mon Sep 17 00:00:00 2001
From: FabsTheFabs <flamingkitty24@gmail.com>
Date: Thu, 30 May 2024 04:35:05 +0100
Subject: [PATCH 52/72] update assets submod

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

diff --git a/assets b/assets
index fe52d20de..dabdf9b1d 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe52d20de7025d90cadb429dbdedf6d986727088
+Subproject commit dabdf9b1d361afa0f65c87b9c0f12cf90b0eebdf

From b7a828e7d89756a20c7415876446ddda2c1cbd84 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 30 May 2024 05:25:51 -0400
Subject: [PATCH 53/72] Make Freeplay use correct ranks, play the slam
 animation after Results, new Results music

---
 assets                                     |   2 +-
 source/funkin/InitState.hx                 |   4 +-
 source/funkin/play/PlayState.hx            |   3 +-
 source/funkin/play/ResultState.hx          | 226 ++++++---------------
 source/funkin/play/scoring/Scoring.hx      | 176 ++++++++++++++++
 source/funkin/save/Save.hx                 |  13 +-
 source/funkin/ui/freeplay/FreeplayState.hx | 107 +++++++---
 source/funkin/ui/freeplay/SongMenuItem.hx  |  99 +++++----
 8 files changed, 389 insertions(+), 241 deletions(-)

diff --git a/assets b/assets
index 8fea0bf1f..2719d3fc1 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 8fea0bf1fe07b6dd0efb8ecf46dc8091b0177007
+Subproject commit 2719d3fc1d8f5d0cbafae8d27141d6c471148482
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index a945c10c5..d0009f95b 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -220,6 +220,8 @@ class InitState extends FlxState
       {
         storyMode: false,
         title: "CUM SONG",
+        songId: "cum",
+        difficultyId: "hard",
         isNewHighscore: true,
         scoreData:
           {
@@ -227,7 +229,7 @@ class InitState extends FlxState
             tallies:
               {
                 sick: 130,
-                good: 25,
+                good: 70,
                 bad: 69,
                 shit: 69,
                 missed: 69,
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a95166e21..e69a50b00 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -3123,9 +3123,10 @@ class PlayState extends MusicBeatSubState
     var res:ResultState = new ResultState(
       {
         storyMode: PlayStatePlaylist.isStoryMode,
+        songId: currentChart.song.id,
+        difficultyId: currentDifficulty,
         title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
         prevScoreData: prevScoreData,
-        difficultyId: currentDifficulty,
         scoreData:
           {
             score: PlayStatePlaylist.isStoryMode ? PlayStatePlaylist.campaignScore : songScore,
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index ee7c8eade..79880038d 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -21,6 +21,7 @@ import funkin.audio.FunkinSound;
 import flixel.util.FlxGradient;
 import flixel.util.FlxTimer;
 import funkin.save.Save;
+import funkin.play.scoring.Scoring;
 import funkin.save.Save.SaveScoreData;
 import funkin.graphics.shaders.LeftMaskShader;
 import funkin.play.components.TallyCounter;
@@ -34,7 +35,7 @@ class ResultState extends MusicBeatSubState
 {
   final params:ResultsStateParams;
 
-  final rank:ResultRank;
+  final rank:ScoringRank;
   final songName:FlxBitmapText;
   final difficulty:FlxSprite;
   final clearPercentSmall:ClearPercentCounter;
@@ -64,8 +65,7 @@ class ResultState extends MusicBeatSubState
 
     this.params = params;
 
-    rank = calculateRank(params);
-    // rank = SHIT;
+    rank = Scoring.calculateRank(params.scoreData) ?? SHIT;
 
     // We build a lot of this stuff in the constructor, then place it in create().
     // This prevents having to do `null` checks everywhere.
@@ -99,6 +99,8 @@ class ResultState extends MusicBeatSubState
 
   override function create():Void
   {
+    if (FlxG.sound.music != null) FlxG.sound.music.stop();
+
     // Reset the camera zoom on the results screen.
     FlxG.camera.zoom = 1.0;
 
@@ -327,6 +329,33 @@ class ResultState extends MusicBeatSubState
       }
     };
 
+    new FlxTimer().start(rank.getMusicDelay(), _ -> {
+      if (rank.hasMusicIntro())
+      {
+        // Play the intro music.
+        var introMusic:String = Paths.music(rank.getMusicPath() + '/' + rank.getMusicPath() + '-intro');
+        FunkinSound.load(introMusic, 1.0, false, true, true, () -> {
+          FunkinSound.playMusic(rank.getMusicPath(),
+            {
+              startingVolume: 1.0,
+              overrideExisting: true,
+              restartTrack: true,
+              loop: rank.shouldMusicLoop()
+            });
+        });
+      }
+      else
+      {
+        FunkinSound.playMusic(rank.getMusicPath(),
+          {
+            startingVolume: 1.0,
+            overrideExisting: true,
+            restartTrack: true,
+            loop: rank.shouldMusicLoop()
+          });
+      }
+    });
+
     refresh();
 
     super.create();
@@ -376,7 +405,8 @@ class ResultState extends MusicBeatSubState
 
           displayRankText();
 
-          new FlxTimer().start(2.0, _ -> {
+          // previously 2.0 seconds
+          new FlxTimer().start(0.25, _ -> {
             FlxTween.tween(clearPercentCounter, {alpha: 0}, 0.5,
               {
                 startDelay: 0.5,
@@ -444,28 +474,6 @@ class ResultState extends MusicBeatSubState
   {
     showSmallClearPercent();
 
-    FunkinSound.playMusic(rank.getMusicPath(),
-      {
-        startingVolume: 1.0,
-        overrideExisting: true,
-        restartTrack: true,
-        loop: rank.shouldMusicLoop()
-      });
-
-    FlxG.sound.music.onComplete = () -> {
-      if (rank == SHIT)
-      {
-        FunkinSound.playMusic('bluu',
-          {
-            startingVolume: 0.0,
-            overrideExisting: true,
-            restartTrack: true,
-            loop: true
-          });
-        FlxG.sound.music.fadeIn(10.0, 0.0, 1.0);
-      }
-    }
-
     switch (rank)
     {
       case PERFECT | PERFECT_GOLD:
@@ -478,7 +486,6 @@ class ResultState extends MusicBeatSubState
           bfPerfect.visible = true;
           bfPerfect.playAnimation('');
         }
-
       case EXCELLENT:
         if (bfExcellent == null)
         {
@@ -489,7 +496,6 @@ class ResultState extends MusicBeatSubState
           bfExcellent.visible = true;
           bfExcellent.playAnimation('Intro');
         }
-
       case GREAT:
         if (bfGreat == null)
         {
@@ -500,7 +506,6 @@ class ResultState extends MusicBeatSubState
           bfGreat.visible = true;
           bfGreat.playAnimation('Intro');
         }
-
       case SHIT:
         if (bfShit == null)
         {
@@ -511,7 +516,6 @@ class ResultState extends MusicBeatSubState
           bfShit.visible = true;
           bfShit.playAnimation('Intro');
         }
-
       case GOOD:
         if (bfGood == null)
         {
@@ -521,7 +525,6 @@ class ResultState extends MusicBeatSubState
         {
           bfGood.animation.play('fall');
           bfGood.visible = true;
-
           new FlxTimer().start((1 / 24) * 22, _ -> {
             // plays about 22 frames (at 24fps timing) after bf spawns in
             if (gfGood != null)
@@ -635,154 +638,39 @@ class ResultState extends MusicBeatSubState
 
     if (controls.PAUSE)
     {
-      FlxTween.tween(FlxG.sound.music, {volume: 0}, 0.8);
-      FlxTween.tween(FlxG.sound.music, {pitch: 3}, 0.1,
-        {
-          onComplete: _ -> {
-            FlxTween.tween(FlxG.sound.music, {pitch: 0.5}, 0.4);
-          }
-        });
+      if (FlxG.sound.music != null)
+      {
+        FlxTween.tween(FlxG.sound.music, {volume: 0}, 0.8);
+        FlxTween.tween(FlxG.sound.music, {pitch: 3}, 0.1,
+          {
+            onComplete: _ -> {
+              FlxTween.tween(FlxG.sound.music, {pitch: 0.5}, 0.4);
+            }
+          });
+      }
       if (params.storyMode)
       {
         openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
       }
       else
       {
-        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(
+          {
+            {
+              fromResults:
+                {
+                  oldRank: Scoring.calculateRank(params?.prevScoreData),
+                  newRank: rank,
+                  songId: params.songId,
+                  difficultyId: params.difficultyId
+                }
+            }
+          }, sticker)));
       }
     }
 
     super.update(elapsed);
   }
-
-  public static function calculateRank(params:ResultsStateParams):ResultRank
-  {
-    // Perfect (Platinum) is a Sick Full Clear
-    var isPerfectGold = params.scoreData.tallies.sick == params.scoreData.tallies.totalNotes;
-    if (isPerfectGold) return ResultRank.PERFECT_GOLD;
-
-    // Else, use the standard grades
-
-    // Grade % (only good and sick), 1.00 is a full combo
-    var grade = (params.scoreData.tallies.sick + params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes;
-    // Clear % (including bad and shit). 1.00 is a full clear but not a full combo
-    var clear = (params.scoreData.tallies.totalNotesHit) / params.scoreData.tallies.totalNotes;
-
-    if (grade == Constants.RANK_PERFECT_THRESHOLD)
-    {
-      return ResultRank.PERFECT;
-    }
-    else if (grade >= Constants.RANK_EXCELLENT_THRESHOLD)
-    {
-      return ResultRank.EXCELLENT;
-    }
-    else if (grade >= Constants.RANK_GREAT_THRESHOLD)
-    {
-      return ResultRank.GREAT;
-    }
-    else if (grade >= Constants.RANK_GOOD_THRESHOLD)
-    {
-      return ResultRank.GOOD;
-    }
-    else
-    {
-      return ResultRank.SHIT;
-    }
-  }
-}
-
-enum abstract ResultRank(String)
-{
-  var PERFECT_GOLD;
-  var PERFECT;
-  var EXCELLENT;
-  var GREAT;
-  var GOOD;
-  var SHIT;
-
-  public function getMusicPath():String
-  {
-    switch (abstract)
-    {
-      case PERFECT_GOLD:
-        return 'resultsPERFECT';
-      case PERFECT:
-        return 'resultsPERFECT';
-      case EXCELLENT:
-        return 'resultsNORMAL';
-      case GREAT:
-        return 'resultsNORMAL';
-      case GOOD:
-        return 'resultsNORMAL';
-      case SHIT:
-        return 'resultsSHIT';
-      default:
-        return 'resultsNORMAL';
-    }
-  }
-
-  public function shouldMusicLoop():Bool
-  {
-    switch (abstract)
-    {
-      case PERFECT_GOLD:
-        return true;
-      case PERFECT:
-        return true;
-      case EXCELLENT:
-        return true;
-      case GREAT:
-        return true;
-      case GOOD:
-        return true;
-      case SHIT:
-        return false;
-      default:
-        return false;
-    }
-  }
-
-  public function getHorTextAsset()
-  {
-    switch (abstract)
-    {
-      case PERFECT_GOLD:
-        return 'resultScreen/rankText/rankScrollPERFECT';
-      case PERFECT:
-        return 'resultScreen/rankText/rankScrollPERFECT';
-      case EXCELLENT:
-        return 'resultScreen/rankText/rankScrollEXCELLENT';
-      case GREAT:
-        return 'resultScreen/rankText/rankScrollGREAT';
-      case GOOD:
-        return 'resultScreen/rankText/rankScrollGOOD';
-      case SHIT:
-        return 'resultScreen/rankText/rankScrollLOSS';
-      default:
-        return 'resultScreen/rankText/rankScrollGOOD';
-    }
-  }
-
-  public function getVerTextAsset()
-  {
-    switch (abstract)
-    {
-      case PERFECT_GOLD:
-        return 'resultScreen/rankText/rankTextPERFECT';
-      case PERFECT:
-        return 'resultScreen/rankText/rankTextPERFECT';
-      case EXCELLENT:
-        return 'resultScreen/rankText/rankTextEXCELLENT';
-      case GREAT:
-        return 'resultScreen/rankText/rankTextGREAT';
-      case GOOD:
-        return 'resultScreen/rankText/rankTextGOOD';
-      case SHIT:
-        return 'resultScreen/rankText/rankTextLOSS';
-      default:
-        return 'resultScreen/rankText/rankTextGOOD';
-    }
-  }
 }
 
 typedef ResultsStateParams =
@@ -797,6 +685,8 @@ typedef ResultsStateParams =
    */
   var title:String;
 
+  var songId:String;
+
   /**
    * Whether the displayed score is a new highscore
    */
diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx
index 744091b44..6155ec879 100644
--- a/source/funkin/play/scoring/Scoring.hx
+++ b/source/funkin/play/scoring/Scoring.hx
@@ -1,5 +1,7 @@
 package funkin.play.scoring;
 
+import funkin.save.Save.SaveScoreData;
+
 /**
  * Which system to use when scoring and judging notes.
  */
@@ -344,4 +346,178 @@ class Scoring
       return 'miss';
     }
   }
+
+  public static function calculateRank(scoreData:Null<SaveScoreData>):Null<ScoringRank>
+  {
+    if (scoreData == null) return null;
+
+    // Perfect (Platinum) is a Sick Full Clear
+    var isPerfectGold = scoreData.tallies.sick == scoreData.tallies.totalNotes;
+    if (isPerfectGold) return ScoringRank.PERFECT_GOLD;
+
+    // Else, use the standard grades
+
+    // Grade % (only good and sick), 1.00 is a full combo
+    var grade = (scoreData.tallies.sick + scoreData.tallies.good) / scoreData.tallies.totalNotes;
+    // Clear % (including bad and shit). 1.00 is a full clear but not a full combo
+    var clear = (scoreData.tallies.totalNotesHit) / scoreData.tallies.totalNotes;
+
+    if (grade == Constants.RANK_PERFECT_THRESHOLD)
+    {
+      return ScoringRank.PERFECT;
+    }
+    else if (grade >= Constants.RANK_EXCELLENT_THRESHOLD)
+    {
+      return ScoringRank.EXCELLENT;
+    }
+    else if (grade >= Constants.RANK_GREAT_THRESHOLD)
+    {
+      return ScoringRank.GREAT;
+    }
+    else if (grade >= Constants.RANK_GOOD_THRESHOLD)
+    {
+      return ScoringRank.GOOD;
+    }
+    else
+    {
+      return ScoringRank.SHIT;
+    }
+  }
+}
+
+enum abstract ScoringRank(String)
+{
+  var PERFECT_GOLD;
+  var PERFECT;
+  var EXCELLENT;
+  var GREAT;
+  var GOOD;
+  var SHIT;
+
+  /**
+   * Delay in seconds
+   */
+  public function getMusicDelay():Float
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD | PERFECT:
+        // return 2.5;
+        return 5.0;
+      case EXCELLENT:
+        return 1.75;
+      default:
+        return 3.5;
+    }
+  }
+
+  public function getMusicPath():String
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD:
+        return 'resultsPERFECT';
+      case PERFECT:
+        return 'resultsPERFECT';
+      case EXCELLENT:
+        return 'resultsEXCELLENT';
+      case GREAT:
+        return 'resultsNORMAL';
+      case GOOD:
+        return 'resultsNORMAL';
+      case SHIT:
+        return 'resultsSHIT';
+      default:
+        return 'resultsNORMAL';
+    }
+  }
+
+  public function hasMusicIntro():Bool
+  {
+    switch (abstract)
+    {
+      case EXCELLENT:
+        return true;
+      case SHIT:
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  public function getFreeplayRankIconAsset():Null<String>
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD:
+        return 'PERFECTSICK';
+      case PERFECT:
+        return 'PERFECT';
+      case EXCELLENT:
+        return 'EXCELLENT';
+      case GREAT:
+        return 'GREAT';
+      case GOOD:
+        return 'GOOD';
+      case SHIT:
+        return 'LOSS';
+      default:
+        return null;
+    }
+  }
+
+  public function shouldMusicLoop():Bool
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD | PERFECT | EXCELLENT | GREAT | GOOD:
+        return true;
+      case SHIT:
+        return false;
+      default:
+        return false;
+    }
+  }
+
+  public function getHorTextAsset()
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD:
+        return 'resultScreen/rankText/rankScrollPERFECT';
+      case PERFECT:
+        return 'resultScreen/rankText/rankScrollPERFECT';
+      case EXCELLENT:
+        return 'resultScreen/rankText/rankScrollEXCELLENT';
+      case GREAT:
+        return 'resultScreen/rankText/rankScrollGREAT';
+      case GOOD:
+        return 'resultScreen/rankText/rankScrollGOOD';
+      case SHIT:
+        return 'resultScreen/rankText/rankScrollLOSS';
+      default:
+        return 'resultScreen/rankText/rankScrollGOOD';
+    }
+  }
+
+  public function getVerTextAsset()
+  {
+    switch (abstract)
+    {
+      case PERFECT_GOLD:
+        return 'resultScreen/rankText/rankTextPERFECT';
+      case PERFECT:
+        return 'resultScreen/rankText/rankTextPERFECT';
+      case EXCELLENT:
+        return 'resultScreen/rankText/rankTextEXCELLENT';
+      case GREAT:
+        return 'resultScreen/rankText/rankTextGREAT';
+      case GOOD:
+        return 'resultScreen/rankText/rankTextGOOD';
+      case SHIT:
+        return 'resultScreen/rankText/rankTextLOSS';
+      default:
+        return 'resultScreen/rankText/rankTextGOOD';
+    }
+  }
 }
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 934d6a4aa..7f25a8e01 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -1,15 +1,17 @@
 package funkin.save;
 
 import flixel.util.FlxSave;
-import funkin.save.migrator.SaveDataMigrator;
-import thx.semver.Version;
 import funkin.input.Controls.Device;
+import funkin.play.scoring.Scoring;
+import funkin.play.scoring.Scoring.ScoringRank;
 import funkin.save.migrator.RawSaveData_v1_0_0;
 import funkin.save.migrator.SaveDataMigrator;
+import funkin.save.migrator.SaveDataMigrator;
 import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle;
 import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
-import thx.semver.Version;
 import funkin.util.SerializerUtil;
+import thx.semver.Version;
+import thx.semver.Version;
 
 @:nullSafety
 class Save
@@ -492,6 +494,11 @@ class Save
     return song.get(difficultyId);
   }
 
+  public function getSongRank(songId:String, difficultyId:String = 'normal'):Null<ScoringRank>
+  {
+    return Scoring.calculateRank(getSongScore(songId, difficultyId));
+  }
+
   /**
    * Apply the score the user achieved for a given song on a given difficulty.
    */
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 530f28c33..904a2ca4a 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -34,6 +34,8 @@ import funkin.play.song.Song;
 import funkin.save.Save;
 import funkin.save.Save.SaveScoreData;
 import funkin.ui.AtlasText;
+import funkin.play.scoring.Scoring;
+import funkin.play.scoring.Scoring.ScoringRank;
 import funkin.ui.mainmenu.MainMenuState;
 import funkin.ui.MusicBeatSubState;
 import funkin.ui.transition.LoadingState;
@@ -49,6 +51,34 @@ import funkin.effects.IntervalShake;
 typedef FreeplayStateParams =
 {
   ?character:String,
+
+  ?fromResults:FromResultsParams,
+};
+
+/**
+ * A set of parameters for transitioning to the FreeplayState from the ResultsState.
+ */
+typedef FromResultsParams =
+{
+  /**
+   * The previous rank the song hand, if any. Null if it had no score before.
+   */
+  var ?oldRank:ScoringRank;
+
+  /**
+   * The new rank the song has.
+   */
+  var newRank:ScoringRank;
+
+  /**
+   * The song ID to play the animation on.
+   */
+  var songId:String;
+
+  /**
+   * The difficulty ID to play the animation on.
+   */
+  var difficultyId:String;
 };
 
 /**
@@ -160,10 +190,14 @@ class FreeplayState extends MusicBeatSubState
   var bgDad:FlxSprite;
   var cardGlow:FlxSprite;
 
+  var fromResultsParams:Null<FromResultsParams> = null;
+
   public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
   {
     currentCharacter = params?.character ?? Constants.DEFAULT_CHARACTER;
 
+    fromResultsParams = params?.fromResults;
+
     if (stickers != null)
     {
       stickerSubState = stickers;
@@ -587,6 +621,11 @@ class FreeplayState extends MusicBeatSubState
 
       cardGlow.visible = true;
       FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut});
+
+      if (fromResultsParams != null)
+      {
+        rankAnimStart(fromResultsParams);
+      }
     });
 
     generateSongList(null, false);
@@ -657,6 +696,12 @@ class FreeplayState extends MusicBeatSubState
     // If curSelected is 0, the result will be null and fall back to the rememberedSongId.
     rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId ?? rememberedSongId;
 
+    if (fromResultsParams != null)
+    {
+      rememberedSongId = fromResultsParams.songId;
+      rememberedDifficulty = fromResultsParams.difficultyId;
+    }
+
     for (cap in grpCapsules.members)
     {
       cap.songText.resetText();
@@ -763,8 +808,10 @@ class FreeplayState extends MusicBeatSubState
     return songsToFilter;
   }
 
-  function rankAnimStart()
+  function rankAnimStart(fromResults:Null<FromResultsParams>):Void
   {
+    busy = true;
+
     dj.fistPump();
     // rankCamera.fade(FlxColor.BLACK, 0.5, true);
     rankCamera.fade(0xFF000000, 0.5, true, null, true);
@@ -789,21 +836,21 @@ class FreeplayState extends MusicBeatSubState
 
     new FlxTimer().start(0.5, _ -> {
       grpCapsules.members[curSelected].doLerp = false;
-      rankDisplayNew();
+      rankDisplayNew(fromResults);
     });
   }
 
-  function rankDisplayNew()
+  function rankDisplayNew(fromResults:Null<FromResultsParams>):Void
   {
     grpCapsules.members[curSelected].ranking.alpha = 1;
     grpCapsules.members[curSelected].blurredRanking.alpha = 1;
 
     grpCapsules.members[curSelected].ranking.scale.set(20, 20);
     grpCapsules.members[curSelected].blurredRanking.scale.set(20, 20);
-    // var tempr:Int = FlxG.random.int(0, 4);
 
-    // grpCapsules.members[curSelected].ranking.rank = tempr;
-    grpCapsules.members[curSelected].ranking.animation.play(grpCapsules.members[curSelected].ranking.animation.curAnim.name, true);
+    grpCapsules.members[curSelected].ranking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true);
+    // grpCapsules.members[curSelected].ranking.animation.curAnim.name, true);
+
     FlxTween.tween(grpCapsules.members[curSelected].ranking, {"scale.x": 1, "scale.y": 1}, 0.1);
 
     grpCapsules.members[curSelected].blurredRanking.animation.play(grpCapsules.members[curSelected].blurredRanking.animation.curAnim.name, true);
@@ -811,13 +858,13 @@ class FreeplayState extends MusicBeatSubState
 
     new FlxTimer().start(0.1, _ -> {
       trace(grpCapsules.members[curSelected].ranking.rank);
-      switch (grpCapsules.members[curSelected].tempr)
+      switch (fromResultsParams?.newRank)
       {
-        case 0:
+        case SHIT:
           FunkinSound.playOnce(Paths.sound('ranks/rankinbad'));
-        case 4:
+        case PERFECT:
           FunkinSound.playOnce(Paths.sound('ranks/rankinperfect'));
-        case 5:
+        case PERFECT_GOLD:
           FunkinSound.playOnce(Paths.sound('ranks/rankinperfect'));
         default:
           FunkinSound.playOnce(Paths.sound('ranks/rankinnormal'));
@@ -846,31 +893,31 @@ class FreeplayState extends MusicBeatSubState
     });
 
     new FlxTimer().start(0.6, _ -> {
-      rankAnimSlam();
+      rankAnimSlam(fromResults);
       // IntervalShake.shake(grpCapsules.members[curSelected].capsule, 0.3, 1 / 30, 0, 0.3, FlxEase.quartIn);
     });
   }
 
-  function rankAnimSlam()
+  function rankAnimSlam(fromResultsParams:Null<FromResultsParams>)
   {
     // FlxTween.tween(rankCamera, {"zoom": 1.9}, 0.5, {ease: FlxEase.backOut});
     FlxTween.tween(rankBg, {alpha: 0}, 0.5, {ease: FlxEase.expoIn});
 
     // FlxTween.tween(grpCapsules.members[curSelected], {angle: 5}, 0.5, {ease: FlxEase.backIn});
 
-    switch (grpCapsules.members[curSelected].tempr)
+    switch (fromResultsParams?.newRank)
     {
-      case 0:
+      case SHIT:
         FunkinSound.playOnce(Paths.sound('ranks/loss'));
-      case 1:
+      case GOOD:
         FunkinSound.playOnce(Paths.sound('ranks/good'));
-      case 2:
+      case GREAT:
         FunkinSound.playOnce(Paths.sound('ranks/great'));
-      case 3:
+      case EXCELLENT:
         FunkinSound.playOnce(Paths.sound('ranks/excellent'));
-      case 4:
+      case PERFECT:
         FunkinSound.playOnce(Paths.sound('ranks/perfect'));
-      case 5:
+      case PERFECT_GOLD:
         FunkinSound.playOnce(Paths.sound('ranks/perfect'));
       default:
         FunkinSound.playOnce(Paths.sound('ranks/loss'));
@@ -880,7 +927,7 @@ class FreeplayState extends MusicBeatSubState
     new FlxTimer().start(0.5, _ -> {
       funnyCam.shake(0.0045, 0.35);
 
-      if (grpCapsules.members[curSelected].tempr == 0)
+      if (fromResultsParams?.newRank == SHIT)
       {
         dj.pumpFistBad();
       }
@@ -915,6 +962,9 @@ class FreeplayState extends MusicBeatSubState
             IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12, 0, FlxEase.quadOut, function(_) {
               capsule.doLerp = true;
               capsule.cameras = [funnyCam];
+
+              // NOW we can interact with the menu
+              busy = false;
             }, null);
 
             // FlxTween.tween(capsule, {"targetPos.x": capsule.targetPos.x - 50}, 0.6,
@@ -981,7 +1031,10 @@ class FreeplayState extends MusicBeatSubState
   var spamTimer:Float = 0;
   var spamming:Bool = false;
 
-  var busy:Bool = false; // Set to true once the user has pressed enter to select a song.
+  /**
+   * If true, disable interaction with the interface.
+   */
+  var busy:Bool = false;
 
   var originalPos:FlxPoint = new FlxPoint();
 
@@ -989,19 +1042,20 @@ class FreeplayState extends MusicBeatSubState
   {
     super.update(elapsed);
 
+    #if debug
     if (FlxG.keys.justPressed.T)
     {
-      rankAnimStart();
+      rankAnimStart(fromResultsParams);
     }
 
     if (FlxG.keys.justPressed.H)
     {
-      rankDisplayNew();
+      rankDisplayNew(fromResultsParams);
     }
 
     if (FlxG.keys.justPressed.G)
     {
-      rankAnimSlam();
+      rankAnimSlam(fromResultsParams);
     }
 
     if (FlxG.keys.justPressed.I)
@@ -1024,6 +1078,7 @@ class FreeplayState extends MusicBeatSubState
       confirmTextGlow.y += 1;
       trace(confirmTextGlow.x, confirmTextGlow.y);
     }
+    #end
 
     if (FlxG.keys.justPressed.F)
     {
@@ -1765,6 +1820,8 @@ class FreeplaySongData
 
   public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
 
+  public var scoringRank:Null<ScoringRank> = null;
+
   var displayedVariations:Array<String> = [Constants.DEFAULT_VARIATION];
 
   function set_currentDifficulty(value:String):String
@@ -1827,6 +1884,8 @@ class FreeplaySongData
     {
       this.albumId = songDifficulty.album;
     }
+
+    this.scoringRank = Save.instance.getSongRank(songId, currentDifficulty);
   }
 }
 
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index 536a9cfe6..ad6ea386e 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -20,6 +20,7 @@ import funkin.graphics.FunkinSprite;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.addons.effects.FlxTrail;
+import funkin.play.scoring.Scoring.ScoringRank;
 import flixel.util.FlxColor;
 
 class SongMenuItem extends FlxSpriteGroup
@@ -70,8 +71,6 @@ class SongMenuItem extends FlxSpriteGroup
 
   var impactThing:FunkinSprite;
 
-  public var tempr:Int;
-
   public function new(x:Float, y:Float)
   {
     super(x, y);
@@ -132,13 +131,10 @@ class SongMenuItem extends FlxSpriteGroup
     // doesn't get added, simply is here to help with visibility of things for the pop in!
     grpHide = new FlxGroup();
 
-    var rank:String = FlxG.random.getObject(ranks);
-
-    tempr = FlxG.random.int(0, 5);
-    ranking = new FreeplayRank(420, 41, tempr);
+    ranking = new FreeplayRank(420, 41);
     add(ranking);
 
-    blurredRanking = new FreeplayRank(ranking.x, ranking.y, tempr);
+    blurredRanking = new FreeplayRank(ranking.x, ranking.y);
     blurredRanking.shader = new GaussianBlurShader(1);
     add(blurredRanking);
     // ranking.loadGraphic(Paths.image('freeplay/ranks/' + rank));
@@ -344,19 +340,19 @@ class SongMenuItem extends FlxSpriteGroup
       });
     add(evilTrail);
 
-    switch (tempr)
+    switch (ranking.rank)
     {
-      case 0:
+      case SHIT:
         evilTrail.color = 0xFF6044FF;
-      case 1:
+      case GOOD:
         evilTrail.color = 0xFFEF8764;
-      case 2:
+      case GREAT:
         evilTrail.color = 0xFFEAF6FF;
-      case 3:
+      case EXCELLENT:
         evilTrail.color = 0xFFFDCB42;
-      case 4:
+      case PERFECT:
         evilTrail.color = 0xFFFF58B4;
-      case 5:
+      case PERFECT_GOLD:
         evilTrail.color = 0xFFFFB619;
     }
   }
@@ -393,6 +389,12 @@ class SongMenuItem extends FlxSpriteGroup
     // diffRatingSprite.visible = false;
   }
 
+  function updateScoringRank(newRank:Null<ScoringRank>):Void
+  {
+    this.ranking.rank = newRank;
+    this.blurredRanking.rank = newRank;
+  }
+
   function set_hsvShader(value:HSVShader):HSVShader
   {
     this.hsvShader = value;
@@ -441,6 +443,7 @@ class SongMenuItem extends FlxSpriteGroup
     if (songData?.songCharacter != null) setCharacter(songData.songCharacter);
     updateBPM(Std.int(songData?.songStartingBpm) ?? 0);
     updateDifficultyRating(songData?.difficultyRating ?? 0);
+    updateScoringRank(songData?.scoringRank);
     // Update opacity, offsets, etc.
     updateSelected();
 
@@ -668,41 +671,53 @@ class SongMenuItem extends FlxSpriteGroup
 
 class FreeplayRank extends FlxSprite
 {
-  public var rank(default, set):Int = 0;
+  public var rank(default, set):Null<ScoringRank> = null;
 
-  var numToRank:Array<String> = ["LOSS", "GOOD", "GREAT", "EXCELLENT", "PERFECT", "PERFECTSICK"];
-
-  function set_rank(val):Int
+  function set_rank(val:Null<ScoringRank>):Null<ScoringRank>
   {
-    animation.play(numToRank[val], true, false);
+    rank = val;
 
-    centerOffsets(false);
-
-    switch (val)
+    if (rank == null)
     {
-      case 0:
-      // offset.x -= 1;
-      case 1:
-        // offset.x -= 1;
-        offset.y -= 8;
-      case 2:
-        // offset.x -= 1;
-        offset.y -= 8;
-      case 3:
-      // offset.y += 5;
-      case 4:
-      // offset.y += 5;
-      default:
-        centerOffsets(false);
+      this.visible = false;
     }
-    updateHitbox();
-    return val;
+    else
+    {
+      this.visible = true;
+
+      animation.play(val.getFreeplayRankIconAsset(), true, false);
+
+      centerOffsets(false);
+
+      switch (val)
+      {
+        case SHIT:
+        // offset.x -= 1;
+        case GOOD:
+          // offset.x -= 1;
+          offset.y -= 8;
+        case GREAT:
+          // offset.x -= 1;
+          offset.y -= 8;
+        case EXCELLENT:
+        // offset.y += 5;
+        case PERFECT:
+        // offset.y += 5;
+        case PERFECT_GOLD:
+        // offset.y += 5;
+        default:
+          centerOffsets(false);
+      }
+      updateHitbox();
+    }
+
+    return rank = val;
   }
 
-  public var baseY:Float = 0;
   public var baseX:Float = 0;
+  public var baseY:Float = 0;
 
-  public function new(x:Float, y:Float, ?initRank:Int = 0)
+  public function new(x:Float, y:Float)
   {
     super(x, y);
 
@@ -717,9 +732,7 @@ class FreeplayRank extends FlxSprite
 
     blend = BlendMode.ADD;
 
-    this.rank = initRank;
-
-    animation.play(numToRank[initRank], true);
+    this.rank = null;
 
     // setGraphicSize(Std.int(width * 0.9));
     scale.set(0.9, 0.9);

From 7a2f3c81a1d5cbb02c376256361eadb953cc9e6d Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 30 May 2024 21:11:17 -0400
Subject: [PATCH 54/72] fix the one random slice of pixels where the bg doesnt
 show... lol

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

diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index c9b3619e9..c6dbcd505 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -124,7 +124,7 @@ class TitleState extends MusicBeatState
 
     persistentUpdate = true;
 
-    var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK);
+    var bg:FunkinSprite = new FunkinSprite(-1).makeSolidColor(FlxG.width + 2, FlxG.height, FlxColor.BLACK);
     bg.screenCenter();
     add(bg);
 

From dc33da904cc51d2ce1cf08aa3af1030863ae86f3 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 30 May 2024 23:03:27 -0400
Subject: [PATCH 55/72] assets submod

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

diff --git a/assets b/assets
index 2719d3fc1..4bc0b35f6 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 2719d3fc1d8f5d0cbafae8d27141d6c471148482
+Subproject commit 4bc0b35f6c7aa22086b85b6a635c6f0511d277fe

From 7347b66ce47570b6098085945cd561d6fe0f2154 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 30 May 2024 23:07:41 -0400
Subject: [PATCH 56/72] removes trace() that lags freeplay

---
 source/funkin/ui/freeplay/SongMenuItem.hx | 2 --
 1 file changed, 2 deletions(-)

diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index ad6ea386e..75a4c07a3 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -264,8 +264,6 @@ class SongMenuItem extends FlxSpriteGroup
 
   function updateBPM(newBPM:Int):Void
   {
-    trace(newBPM);
-
     var shiftX:Float = 191;
     var tempShift:Float = 0;
 

From 40d1fd96a36bf566437d2dafdb716ffafee9e212 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 30 May 2024 23:27:00 -0400
Subject: [PATCH 57/72] art submod

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

diff --git a/art b/art
index 66572f85d..faeba700c 160000
--- a/art
+++ b/art
@@ -1 +1 @@
-Subproject commit 66572f85d826ce2ec1d45468c12733b161237ffa
+Subproject commit faeba700c5526bd4fd57ccc927d875c82b9d3553

From 007ec95e8548892e70189e782081201624211827 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 30 May 2024 23:42:48 -0400
Subject: [PATCH 58/72] update difficulties

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

diff --git a/assets b/assets
index 4bc0b35f6..11bcd1b79 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 4bc0b35f6c7aa22086b85b6a635c6f0511d277fe
+Subproject commit 11bcd1b79169df4f0aa46d72c867e960a287d28a

From 0d4f3cdc334c8593fe7b1042d1c2295adf69992e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 01:42:41 -0400
Subject: [PATCH 59/72] Scrolling results text

---
 assets                            |  2 +-
 source/funkin/InitState.hx        |  4 +--
 source/funkin/play/ResultState.hx | 44 ++++++++++++++++++++++++++++---
 3 files changed, 43 insertions(+), 7 deletions(-)

diff --git a/assets b/assets
index 11bcd1b79..7a0d92d30 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 11bcd1b79169df4f0aa46d72c867e960a287d28a
+Subproject commit 7a0d92d3007de42c452b2ea97a917d8c8d114ee7
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index d0009f95b..c7a08d714 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -219,9 +219,9 @@ class InitState extends FlxState
     FlxG.switchState(() -> new funkin.play.ResultState(
       {
         storyMode: false,
-        title: "CUM SONG",
+        title: "Cum Song Erect by Kawai Sprite",
         songId: "cum",
-        difficultyId: "hard",
+        difficultyId: "nightmare",
         isNewHighscore: true,
         scoreData:
           {
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 79880038d..8b8c0aea3 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -15,8 +15,10 @@ import funkin.ui.freeplay.FreeplayScore;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import flixel.tweens.FlxEase;
+import funkin.graphics.FunkinCamera;
 import funkin.ui.freeplay.FreeplayState;
 import flixel.tweens.FlxTween;
+import flixel.addons.display.FlxBackdrop;
 import funkin.audio.FunkinSound;
 import flixel.util.FlxGradient;
 import flixel.util.FlxTimer;
@@ -59,6 +61,10 @@ class ResultState extends MusicBeatSubState
   var gfGood:Null<FlxSprite> = null;
   var bfShit:Null<FlxAtlasSprite> = null;
 
+  final cameraBG:FunkinCamera;
+  final cameraScroll:FunkinCamera;
+  final cameraEverything:FunkinCamera;
+
   public function new(params:ResultsStateParams)
   {
     super();
@@ -67,6 +73,10 @@ class ResultState extends MusicBeatSubState
 
     rank = Scoring.calculateRank(params.scoreData) ?? SHIT;
 
+    cameraBG = new FunkinCamera('resultsBG', 0, 0, FlxG.width, FlxG.height);
+    cameraScroll = new FunkinCamera('resultsScroll', 0, 0, FlxG.width, FlxG.height);
+    cameraEverything = new FunkinCamera('resultsEverything', 0, 0, FlxG.width, FlxG.height);
+
     // We build a lot of this stuff in the constructor, then place it in create().
     // This prevents having to do `null` checks everywhere.
 
@@ -101,17 +111,32 @@ class ResultState extends MusicBeatSubState
   {
     if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
+    // We need multiple cameras so we can put one at an angle.
+    cameraScroll.angle = -3.8;
+
+    cameraBG.bgColor = FlxColor.MAGENTA;
+    cameraScroll.bgColor = FlxColor.TRANSPARENT;
+    cameraEverything.bgColor = FlxColor.TRANSPARENT;
+
+    FlxG.cameras.add(cameraBG, false);
+    FlxG.cameras.add(cameraScroll, false);
+    FlxG.cameras.add(cameraEverything, false);
+
+    FlxG.cameras.setDefaultDrawTarget(cameraEverything, true);
+
     // Reset the camera zoom on the results screen.
     FlxG.camera.zoom = 1.0;
 
     var bg:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFECC5C, 0xFFFDC05C], 90);
     bg.scrollFactor.set();
     bg.zIndex = 10;
+    bg.cameras = [cameraBG];
     add(bg);
 
     bgFlash.scrollFactor.set();
     bgFlash.visible = false;
     bgFlash.zIndex = 20;
+    bgFlash.cameras = [cameraBG];
     add(bgFlash);
 
     // The sound system which falls into place behind the score text. Plays every time!
@@ -455,16 +480,27 @@ class ResultState extends MusicBeatSubState
 
   function displayRankText():Void
   {
-    var rankTextVert:FunkinSprite = FunkinSprite.create(FlxG.width - 64, 100, rank.getVerTextAsset());
-    rankTextVert.zIndex = 2000;
+    var rankTextVert:FlxBackdrop = new FlxBackdrop(Paths.image(rank.getVerTextAsset()), Y, 0, 30);
+    rankTextVert.x = FlxG.width - 64;
+    rankTextVert.y = 100;
+    rankTextVert.zIndex = 990;
     add(rankTextVert);
 
+    // Scrolling.
+    rankTextVert.velocity.y = -50;
+
     for (i in 0...10)
     {
-      var rankTextBack:FunkinSprite = FunkinSprite.create(FlxG.width / 2 - 80, 50, rank.getHorTextAsset());
-      rankTextBack.y += (rankTextBack.height * i / 2) + 10;
+      var rankTextBack:FlxBackdrop = new FlxBackdrop(Paths.image(rank.getHorTextAsset()), X, 10, 0);
+      rankTextBack.x = FlxG.width / 2 - 320;
+      rankTextBack.y = 50 + (150 * i / 2) + 10;
+      // rankTextBack.angle = -3.8;
       rankTextBack.zIndex = 100;
+      rankTextBack.cameras = [cameraScroll];
       add(rankTextBack);
+
+      // Scrolling.
+      rankTextBack.velocity.x = (i % 2 == 0) ? -10.0 : 10.0;
     }
 
     refresh();

From 9e0a99374691f8a3018dc547a49c7c400b9245a7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 02:03:40 -0400
Subject: [PATCH 60/72] Disable song previews for mod songs rather than
 crashing the game

---
 source/funkin/audio/FunkinSound.hx | 23 +++++++++++++++--------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index b94c6008c..7663c1305 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -377,7 +377,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
           FlxG.sound.music = partialMusic;
           FlxG.sound.list.remove(FlxG.sound.music);
 
-          if (params.onLoad != null) params.onLoad();
+          if (FlxG.sound.music != null && params.onLoad != null) params.onLoad();
         });
 
         return true;
@@ -488,14 +488,21 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 
     var soundRequest = FlxPartialSound.partialLoadFromFile(path, start, end);
 
-    promise.future.onError(function(e) {
-      soundRequest.error("Sound loading was errored or cancelled");
-    });
+    if (soundRequest == null)
+    {
+      promise.complete(null);
+    }
+    else
+    {
+      promise.future.onError(function(e) {
+        soundRequest.error("Sound loading was errored or cancelled");
+      });
 
-    soundRequest.future.onComplete(function(partialSound) {
-      var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, onComplete, onLoad);
-      promise.complete(snd);
-    });
+      soundRequest.future.onComplete(function(partialSound) {
+        var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, onComplete, onLoad);
+        promise.complete(snd);
+      });
+    }
 
     return promise;
   }

From 2a8cdfaffae9dca58c0957f825f491ec89bd8070 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 02:30:42 -0400
Subject: [PATCH 61/72] Fix an extra crash.

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

diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 192c6e3ce..f0695e51e 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -830,7 +830,7 @@ class FreeplayState extends MusicBeatSubState
     dj.fistPump();
     // rankCamera.fade(FlxColor.BLACK, 0.5, true);
     rankCamera.fade(0xFF000000, 0.5, true, null, true);
-    FlxG.sound.music.volume = 0;
+    if (FlxG.sound.music != null) FlxG.sound.music.volume = 0;
     rankBg.alpha = 1;
 
     originalPos.x = grpCapsules.members[curSelected].x;

From d89a898e6c3d33430002896bd601ec72c6a0faba Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Fri, 31 May 2024 17:16:26 -0400
Subject: [PATCH 62/72] make songs last longer on freeplay

---
 source/funkin/ui/freeplay/FreeplayState.hx | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 192c6e3ce..71052a923 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -104,6 +104,7 @@ class FreeplayState extends MusicBeatSubState
 
   /**
    * For the audio preview, the duration of the fade-out effect.
+   *
    */
   public static final FADE_OUT_DURATION:Float = 0.25;
 
@@ -1690,7 +1691,6 @@ class FreeplayState extends MusicBeatSubState
       else
       {
         var potentiallyErect:String = (currentDifficulty == "erect") || (currentDifficulty == "nightmare") ? "-erect" : "";
-        // TODO: Stream the instrumental of the selected song?
         FunkinSound.playMusic(daSongCapsule.songData.songId,
           {
             startingVolume: 0.0,
@@ -1701,8 +1701,8 @@ class FreeplayState extends MusicBeatSubState
             partialParams:
               {
                 loadPartial: true,
-                start: 0,
-                end: 0.1
+                start: 0.05,
+                end: 0.25
               },
             onLoad: function() {
               FlxG.sound.music.fadeIn(2, 0, 0.4);

From 98eda8ef551ad533b29a5a1625ec613b78165c5e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 18:09:07 -0400
Subject: [PATCH 63/72] Give timeFormat a default value (fixes the Stress
 issues!)

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

diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index bd25139a7..ca805e139 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -56,6 +56,8 @@ class SongMetadata implements ICloneable<SongMetadata>
   @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
   public var generatedBy:String;
 
+  @:optional
+  @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS)
   public var timeFormat:SongTimeFormat;
 
   public var timeChanges:Array<SongTimeChange>;
@@ -117,7 +119,7 @@ class SongMetadata implements ICloneable<SongMetadata>
   {
     var ignoreNullOptionals = true;
     var writer = new json2object.JsonWriter<SongMetadata>(ignoreNullOptionals);
-    // I believe @:jignored should be iggnored by the writer?
+    // I believe @:jignored should be ignored by the writer?
     // var output = this.clone();
     // output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer.
     return writer.write(this, pretty ? '  ' : null);

From 8bf26322e9a405e3eb70508711b66a606cd444a6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 18:09:30 -0400
Subject: [PATCH 64/72] Update `generatedBy` to the latest value when saving a
 chart.

---
 .../ChartEditorImportExportHandler.hx         | 25 +++++++++++++++----
 1 file changed, 20 insertions(+), 5 deletions(-)

diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 0308cd871..a78eeae4c 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -384,17 +384,32 @@ class ChartEditorImportExportHandler
       if (variationId == '')
       {
         var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
-        if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', variationMetadata.serialize()));
+        if (variationMetadata != null)
+        {
+          variationMetadata.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
+          zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', variationMetadata.serialize()));
+        }
         var variationChart:Null<SongChartData> = state.songChartData.get(variation);
-        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', variationChart.serialize()));
+        if (variationChart != null)
+        {
+          variationChart.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
+          zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', variationChart.serialize()));
+        }
       }
       else
       {
         var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
-        if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json',
-          variationMetadata.serialize()));
+        if (variationMetadata != null)
+        {
+          variationMetadata.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
+          zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json', variationMetadata.serialize()));
+        }
         var variationChart:Null<SongChartData> = state.songChartData.get(variation);
-        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', variationChart.serialize()));
+        if (variationChart != null)
+        {
+          variationChart.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
+          zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', variationChart.serialize()));
+        }
       }
     }
 

From 06daa9d402a81bb45b5bf214595b4d0ac794fd4c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 19:20:39 -0400
Subject: [PATCH 65/72] Increase Great threshold to 80%

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

diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 4e706c612..1e0978839 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -467,7 +467,7 @@ class Constants
   // % Hit
   public static final RANK_PERFECT_THRESHOLD:Float = 1.00;
   public static final RANK_EXCELLENT_THRESHOLD:Float = 0.90;
-  public static final RANK_GREAT_THRESHOLD:Float = 0.75;
+  public static final RANK_GREAT_THRESHOLD:Float = 0.80;
   public static final RANK_GOOD_THRESHOLD:Float = 0.60;
 
   // public static final RANK_SHIT_THRESHOLD:Float = 0.00;

From 9088570b926eae749c5cc6822dbb01cd1d541f69 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 19:21:21 -0400
Subject: [PATCH 66/72] Make sure JSON data uses latest version/generatedBy
 when writing.

---
 source/funkin/data/song/SongData.hx           | 24 +++++++++++++++++++
 .../data/song/importer/ChartManifestData.hx   |  8 +++++++
 source/funkin/data/stage/StageData.hx         |  8 +++++++
 .../funkin/ui/credits/CreditsDataHandler.hx   |  2 +-
 .../ChartEditorImportExportHandler.hx         |  4 +++-
 5 files changed, 44 insertions(+), 2 deletions(-)

diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index ca805e139..769af8f08 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -117,6 +117,9 @@ class SongMetadata implements ICloneable<SongMetadata>
    */
   public function serialize(pretty:Bool = true):String
   {
+    // Update generatedBy and version before writing.
+    updateVersionToLatest();
+
     var ignoreNullOptionals = true;
     var writer = new json2object.JsonWriter<SongMetadata>(ignoreNullOptionals);
     // I believe @:jignored should be ignored by the writer?
@@ -125,6 +128,12 @@ class SongMetadata implements ICloneable<SongMetadata>
     return writer.write(this, pretty ? '  ' : null);
   }
 
+  public function updateVersionToLatest():Void
+  {
+    this.version = SongRegistry.SONG_METADATA_VERSION;
+    this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
@@ -373,6 +382,12 @@ class SongMusicData implements ICloneable<SongMusicData>
     this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
   }
 
+  public function updateVersionToLatest():Void
+  {
+    this.version = SongRegistry.SONG_MUSIC_DATA_VERSION;
+    this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+  }
+
   public function clone():SongMusicData
   {
     var result:SongMusicData = new SongMusicData(this.songName, this.artist, this.variation);
@@ -605,11 +620,20 @@ class SongChartData implements ICloneable<SongChartData>
    */
   public function serialize(pretty:Bool = true):String
   {
+    // Update generatedBy and version before writing.
+    updateVersionToLatest();
+
     var ignoreNullOptionals = true;
     var writer = new json2object.JsonWriter<SongChartData>(ignoreNullOptionals);
     return writer.write(this, pretty ? '  ' : null);
   }
 
+  public function updateVersionToLatest():Void
+  {
+    this.version = SongRegistry.SONG_CHART_DATA_VERSION;
+    this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+  }
+
   public function clone():SongChartData
   {
     // We have to manually perform the deep clone here because Map.deepClone() doesn't work.
diff --git a/source/funkin/data/song/importer/ChartManifestData.hx b/source/funkin/data/song/importer/ChartManifestData.hx
index dd0d28479..04b5a1b69 100644
--- a/source/funkin/data/song/importer/ChartManifestData.hx
+++ b/source/funkin/data/song/importer/ChartManifestData.hx
@@ -61,10 +61,18 @@ class ChartManifestData
    */
   public function serialize(pretty:Bool = true):String
   {
+    // Update generatedBy and version before writing.
+    updateVersionToLatest();
+
     var writer = new json2object.JsonWriter<ChartManifestData>();
     return writer.write(this, pretty ? '  ' : null);
   }
 
+  public function updateVersionToLatest():Void
+  {
+    this.version = CHART_MANIFEST_DATA_VERSION;
+  }
+
   public static function deserialize(contents:String):Null<ChartManifestData>
   {
     var parser = new json2object.JsonParser<ChartManifestData>();
diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx
index 22b883c75..bebd86d02 100644
--- a/source/funkin/data/stage/StageData.hx
+++ b/source/funkin/data/stage/StageData.hx
@@ -58,9 +58,17 @@ class StageData
    */
   public function serialize(pretty:Bool = true):String
   {
+    // Update generatedBy and version before writing.
+    updateVersionToLatest();
+
     var writer = new json2object.JsonWriter<StageData>();
     return writer.write(this, pretty ? '  ' : null);
   }
+
+  public function updateVersionToLatest():Void
+  {
+    this.version = StageRegistry.STAGE_DATA_VERSION;
+  }
 }
 
 typedef StageDataCharacters =
diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx
index 2240ec50e..844d0f4db 100644
--- a/source/funkin/ui/credits/CreditsDataHandler.hx
+++ b/source/funkin/ui/credits/CreditsDataHandler.hx
@@ -54,7 +54,7 @@ class CreditsDataHandler
           body: [
             {line: 'ninjamuffin99'},
             {line: 'PhantomArcade'},
-            {line: 'KawaiSprite'},
+            {line: 'Kawai Sprite'},
             {line: 'evilsk8r'},
           ]
         }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index a78eeae4c..e84f7ec43 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -386,12 +386,14 @@ class ChartEditorImportExportHandler
         var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
         if (variationMetadata != null)
         {
+          variationMetadata.version = funkin.data.song.SongRegistry.SONG_METADATA_VERSION;
           variationMetadata.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
           zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', variationMetadata.serialize()));
         }
         var variationChart:Null<SongChartData> = state.songChartData.get(variation);
         if (variationChart != null)
         {
+          variationChart.version = funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION;
           variationChart.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
           zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', variationChart.serialize()));
         }
@@ -401,12 +403,12 @@ class ChartEditorImportExportHandler
         var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
         if (variationMetadata != null)
         {
-          variationMetadata.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
           zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json', variationMetadata.serialize()));
         }
         var variationChart:Null<SongChartData> = state.songChartData.get(variation);
         if (variationChart != null)
         {
+          variationChart.version = funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION;
           variationChart.generatedBy = funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY;
           zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', variationChart.serialize()));
         }

From 074b1afc7659cf0923a03533994a812c9f39ef8d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 20:16:23 -0400
Subject: [PATCH 67/72] Readd a reverted freeplay bugfix

---
 source/funkin/audio/FunkinSound.hx | 23 +++++++++++++++--------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index b94c6008c..7663c1305 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -377,7 +377,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
           FlxG.sound.music = partialMusic;
           FlxG.sound.list.remove(FlxG.sound.music);
 
-          if (params.onLoad != null) params.onLoad();
+          if (FlxG.sound.music != null && params.onLoad != null) params.onLoad();
         });
 
         return true;
@@ -488,14 +488,21 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 
     var soundRequest = FlxPartialSound.partialLoadFromFile(path, start, end);
 
-    promise.future.onError(function(e) {
-      soundRequest.error("Sound loading was errored or cancelled");
-    });
+    if (soundRequest == null)
+    {
+      promise.complete(null);
+    }
+    else
+    {
+      promise.future.onError(function(e) {
+        soundRequest.error("Sound loading was errored or cancelled");
+      });
 
-    soundRequest.future.onComplete(function(partialSound) {
-      var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, onComplete, onLoad);
-      promise.complete(snd);
-    });
+      soundRequest.future.onComplete(function(partialSound) {
+        var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, onComplete, onLoad);
+        promise.complete(snd);
+      });
+    }
 
     return promise;
   }

From e36fbfa72c79bf2ac793e1a78a80b0559c9cba64 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 20:16:37 -0400
Subject: [PATCH 68/72] Update the changelog

---
 CHANGELOG.md | 36 +++++++++++++++++++++++++++++-------
 1 file changed, 29 insertions(+), 7 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10bbfe5f7..f5aefb885 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,31 +6,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [0.4.0] - 2024-05-??
 ### Added
-- 2 new Erect remixes, Eggnog and Satin Panties. Check them out from
-- Improvements to the Freeplay screen, with song difficulty ratings and player rank displays.
-- Reworked the Results screen, with additional animations and audio based on your performance.
+- 2 new Erect remixes, Eggnog and Satin Panties. Check them out from the Freeplay menu!
+- Major visual improvements to the Results screen, with additional animations and audio based on your performance.
+- Major visual improvements to the Freeplay screen, with song difficulty ratings and player rank displays.
+  - Freeplay now plays a preview of songs when you hover over them.
 - Added a Charter field to the chart format, to allow for crediting the creator of a level's chart.
   - You can see who charted a song from the Pause menu.
+- Added a new Scroll Speed chart event to change the note speed mid-song (thanks )
 ### Changed
 - Tweaked the charts for several songs:
+  - Monster
   - Winter Horrorland
   - Stress
   - Lit Up
+  - Tutorial (increased the note speed slightly)
+  - Senpai (increased the note speed)
+  - Thorns (increased the note speed slightly)
+- Favorite songs marked in Freeplay are now stored between sessions.
 - Custom note styles are now properly supported for songs; add new notestyles via JSON, then select it for use from the Chart Editor Metadata toolbox. (thanks Keoiki!)
+- Improved logic for NoteHitScriptEvents, allowing you to view the hit diff and modify whether a note hit is a combo break (thanks nebulazorua!)
 - Health icons now support a Winning frame without requiring a spritesheet, simply include a third frame in the icon file. (thanks gamerbross!)
   - Remember that for more complex behaviors such as animations or transitions, you should use an XML file to define each frame.
 ### Fixed
+- Fixed a bug where the game would silently fail to load saves on HTML5
+- Fixed some bugs with the props on the Story Menu not bopping properly
+- Additional fixes to the Loading bar on HTML5 (thanks lemz1!)
+- Fixed several bugs with the TitleState, including missing music when returning from the Main Menu (thanks gamerbross!)
+- Fixed a camera bug in the Main Menu (thanks richTrash21!)
+- Fixed a bug where changing difficulties in Story mode wouldn't update the score (thanks sectorA!)
+- Fixed a crash in Freeplay caused by a level referencing an invalid song (thanks gamerbross!)
 - Fixed a bug where pressing the volume keys would stop the Toy commercial (thanks gamerbross!)
-- Fixed a bug where the Chart Editor would crash when losing (thanks gamerbross!)
+- Fixed a bug where the Chart Editor Playtest would crash when losing (thanks gamerbross!)
+- Fixed a bug where hold notes would display improperly in the Chart Editor when downscroll was enabled for gameplay (thanks gamerbross!)
+- Fixed a bug where hold notes would be positioned wrong on downscroll (thanks MaybeMaru!)
+- Removed a large number of unused imports to optimize builds (thanks Ethan-makes-music!)
+- Improved debug logging for unscripted stages (thanks gamerbross!)
 - Made improvements to compiling documentation (thanks gedehari!)
 - Fixed a crash on Linux caused by an old version of hxCodec (thanks Noobz4Life!)
 - Optimized animation handling for characters (thanks richTrash21!)
+- Additional bug fixes and optimizations.
 
 ## [0.3.3] - 2024-05-14
 ### Changed
 - Cleaned up some code in `PlayAnimationSongEvent.hx` (thanks BurgerBalls!)
 ### Fixed
-- Fix Web Loading Bar (thanks lemz1!)
+- Fixes to the Loading bar on HTML5 (thanks lemz1!)
 - Don't allow any more inputs when exiting freeplay (thanks gamerbros!)
 - Fixed using mouse wheel to scroll on freeplay (thanks JugieNoob!)
 - Fixed the reset's of the health icons, score, and notes when re-entering gameplay from gameover (thanks ImCodist!)
@@ -38,11 +58,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed camera stutter once a wipe transition to the Main Menu completes (thanks ImCodist!)
 - Fixed an issue where hold note would be invisible for a single frame (thanks ImCodist!)
 - Fix tween accumulation on title screen when pressing Y multiple times (thanks TheGaloXx!)
-- Fix for a game over easter egg so you don't accidentally exit it when viewing
 - Fix a crash when querying FlxG.state in the crash handler
+- Fix for a game over easter egg so you don't accidentally exit it when viewing
 - Fix an issue where the Freeplay menu never displays 100% clear
+- Fix an issue where Weekend 1 Pico attempted to retrieve a missing asset.
+- Fix an issue where duplicate keybinds would be stoed, potentially causing a crash
 - Chart debug key now properly returns you to the previous chart editor session if you were playtesting a chart (thanks nebulazorua!)
-- Hopefully fixed Freeplay crashes on AMD gpu's
+- Fix a crash on Freeplay found on AMD graphics cards
 
 ## [0.3.2] - 2024-05-03
 ### Added

From fd28c91e75b949976bc074d0cd1d0cde5c97b6f4 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 21:21:46 -0400
Subject: [PATCH 69/72] Update assets submodule

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

diff --git a/assets b/assets
index 7a0d92d30..59d376218 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 7a0d92d3007de42c452b2ea97a917d8c8d114ee7
+Subproject commit 59d376218d288ef3001de6ef78b8d6d7c5f52842

From 12acdcd9d9cb4cf1a65e5598f421ecca962f33a3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 21:55:27 -0400
Subject: [PATCH 70/72] Fix a Results->Freeplay crash

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

diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 192c6e3ce..1bde92667 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -868,7 +868,7 @@ class FreeplayState extends MusicBeatSubState
 
     FlxTween.tween(grpCapsules.members[curSelected].ranking, {"scale.x": 1, "scale.y": 1}, 0.1);
 
-    grpCapsules.members[curSelected].blurredRanking.animation.play(grpCapsules.members[curSelected].blurredRanking.animation.curAnim.name, true);
+    grpCapsules.members[curSelected].blurredRanking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true);
     FlxTween.tween(grpCapsules.members[curSelected].blurredRanking, {"scale.x": 1, "scale.y": 1}, 0.1);
 
     new FlxTimer().start(0.1, _ -> {

From 6851edc64b89ce960d34975af1cf2ce6699e5996 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 31 May 2024 21:55:33 -0400
Subject: [PATCH 71/72] Add new charts

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

diff --git a/assets b/assets
index 59d376218..0e5a66cb1 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 59d376218d288ef3001de6ef78b8d6d7c5f52842
+Subproject commit 0e5a66cb15229fde2c65503ba1267dfc7b4640b5

From e4eb9a7dc90f40ef667d78c027daecf58832819a Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Sat, 1 Jun 2024 16:23:22 -0400
Subject: [PATCH 72/72] assets submod

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

diff --git a/assets b/assets
index 0e5a66cb1..3bfa4e3da 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 0e5a66cb15229fde2c65503ba1267dfc7b4640b5
+Subproject commit 3bfa4e3da87713ea651f60d4f898c283e5d86093