Explorar el Código

Merge OsuBeatmap and OsuBeatmapStandard

Clément Wolf hace 1 mes
padre
commit
148ba27862

+ 1 - 1
src/App/Osu/BanchoDownloader.h

@@ -1,5 +1,5 @@
 #pragma once
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuDatabaseBeatmap.h"
 
 enum DownloadStatus {

+ 7 - 14
src/App/Osu/Osu.cpp

@@ -31,7 +31,6 @@
 
 #include "OsuBackgroundImageHandler.h"
 #include "OsuBeatmap.h"
-#include "OsuBeatmapStandard.h"
 #include "OsuChangelog.h"
 #include "OsuChat.h"
 #include "OsuDatabaseBeatmap.h"
@@ -348,7 +347,6 @@ Osu::Osu(int instanceID) {
 
     m_bShouldCursorBeVisible = false;
 
-    m_gamemode = GAMEMODE::STD;
     m_bScheduleEndlessModNextBeatmap = false;
     m_iMultiplayerClientNumEscPresses = 0;
     m_bWasBossKeyPaused = false;
@@ -592,7 +590,7 @@ void Osu::draw(Graphics *g) {
     // draw everything in the correct order
     if(isInPlayMode())  // if we are playing a beatmap
     {
-        OsuBeatmapStandard *beatmapStd = dynamic_cast<OsuBeatmapStandard *>(getSelectedBeatmap());
+        OsuBeatmap *beatmap = getSelectedBeatmap();
         const bool isAuto = (m_bModAuto || m_bModAutopilot);
         const bool isFPoSu = (m_osu_mod_fposu_ref->getBool());
 
@@ -600,7 +598,7 @@ void Osu::draw(Graphics *g) {
 
         getSelectedBeatmap()->draw(g);
 
-        if(m_bModFlashlight && beatmapStd != NULL) {
+        if(m_bModFlashlight && beatmap != NULL) {
             // Dim screen when holding a slider
             float max_opacity = 1.f;
             if(holding_slider && !avoid_flashes.getBool()) {
@@ -608,7 +606,7 @@ void Osu::draw(Graphics *g) {
             }
 
             // Convert screen mouse -> osu mouse pos
-            Vector2 cursorPos = isAuto ? beatmapStd->getCursorPos() : engine->getMouse()->getPos();
+            Vector2 cursorPos = isAuto ? beatmap->getCursorPos() : engine->getMouse()->getPos();
             Vector2 mouse_position = cursorPos - OsuGameRules::getPlayfieldOffset(this);
             mouse_position /= OsuGameRules::getPlayfieldScaleFactor(this);
 
@@ -656,9 +654,9 @@ void Osu::draw(Graphics *g) {
         if(m_pauseMenu->isVisible() || getSelectedBeatmap()->isContinueScheduled()) fadingCursorAlpha = 1.0f;
 
         // draw auto cursor
-        if(isAuto && allowDrawCursor && !isFPoSu && beatmapStd != NULL && !beatmapStd->isLoading())
+        if(isAuto && allowDrawCursor && !isFPoSu && beatmap != NULL && !beatmap->isLoading())
             m_hud->drawCursor(
-                g, m_osu_mod_fps_ref->getBool() ? OsuGameRules::getPlayfieldCenter(this) : beatmapStd->getCursorPos(),
+                g, m_osu_mod_fps_ref->getBool() ? OsuGameRules::getPlayfieldCenter(this) : beatmap->getCursorPos(),
                 osu_mod_fadingcursor.getBool() ? fadingCursorAlpha : 1.0f);
 
         m_pauseMenu->draw(g);
@@ -674,7 +672,7 @@ void Osu::draw(Graphics *g) {
 
         // draw FPoSu cursor trail
         if(isFPoSu && m_fposu_draw_cursor_trail_ref->getBool())
-            m_hud->drawCursorTrail(g, beatmapStd->getCursorPos(),
+            m_hud->drawCursorTrail(g, beatmap->getCursorPos(),
                                    osu_mod_fadingcursor.getBool() ? fadingCursorAlpha : 1.0f);
 
         if(isFPoSu) {
@@ -687,8 +685,7 @@ void Osu::draw(Graphics *g) {
 
         // draw player cursor
         if((!isAuto || allowDoubleCursor) && allowDrawCursor) {
-            Vector2 cursorPos =
-                (beatmapStd != NULL && !isAuto) ? beatmapStd->getCursorPos() : engine->getMouse()->getPos();
+            Vector2 cursorPos = (beatmap != NULL && !isAuto) ? beatmap->getCursorPos() : engine->getMouse()->getPos();
 
             if(isFPoSu) {
                 cursorPos = getScreenSize() / 2.0f;
@@ -1469,10 +1466,6 @@ void Osu::onKeyDown(KeyboardEvent &key) {
 }
 
 void Osu::onKeyUp(KeyboardEvent &key) {
-    if(isInPlayMode()) {
-        if(!getSelectedBeatmap()->isPaused()) getSelectedBeatmap()->onKeyUp(key);  // only used for mania atm
-    }
-
     // clicks
     {
         // K1

+ 0 - 6
src/App/Osu/Osu.h

@@ -64,8 +64,6 @@ class Osu : public App, public MouseListener {
 
     static bool findIgnoreCase(const std::string &haystack, const std::string &needle);
 
-    enum class GAMEMODE { STD, MANIA };
-
     Osu(int instanceID = 0);
     virtual ~Osu();
 
@@ -109,10 +107,7 @@ class Osu : public App, public MouseListener {
     void setSkin(UString skin) { onSkinChange("", skin); }
     void reloadSkin() { onSkinReload(); }
 
-    void setGamemode(GAMEMODE gamemode) { m_gamemode = gamemode; }
-
     inline int getInstanceID() const { return m_iInstanceID; }
-    inline GAMEMODE getGamemode() const { return m_gamemode; }
 
     inline Vector2 getScreenSize() const { return g_vInternalResolution; }
     inline int getScreenWidth() const { return (int)g_vInternalResolution.x; }
@@ -363,7 +358,6 @@ class Osu : public App, public MouseListener {
     CWindowManager *m_windowManager;
 
     // custom
-    GAMEMODE m_gamemode;
     bool m_bScheduleEndlessModNextBeatmap;
     int m_iMultiplayerClientNumEscPresses;
     int m_iInstanceID;

+ 3 - 4
src/App/Osu/OsuBackgroundStarCacheLoader.cpp

@@ -1,6 +1,6 @@
 //================ Copyright (c) 2020, PG, All rights reserved. =================//
 //
-// Purpose:		used by OsuBeatmapStandard for populating the live pp star cache
+// Purpose:		used by OsuBeatmap for populating the live pp star cache
 //
 // $NoKeywords: $osubgscache
 //===============================================================================//
@@ -38,7 +38,6 @@ void OsuBackgroundStarCacheLoader::initAsync() {
         m_beatmap->m_speedNotesForNumHitObjects.clear();
 
         const std::string &osuFilePath = diff2->getFilePath();
-        const Osu::GAMEMODE gameMode = m_beatmap->getOsu()->getGamemode();
         const float AR = m_beatmap->getAR();
         const float CS = m_beatmap->getCS();
         const float OD = m_beatmap->getOD();
@@ -47,8 +46,8 @@ void OsuBackgroundStarCacheLoader::initAsync() {
         const bool relax = m_beatmap->getOsu()->getModRelax();
         const bool touchDevice = m_beatmap->getOsu()->getModTD();
 
-        OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres = OsuDatabaseBeatmap::loadDifficultyHitObjects(
-            osuFilePath, gameMode, AR, CS, speedMultiplier, false, m_bDead);
+        OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres =
+            OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, AR, CS, speedMultiplier, false, m_bDead);
 
         for(size_t i = 0; i < diffres.diffobjects.size(); i++) {
             double aimStars = 0.0;

+ 1 - 1
src/App/Osu/OsuBackgroundStarCacheLoader.h

@@ -1,6 +1,6 @@
 //================ Copyright (c) 2020, PG, All rights reserved. =================//
 //
-// Purpose:		used by OsuBeatmapStandard for populating the live pp star cache
+// Purpose:		used by OsuBeatmap for populating the live pp star cache
 //
 // $NoKeywords: $osubgscache
 //===============================================================================//

+ 3401 - 1550
src/App/Osu/OsuBeatmap.cpp

@@ -11,11 +11,14 @@
 
 #include <algorithm>
 #include <cctype>
+#include <chrono>
 #include <sstream>
 
 #include "AnimationHandler.h"
 #include "Bancho.h"
 #include "BanchoNetworking.h"
+#include "BanchoProtocol.h"
+#include "BanchoSubmitter.h"
 #include "ConVar.h"
 #include "Engine.h"
 #include "Environment.h"
@@ -23,23 +26,30 @@
 #include "Mouse.h"
 #include "Osu.h"
 #include "OsuBackgroundImageHandler.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBackgroundStarCacheLoader.h"
+#include "OsuBeatmap.h"
 #include "OsuChat.h"
 #include "OsuCircle.h"
+#include "OsuDatabase.h"
 #include "OsuDatabaseBeatmap.h"
+#include "OsuDifficultyCalculator.h"
 #include "OsuGameRules.h"
 #include "OsuHUD.h"
 #include "OsuHitObject.h"
 #include "OsuKeyBindings.h"
 #include "OsuMainMenu.h"
+#include "OsuModFPoSu.h"
 #include "OsuModSelector.h"
 #include "OsuNotificationOverlay.h"
 #include "OsuPauseMenu.h"
 #include "OsuReplay.h"
+#include "OsuRichPresence.h"
 #include "OsuRoom.h"
 #include "OsuSkin.h"
 #include "OsuSkinImage.h"
 #include "OsuSlider.h"
+#include "OsuSongBrowser2.h"
+#include "OsuSpinner.h"
 #include "ResourceManager.h"
 #include "SoundEngine.h"
 
@@ -186,34 +196,108 @@ ConVar osu_play_hitsound_on_click_while_playing("osu_play_hitsound_on_click_whil
 
 ConVar osu_debug_draw_timingpoints("osu_debug_draw_timingpoints", false, FCVAR_CHEAT);
 
-ConVar *OsuBeatmap::m_osu_pvs = &osu_pvs;
-ConVar *OsuBeatmap::m_osu_draw_hitobjects_ref = &osu_draw_hitobjects;
-ConVar *OsuBeatmap::m_osu_followpoints_prevfadetime_ref = &osu_followpoints_prevfadetime;
-ConVar *OsuBeatmap::m_osu_universal_offset_ref = &osu_universal_offset;
-ConVar *OsuBeatmap::m_osu_early_note_time_ref = &osu_early_note_time;
-ConVar *OsuBeatmap::m_osu_fail_time_ref = &osu_fail_time;
-ConVar *OsuBeatmap::m_osu_drain_type_ref = &osu_drain_type;
-
-ConVar *OsuBeatmap::m_osu_draw_hud_ref = NULL;
-ConVar *OsuBeatmap::m_osu_draw_scorebarbg_ref = NULL;
-ConVar *OsuBeatmap::m_osu_hud_scorebar_hide_during_breaks_ref = NULL;
-ConVar *OsuBeatmap::m_osu_drain_stable_hpbar_maximum_ref = NULL;
-ConVar *OsuBeatmap::m_osu_volume_music_ref = NULL;
-ConVar *OsuBeatmap::m_osu_mod_fposu_ref = NULL;
-ConVar *OsuBeatmap::m_fposu_draw_scorebarbg_on_top_ref = NULL;
+ConVar osu_draw_followpoints("osu_draw_followpoints", true, FCVAR_NONE);
+ConVar osu_draw_reverse_order("osu_draw_reverse_order", false, FCVAR_NONE);
+ConVar osu_draw_playfield_border("osu_draw_playfield_border", true, FCVAR_NONE);
+
+ConVar osu_stacking("osu_stacking", true, FCVAR_NONE, "Whether to use stacking calculations or not");
+ConVar osu_stacking_leniency_override("osu_stacking_leniency_override", -1.0f, FCVAR_NONE);
+
+ConVar osu_auto_snapping_strength("osu_auto_snapping_strength", 1.0f, FCVAR_NONE,
+                                  "How many iterations of quadratic interpolation to use, more = snappier, 0 = linear");
+ConVar osu_auto_cursordance("osu_auto_cursordance", false, FCVAR_NONE);
+ConVar osu_autopilot_snapping_strength(
+    "osu_autopilot_snapping_strength", 2.0f, FCVAR_NONE,
+    "How many iterations of quadratic interpolation to use, more = snappier, 0 = linear");
+ConVar osu_autopilot_lenience("osu_autopilot_lenience", 0.75f, FCVAR_NONE);
+
+ConVar osu_followpoints_clamp("osu_followpoints_clamp", false, FCVAR_NONE,
+                              "clamp followpoint approach time to current circle approach time (instead of using the "
+                              "hardcoded default 800 ms raw)");
+ConVar osu_followpoints_anim("osu_followpoints_anim", false, FCVAR_NONE,
+                             "scale + move animation while fading in followpoints (osu only does this when its "
+                             "internal default skin is being used)");
+ConVar osu_followpoints_connect_combos("osu_followpoints_connect_combos", false, FCVAR_NONE,
+                                       "connect followpoints even if a new combo has started");
+ConVar osu_followpoints_connect_spinners("osu_followpoints_connect_spinners", false, FCVAR_NONE,
+                                         "connect followpoints even through spinners");
+ConVar osu_followpoints_approachtime("osu_followpoints_approachtime", 800.0f, FCVAR_NONE);
+ConVar osu_followpoints_scale_multiplier("osu_followpoints_scale_multiplier", 1.0f, FCVAR_NONE);
+ConVar osu_followpoints_separation_multiplier("osu_followpoints_separation_multiplier", 1.0f, FCVAR_NONE);
+
+ConVar osu_number_scale_multiplier("osu_number_scale_multiplier", 1.0f, FCVAR_NONE);
+
+ConVar osu_playfield_mirror_horizontal("osu_playfield_mirror_horizontal", false, FCVAR_NONE);
+ConVar osu_playfield_mirror_vertical("osu_playfield_mirror_vertical", false, FCVAR_NONE);
+
+ConVar osu_playfield_rotation("osu_playfield_rotation", 0.0f, FCVAR_CHEAT,
+                              "rotates the entire playfield by this many degrees");
+ConVar osu_playfield_stretch_x("osu_playfield_stretch_x", 0.0f, FCVAR_CHEAT,
+                               "offsets/multiplies all hitobject coordinates by it (0 = default 1x playfield size, -1 "
+                               "= on a line, -0.5 = 0.5x playfield size, 0.5 = 1.5x playfield size)");
+ConVar osu_playfield_stretch_y("osu_playfield_stretch_y", 0.0f, FCVAR_CHEAT,
+                               "offsets/multiplies all hitobject coordinates by it (0 = default 1x playfield size, -1 "
+                               "= on a line, -0.5 = 0.5x playfield size, 0.5 = 1.5x playfield size)");
+ConVar osu_playfield_circular(
+    "osu_playfield_circular", false, FCVAR_CHEAT,
+    "whether the playfield area should be transformed from a rectangle into a circle/disc/oval");
+
+ConVar osu_drain_lazer_health_min("osu_drain_lazer_health_min", 0.95f, FCVAR_NONE);
+ConVar osu_drain_lazer_health_mid("osu_drain_lazer_health_mid", 0.70f, FCVAR_NONE);
+ConVar osu_drain_lazer_health_max("osu_drain_lazer_health_max", 0.30f, FCVAR_NONE);
+
+ConVar osu_mod_wobble("osu_mod_wobble", false, FCVAR_NONVANILLA);
+ConVar osu_mod_wobble2("osu_mod_wobble2", false, FCVAR_NONVANILLA);
+ConVar osu_mod_wobble_strength("osu_mod_wobble_strength", 25.0f, FCVAR_NONE);
+ConVar osu_mod_wobble_frequency("osu_mod_wobble_frequency", 1.0f, FCVAR_NONE);
+ConVar osu_mod_wobble_rotation_speed("osu_mod_wobble_rotation_speed", 1.0f, FCVAR_NONE);
+ConVar osu_mod_jigsaw2("osu_mod_jigsaw2", false, FCVAR_NONVANILLA);
+ConVar osu_mod_jigsaw_followcircle_radius_factor("osu_mod_jigsaw_followcircle_radius_factor", 0.0f, FCVAR_NONE);
+ConVar osu_mod_shirone("osu_mod_shirone", false, FCVAR_NONVANILLA);
+ConVar osu_mod_shirone_combo("osu_mod_shirone_combo", 20.0f, FCVAR_NONE);
+ConVar osu_mod_mafham_render_chunksize("osu_mod_mafham_render_chunksize", 15, FCVAR_NONE,
+                                       "render this many hitobjects per frame chunk into the scene buffer (spreads "
+                                       "rendering across many frames to minimize lag)");
+
+ConVar osu_mandala("osu_mandala", false, FCVAR_CHEAT);
+ConVar osu_mandala_num("osu_mandala_num", 7, FCVAR_NONE);
+
+ConVar osu_debug_hiterrorbar_misaims("osu_debug_hiterrorbar_misaims", false, FCVAR_NONE);
+
+ConVar osu_pp_live_timeout(
+    "osu_pp_live_timeout", 1.0f, FCVAR_NONE,
+    "show message that we're still calculating stars after this many seconds, on the first start of the beatmap");
 
 OsuBeatmap::OsuBeatmap(Osu *osu) {
     // convar refs
-    if(m_osu_draw_hud_ref == NULL) m_osu_draw_hud_ref = convar->getConVarByName("osu_draw_hud");
-    if(m_osu_draw_scorebarbg_ref == NULL) m_osu_draw_scorebarbg_ref = convar->getConVarByName("osu_draw_scorebarbg");
-    if(m_osu_hud_scorebar_hide_during_breaks_ref == NULL)
-        m_osu_hud_scorebar_hide_during_breaks_ref = convar->getConVarByName("osu_hud_scorebar_hide_during_breaks");
-    if(m_osu_drain_stable_hpbar_maximum_ref == NULL)
-        m_osu_drain_stable_hpbar_maximum_ref = convar->getConVarByName("osu_drain_stable_hpbar_maximum");
-    if(m_osu_volume_music_ref == NULL) m_osu_volume_music_ref = convar->getConVarByName("osu_volume_music");
-    if(m_osu_mod_fposu_ref == NULL) m_osu_mod_fposu_ref = convar->getConVarByName("osu_mod_fposu");
-    if(m_fposu_draw_scorebarbg_on_top_ref == NULL)
-        m_fposu_draw_scorebarbg_on_top_ref = convar->getConVarByName("fposu_draw_scorebarbg_on_top");
+    m_osu_pvs = &osu_pvs;
+    m_osu_draw_hitobjects_ref = &osu_draw_hitobjects;
+    m_osu_followpoints_prevfadetime_ref = &osu_followpoints_prevfadetime;
+    m_osu_universal_offset_ref = &osu_universal_offset;
+    m_osu_early_note_time_ref = &osu_early_note_time;
+    m_osu_fail_time_ref = &osu_fail_time;
+    m_osu_drain_type_ref = &osu_drain_type;
+    m_osu_draw_hud_ref = convar->getConVarByName("osu_draw_hud");
+    m_osu_draw_scorebarbg_ref = convar->getConVarByName("osu_draw_scorebarbg");
+    m_osu_hud_scorebar_hide_during_breaks_ref = convar->getConVarByName("osu_hud_scorebar_hide_during_breaks");
+    m_osu_drain_stable_hpbar_maximum_ref = convar->getConVarByName("osu_drain_stable_hpbar_maximum");
+    m_osu_volume_music_ref = convar->getConVarByName("osu_volume_music");
+    m_osu_mod_fposu_ref = convar->getConVarByName("osu_mod_fposu");
+    m_fposu_draw_scorebarbg_on_top_ref = convar->getConVarByName("fposu_draw_scorebarbg_on_top");
+    m_osu_draw_statistics_pp_ref = convar->getConVarByName("osu_draw_statistics_pp");
+    m_osu_draw_statistics_livestars_ref = convar->getConVarByName("osu_draw_statistics_livestars");
+    m_osu_mod_fullalternate_ref = convar->getConVarByName("osu_mod_fullalternate");
+    m_fposu_distance_ref = convar->getConVarByName("fposu_distance");
+    m_fposu_curved_ref = convar->getConVarByName("fposu_curved");
+    m_fposu_mod_strafing_ref = convar->getConVarByName("fposu_mod_strafing");
+    m_fposu_mod_strafing_frequency_x_ref = convar->getConVarByName("fposu_mod_strafing_frequency_x");
+    m_fposu_mod_strafing_frequency_y_ref = convar->getConVarByName("fposu_mod_strafing_frequency_y");
+    m_fposu_mod_strafing_frequency_z_ref = convar->getConVarByName("fposu_mod_strafing_frequency_z");
+    m_fposu_mod_strafing_strength_x_ref = convar->getConVarByName("fposu_mod_strafing_strength_x");
+    m_fposu_mod_strafing_strength_y_ref = convar->getConVarByName("fposu_mod_strafing_strength_y");
+    m_fposu_mod_strafing_strength_z_ref = convar->getConVarByName("fposu_mod_strafing_strength_z");
+    m_fposu_mod_3d_depthwobble_ref = convar->getConVarByName("fposu_mod_3d_depthwobble");
+    m_osu_slider_scorev2_ref = convar->getConVarByName("osu_slider_scorev2");
 
     // vars
     m_osu = osu;
@@ -283,6 +367,53 @@ OsuBeatmap::OsuBeatmap(Osu *osu) {
     m_iScoreV2ComboPortionMaximum = 0;
 
     m_iPreviousFollowPointObjectIndex = -1;
+
+    m_bIsSpinnerActive = false;
+
+    m_fPlayfieldRotation = 0.0f;
+    m_fScaleFactor = 1.0f;
+
+    m_fXMultiplier = 1.0f;
+    m_fNumberScale = 1.0f;
+    m_fHitcircleOverlapScale = 1.0f;
+    m_fRawHitcircleDiameter = 27.35f * 2.0f;
+    m_fHitcircleDiameter = 0.0f;
+    m_fSliderFollowCircleDiameter = 0.0f;
+    m_fRawSliderFollowCircleDiameter = 0.0f;
+
+    m_iAutoCursorDanceIndex = 0;
+
+    m_fAimStars = 0.0f;
+    m_fAimSliderFactor = 0.0f;
+    m_fSpeedStars = 0.0f;
+    m_fSpeedNotes = 0.0f;
+    m_starCacheLoader = new OsuBackgroundStarCacheLoader(this);
+    m_fStarCacheTime = 0.0f;
+
+    m_bWasHREnabled = false;
+    m_fPrevHitCircleDiameter = 0.0f;
+    m_bWasHorizontalMirrorEnabled = false;
+    m_bWasVerticalMirrorEnabled = false;
+    m_bWasEZEnabled = false;
+    m_bWasMafhamEnabled = false;
+    m_fPrevPlayfieldRotationFromConVar = 0.0f;
+    m_fPrevPlayfieldStretchX = 0.0f;
+    m_fPrevPlayfieldStretchY = 0.0f;
+    m_fPrevHitCircleDiameterForStarCache = 1.0f;
+    m_fPrevSpeedForStarCache = 1.0f;
+    m_bIsPreLoading = true;
+    m_iPreLoadingIndex = 0;
+
+    m_mafhamActiveRenderTarget = NULL;
+    m_mafhamFinishedRenderTarget = NULL;
+    m_bMafhamRenderScheduled = true;
+    m_iMafhamHitObjectRenderIndex = 0;
+    m_iMafhamPrevHitObjectIndex = 0;
+    m_iMafhamActiveRenderHitObjectIndex = 0;
+    m_iMafhamFinishedRenderHitObjectIndex = 0;
+    m_bInMafhamRenderChunk = false;
+
+    m_iMandalaIndex = 0;
 }
 
 OsuBeatmap::~OsuBeatmap() {
@@ -291,18 +422,16 @@ OsuBeatmap::~OsuBeatmap() {
     anim->deleteExistingAnimation(&m_fFailAnim);
 
     unloadObjects();
-}
-
-void OsuBeatmap::draw(Graphics *g) { drawInt(g); }
 
-void OsuBeatmap::drawInt(Graphics *g) {
-    if(!canDraw()) return;
+    m_starCacheLoader->kill();
 
-    // draw background
-    drawBackground(g);
+    if(engine->getResourceManager()->isLoadingResource(m_starCacheLoader)) {
+        while(!m_starCacheLoader->isAsyncReady()) {
+            // wait
+        }
+    }
 
-    // draw loading circle
-    if(isLoading()) m_osu->getHUD()->drawLoadingSmall(g);
+    engine->getResourceManager()->destroyResource(m_starCacheLoader);
 }
 
 void OsuBeatmap::drawDebug(Graphics *g) {
@@ -415,1901 +544,3623 @@ void OsuBeatmap::drawBackground(Graphics *g) {
     }
 }
 
-void OsuBeatmap::update() {
-    if(!canUpdate()) return;
-
-    long osu_universal_offset_hardcoded = convar->getConVarByName("osu_universal_offset_hardcoded")->getInt();
+void OsuBeatmap::onKeyDown(KeyboardEvent &e) {
+    if(e == KEY_O && engine->getKeyboard()->isControlDown()) {
+        m_osu->toggleOptionsMenu();
+        e.consume();
+    }
+}
 
-    if(m_bContinueScheduled) {
-        // If we paused while m_bIsWaiting (green progressbar), then we have to let the 'if (m_bIsWaiting)' block handle
-        // the sound play() call
-        bool isEarlyNoteContinue = (!m_bIsPaused && m_bIsWaiting);
-        if(m_bClickedContinue || isEarlyNoteContinue) {
-            m_bClickedContinue = false;
-            m_bContinueScheduled = false;
-            m_bIsPaused = false;
+void OsuBeatmap::skipEmptySection() {
+    if(!m_bIsInSkippableSection) return;
+    m_bIsInSkippableSection = false;
+    m_osu->m_chat->updateVisibility();
 
-            if(!isEarlyNoteContinue) {
-                engine->getSound()->play(m_music);
-            }
+    const float offset = 2500.0f;
+    float offsetMultiplier = m_osu->getSpeedMultiplier();
+    {
+        // only compensate if not within "normal" osu mod range (would make the game feel too different regarding time
+        // from skip until first hitobject)
+        if(offsetMultiplier >= 0.74f && offsetMultiplier <= 1.51f) offsetMultiplier = 1.0f;
 
-            m_bIsPlaying = true;  // usually this should be checked with the result of the above play() call, but since
-                                  // we are continuing we can assume that everything works
+        // don't compensate speed increases at all actually
+        if(offsetMultiplier > 1.0f) offsetMultiplier = 1.0f;
 
-            // for nightmare mod, to avoid a miss because of the continue click
-            {
-                m_clicks.clear();
-                m_keyUps.clear();
-            }
-        }
+        // and cap slowdowns at sane value (~ spinner fadein start)
+        if(offsetMultiplier <= 0.2f) offsetMultiplier = 0.2f;
     }
 
-    // handle restarts
-    if(m_bIsRestartScheduled) {
-        m_bIsRestartScheduled = false;
-        actualRestart();
-        return;
-    }
+    const long nextHitObjectDelta = m_iNextHitObjectTime - (long)m_iCurMusicPosWithOffsets;
 
-    // update current music position (this variable does not include any offsets!)
-    m_iCurMusicPos = getMusicPositionMSInterpolated();
-    m_iContinueMusicPos = m_music->getPositionMS();
-    const bool wasSeekFrame = m_bWasSeekFrame;
-    m_bWasSeekFrame = false;
+    if(!osu_end_skip.getBool() && nextHitObjectDelta < 0)
+        m_music->setPositionMS(std::max(m_music->getLengthMS(), (unsigned long)1) - 1);
+    else
+        m_music->setPositionMS(std::max(m_iNextHitObjectTime - (long)(offset * offsetMultiplier), (long)0));
 
-    // handle timewarp
-    if(osu_mod_timewarp.getBool()) {
-        if(m_hitobjects.size() > 0 && m_iCurMusicPos > m_hitobjects[0]->getTime()) {
-            const float percentFinished =
-                ((double)(m_iCurMusicPos - m_hitobjects[0]->getTime()) /
-                 (double)(m_hitobjects[m_hitobjects.size() - 1]->getTime() +
-                          m_hitobjects[m_hitobjects.size() - 1]->getDuration() - m_hitobjects[0]->getTime()));
-            float warp_multiplier = std::max(osu_mod_timewarp_multiplier.getFloat(), 1.f);
-            const float speed =
-                m_osu->getSpeedMultiplier() + percentFinished * m_osu->getSpeedMultiplier() * (warp_multiplier - 1.0f);
-            m_music->setSpeed(speed);
+    engine->getSound()->play(m_osu->getSkin()->getMenuHit());
+}
+
+void OsuBeatmap::keyPressed1(bool mouse) {
+    if(m_bContinueScheduled) m_bClickedContinue = !m_osu->getModSelector()->isMouseInside();
+
+    if(osu_mod_fullalternate.getBool() && m_bPrevKeyWasKey1) {
+        if(m_iCurrentHitObjectIndex > m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex) {
+            engine->getSound()->play(getSkin()->getCombobreak());
+            return;
         }
     }
 
-    // HACKHACK: clean this mess up
-    // waiting to start (file loading, retry)
-    // NOTE: this is dependent on being here AFTER m_iCurMusicPos has been set above, because it modifies it to fake a
-    // negative start (else everything would just freeze for the waiting period)
-    if(m_bIsWaiting) {
-        if(isLoading()) {
-            m_fWaitTime = engine->getTimeReal();
+    // key overlay & counter
+    m_osu->getHUD()->animateInputoverlay(mouse ? 3 : 1, true);
 
-            // if the first hitobject starts immediately, add artificial wait time before starting the music
-            if(!m_bIsRestartScheduledQuick && m_hitobjects.size() > 0) {
-                if(m_hitobjects[0]->getTime() < (long)osu_early_note_time.getInt())
-                    m_fWaitTime = engine->getTimeReal() + osu_early_note_time.getFloat() / 1000.0f;
-            }
-        } else {
-            if(engine->getTimeReal() > m_fWaitTime) {
-                if(!m_bIsPaused) {
-                    m_bIsWaiting = false;
-                    m_bIsPlaying = true;
+    if(m_bFailed) return;
 
-                    engine->getSound()->play(m_music);
-                    m_music->setPositionMS(0);
-                    m_music->setVolume(m_osu_volume_music_ref->getFloat());
-                    m_music->setSpeed(m_osu->getSpeedMultiplier());
+    if(!m_bInBreak && !m_bIsInSkippableSection && m_bIsPlaying) m_osu->getScore()->addKeyCount(mouse ? 3 : 1);
 
-                    // if we are quick restarting, jump just before the first hitobject (even if there is a long waiting
-                    // period at the beginning with nothing etc.)
-                    if(m_bIsRestartScheduledQuick && m_hitobjects.size() > 0 &&
-                       m_hitobjects[0]->getTime() > (long)osu_quick_retry_time.getInt())
-                        m_music->setPositionMS(
-                            std::max((long)0, m_hitobjects[0]->getTime() - (long)osu_quick_retry_time.getInt()));
+    m_bPrevKeyWasKey1 = true;
+    m_bClick1Held = true;
 
-                    m_bIsRestartScheduledQuick = false;
+    CLICK click;
+    click.musicPos = m_iCurMusicPosWithOffsets;
 
-                    onPlayStart();
-                }
-            } else
-                m_iCurMusicPos = (engine->getTimeReal() - m_fWaitTime) * 1000.0f * m_osu->getSpeedMultiplier();
-        }
+    if((!m_osu->getModAuto() && !m_osu->getModRelax()) || !osu_auto_and_relax_block_user_input.getBool())
+        m_clicks.push_back(click);
 
-        // ugh. force update all hitobjects while waiting (necessary because of pvs optimization)
-        long curPos = m_iCurMusicPos + (long)(osu_universal_offset.getFloat() * m_osu->getSpeedMultiplier()) +
-                      osu_universal_offset_hardcoded - m_selectedDifficulty2->getLocalOffset() -
-                      m_selectedDifficulty2->getOnlineOffset() -
-                      (m_selectedDifficulty2->getVersion() < 5 ? osu_old_beatmap_offset.getInt() : 0);
-        if(curPos > -1)  // otherwise auto would already click elements that start at exactly 0 (while the map has not
-                         // even started)
-            curPos = -1;
+    if(mouse) {
+        current_keys = current_keys | OsuReplay::M1;
+    } else {
+        current_keys = current_keys | OsuReplay::M1 | OsuReplay::K1;
+    }
+}
 
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            m_hitobjects[i]->update(curPos);
+void OsuBeatmap::keyPressed2(bool mouse) {
+    if(m_bContinueScheduled) m_bClickedContinue = !m_osu->getModSelector()->isMouseInside();
+
+    if(osu_mod_fullalternate.getBool() && !m_bPrevKeyWasKey1) {
+        if(m_iCurrentHitObjectIndex > m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex) {
+            engine->getSound()->play(getSkin()->getCombobreak());
+            return;
         }
     }
 
-    // only continue updating hitobjects etc. if we have loaded everything
-    if(isLoading()) return;
+    // key overlay & counter
+    m_osu->getHUD()->animateInputoverlay(mouse ? 4 : 2, true);
 
-    // handle music loading fail
-    if(!m_music->isReady()) {
-        m_iResourceLoadUpdateDelayHack++;  // HACKHACK: async loading takes 1 additional engine update() until both
-                                           // isAsyncReady() and isReady() return true
-        if(m_iResourceLoadUpdateDelayHack > 1 &&
-           !m_bForceStreamPlayback)  // first: try loading a stream version of the music file
-        {
-            m_bForceStreamPlayback = true;
-            unloadMusicInt();
-            loadMusic(true, m_bForceStreamPlayback);
+    if(m_bFailed) return;
 
-            // we are waiting for an asynchronous start of the beatmap in the next update()
-            m_bIsWaiting = true;
-            m_fWaitTime = engine->getTimeReal();
-        } else if(m_iResourceLoadUpdateDelayHack >
-                  3)  // second: if that still doesn't work, stop and display an error message
-        {
-            m_osu->getNotificationOverlay()->addNotification("Couldn't load music file :(", 0xffff0000);
-            stop(true);
-        }
-    }
+    if(!m_bInBreak && !m_bIsInSkippableSection && m_bIsPlaying) m_osu->getScore()->addKeyCount(mouse ? 4 : 2);
 
-    // detect and handle music end
-    if(!m_bIsWaiting && m_music->isReady()) {
-        const bool isMusicFinished = m_music->isFinished();
+    m_bPrevKeyWasKey1 = false;
+    m_bClick2Held = true;
 
-        // trigger virtual audio time after music finishes
-        if(!isMusicFinished)
-            m_fAfterMusicIsFinishedVirtualAudioTimeStart = -1.0f;
-        else if(m_fAfterMusicIsFinishedVirtualAudioTimeStart < 0.0f)
-            m_fAfterMusicIsFinishedVirtualAudioTimeStart = engine->getTimeReal();
+    CLICK click;
+    click.musicPos = m_iCurMusicPosWithOffsets;
 
-        if(isMusicFinished) {
-            // continue with virtual audio time until the last hitobject is done (plus sanity offset given via
-            // osu_end_delay_time) because some beatmaps have hitobjects going until >= the exact end of the music ffs
-            // NOTE: this overwrites m_iCurMusicPos for the rest of the update loop
-            m_iCurMusicPos = (long)m_music->getLengthMS() +
-                             (long)((engine->getTimeReal() - m_fAfterMusicIsFinishedVirtualAudioTimeStart) * 1000.0f);
-        }
+    if((!m_osu->getModAuto() && !m_osu->getModRelax()) || !osu_auto_and_relax_block_user_input.getBool())
+        m_clicks.push_back(click);
 
-        const bool hasAnyHitObjects = (m_hitobjects.size() > 0);
-        const bool isTimePastLastHitObjectPlusLenience =
-            (m_iCurMusicPos > (m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getTime() +
-                               m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getDuration() +
-                               (long)osu_end_delay_time.getInt()));
-        if(!hasAnyHitObjects || (osu_end_skip.getBool() && isTimePastLastHitObjectPlusLenience) ||
-           (!osu_end_skip.getBool() && isMusicFinished)) {
-            if(!m_bFailed) {
-                stop(false);
-                return;
-            }
-        }
+    if(mouse) {
+        current_keys = current_keys | OsuReplay::M2;
+    } else {
+        current_keys = current_keys | OsuReplay::M2 | OsuReplay::K2;
     }
+}
 
-    // update timing (points)
-    m_iCurMusicPosWithOffsets = m_iCurMusicPos + (long)(osu_universal_offset.getFloat() * m_osu->getSpeedMultiplier()) +
-                                osu_universal_offset_hardcoded - m_selectedDifficulty2->getLocalOffset() -
-                                m_selectedDifficulty2->getOnlineOffset() -
-                                (m_selectedDifficulty2->getVersion() < 5 ? osu_old_beatmap_offset.getInt() : 0);
-    updateTimingPoints(m_iCurMusicPosWithOffsets);
+void OsuBeatmap::keyReleased1(bool mouse) {
+    // key overlay
+    m_osu->getHUD()->animateInputoverlay(1, false);
+    m_osu->getHUD()->animateInputoverlay(3, false);
 
-    // for performance reasons, a lot of operations are crammed into 1 loop over all hitobjects:
-    // update all hitobjects,
-    // handle click events,
-    // also get the time of the next/previous hitobject and their indices for later,
-    // and get the current hitobject,
-    // also handle miss hiterrorbar slots,
-    // also calculate nps and nd,
-    // also handle note blocking
-    m_currentHitObject = NULL;
-    m_iNextHitObjectTime = 0;
-    m_iPreviousHitObjectTime = 0;
-    m_iPreviousFollowPointObjectIndex = 0;
-    m_iNPS = 0;
-    m_iND = 0;
-    m_iCurrentNumCircles = 0;
-    m_iCurrentNumSliders = 0;
-    m_iCurrentNumSpinners = 0;
-    {
-        bool blockNextNotes = false;
+    m_bClick1Held = false;
 
-        const long pvs =
-            !OsuGameRules::osu_mod_mafham.getBool()
-                ? getPVS()
-                : (m_hitobjects.size() > 0
-                       ? (m_hitobjects[clamp<int>(m_iCurrentHitObjectIndex +
-                                                      OsuGameRules::osu_mod_mafham_render_livesize.getInt() + 1,
-                                                  0, m_hitobjects.size() - 1)]
-                              ->getTime() -
-                          m_iCurMusicPosWithOffsets + 1500)
-                       : getPVS());
-        const bool usePVS = m_osu_pvs->getBool();
-
-        const int notelockType = osu_notelock_type.getInt();
-        const long tolerance2B = (long)osu_notelock_stable_tolerance2b.getInt();
+    current_keys = current_keys & ~(OsuReplay::M1 | OsuReplay::K1);
+}
 
-        m_iCurrentHitObjectIndex = 0;  // reset below here, since it's needed for mafham pvs
+void OsuBeatmap::keyReleased2(bool mouse) {
+    // key overlay
+    m_osu->getHUD()->animateInputoverlay(2, false);
+    m_osu->getHUD()->animateInputoverlay(4, false);
 
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            // the order must be like this:
-            // 0) miscellaneous stuff (minimal performance impact)
-            // 1) prev + next time vars
-            // 2) PVS optimization
-            // 3) main hitobject update
-            // 4) note blocking
-            // 5) click events
-            //
-            // (because the hitobjects need to know about note blocking before handling the click events)
+    m_bClick2Held = false;
 
-            // ************ live pp block start ************ //
-            const bool isCircle = m_hitobjects[i]->isCircle();
-            const bool isSlider = m_hitobjects[i]->isSlider();
-            const bool isSpinner = m_hitobjects[i]->isSpinner();
-            // ************ live pp block end ************** //
+    current_keys = current_keys & ~(OsuReplay::M2 | OsuReplay::K2);
+}
 
-            // determine previous & next object time, used for auto + followpoints + warning arrows + empty section
-            // skipping
-            if(m_iNextHitObjectTime == 0) {
-                if(m_hitobjects[i]->getTime() > m_iCurMusicPosWithOffsets)
-                    m_iNextHitObjectTime = m_hitobjects[i]->getTime();
-                else {
-                    m_currentHitObject = m_hitobjects[i];
-                    const long actualPrevHitObjectTime = m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration();
-                    m_iPreviousHitObjectTime = actualPrevHitObjectTime;
+void OsuBeatmap::select() {
+    // if possible, continue playing where we left off
+    if(m_music != NULL && (m_music->isPlaying())) m_iContinueMusicPos = m_music->getPositionMS();
 
-                    if(m_iCurMusicPosWithOffsets >
-                       actualPrevHitObjectTime + (long)osu_followpoints_prevfadetime.getFloat())
-                        m_iPreviousFollowPointObjectIndex = i;
-                }
-            }
+    selectDifficulty2(m_selectedDifficulty2);
 
-            // PVS optimization
-            if(usePVS) {
-                if(m_hitobjects[i]->isFinished() &&
-                   (m_iCurMusicPosWithOffsets - pvs >
-                    m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration()))  // past objects
-                {
-                    // ************ live pp block start ************ //
-                    if(isCircle) m_iCurrentNumCircles++;
-                    if(isSlider) m_iCurrentNumSliders++;
-                    if(isSpinner) m_iCurrentNumSpinners++;
+    loadMusic();
+    handlePreviewPlay();
+}
 
-                    m_iCurrentHitObjectIndex = i;
-                    // ************ live pp block end ************** //
+void OsuBeatmap::selectDifficulty2(OsuDatabaseBeatmap *difficulty2) {
+    if(difficulty2 != NULL) {
+        m_selectedDifficulty2 = difficulty2;
 
-                    continue;
-                }
-                if(m_hitobjects[i]->getTime() > m_iCurMusicPosWithOffsets + pvs)  // future objects
-                    break;
-            }
+        // need to recheck/reload the music here since every difficulty might be using a different sound file
+        loadMusic();
+        handlePreviewPlay();
+    }
 
-            // ************ live pp block start ************ //
-            if(m_iCurMusicPosWithOffsets >= m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration())
-                m_iCurrentHitObjectIndex = i;
-            // ************ live pp block end ************** //
+    if(osu_beatmap_preview_mods_live.getBool()) onModUpdate();
+}
 
-            // main hitobject update
-            m_hitobjects[i]->update(m_iCurMusicPosWithOffsets);
+void OsuBeatmap::deselect() {
+    m_iContinueMusicPos = 0;
 
-            // note blocking / notelock (1)
-            const OsuSlider *currentSliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[i]);
-            if(notelockType > 0) {
-                m_hitobjects[i]->setBlocked(blockNextNotes);
+    unloadObjects();
+}
 
-                if(notelockType == 1)  // McOsu
-                {
-                    // (nothing, handled in (2) block)
-                } else if(notelockType == 2)  // osu!stable
-                {
-                    if(!m_hitobjects[i]->isFinished()) {
-                        blockNextNotes = true;
+bool OsuBeatmap::play() {
+    if(m_selectedDifficulty2 == NULL) return false;
 
-                        // old implementation
-                        // sliders are "finished" after their startcircle
-                        /*
-                        {
-                                OsuSlider *sliderPointer = dynamic_cast<OsuSlider*>(m_hitobjects[i]);
+    static const int OSU_COORD_WIDTH = 512;
+    static const int OSU_COORD_HEIGHT = 384;
+    m_osu->flashlight_position = Vector2{OSU_COORD_WIDTH / 2, OSU_COORD_HEIGHT / 2};
 
-                                // sliders with finished startcircles do not block
-                                if (sliderPointer != NULL && sliderPointer->isStartCircleFinished())
-                                        blockNextNotes = false;
-                        }
-                        */
-
-                        // new implementation
-                        // sliders are "finished" after they end
-                        // extra handling for simultaneous/2b hitobjects, as these would now otherwise get blocked
-                        // completely NOTE: this will (same as the old implementation) still unlock some simultaneous/2b
-                        // patterns too early (slider slider circle [circle]), but nobody from that niche has complained
-                        // so far
-                        {
-                            const bool isSlider = (currentSliderPointer != NULL);
-                            const bool isSpinner = (!isSlider && !isCircle);
+    // reset everything, including deleting any previously loaded hitobjects from another diff which we might just have
+    // played
+    unloadObjects();
+    resetScore();
 
-                            if(isSlider || isSpinner) {
-                                if((i + 1) < m_hitobjects.size()) {
-                                    if((isSpinner || currentSliderPointer->isStartCircleFinished()) &&
-                                       (m_hitobjects[i + 1]->getTime() <=
-                                        (m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration() + tolerance2B)))
-                                        blockNextNotes = false;
-                                }
-                            }
-                        }
-                    }
-                } else if(notelockType == 3)  // osu!lazer 2020
-                {
-                    if(!m_hitobjects[i]->isFinished()) {
-                        const bool isSlider = (currentSliderPointer != NULL);
-                        const bool isSpinner = (!isSlider && !isCircle);
+    // some hitobjects already need this information to be up-to-date before their constructor is called
+    updatePlayfieldMetrics();
+    updateHitobjectMetrics();
+    m_bIsPreLoading = false;
 
-                        if(!isSpinner)  // spinners are completely ignored (transparent)
-                        {
-                            blockNextNotes = (m_iCurMusicPosWithOffsets <= m_hitobjects[i]->getTime());
+    // actually load the difficulty (and the hitobjects)
+    {
+        OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT result = OsuDatabaseBeatmap::loadGameplay(m_selectedDifficulty2, this);
+        if(result.errorCode != 0) {
+            switch(result.errorCode) {
+                case 1: {
+                    UString errorMessage = "Error: Couldn't load beatmap metadata :(";
+                    debugLog("Osu Error: Couldn't load beatmap metadata %s\n",
+                             m_selectedDifficulty2->getFilePath().c_str());
 
-                            // sliders are "finished" after their startcircle
-                            {
-                                // sliders with finished startcircles do not block
-                                if(currentSliderPointer != NULL && currentSliderPointer->isStartCircleFinished())
-                                    blockNextNotes = false;
-                            }
-                        }
-                    }
-                }
-            } else
-                m_hitobjects[i]->setBlocked(false);
+                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
+                } break;
 
-            // click events (this also handles hitsounds!)
-            const bool isCurrentHitObjectASliderAndHasItsStartCircleFinishedBeforeClickEvents =
-                (currentSliderPointer != NULL && currentSliderPointer->isStartCircleFinished());
-            const bool isCurrentHitObjectFinishedBeforeClickEvents = m_hitobjects[i]->isFinished();
-            {
-                if(m_clicks.size() > 0) m_hitobjects[i]->onClickEvent(m_clicks);
+                case 2: {
+                    UString errorMessage = "Error: Couldn't load beatmap file :(";
+                    debugLog("Osu Error: Couldn't load beatmap file %s\n",
+                             m_selectedDifficulty2->getFilePath().c_str());
 
-                if(m_keyUps.size() > 0) m_hitobjects[i]->onKeyUpEvent(m_keyUps);
-            }
-            const bool isCurrentHitObjectFinishedAfterClickEvents = m_hitobjects[i]->isFinished();
-            const bool isCurrentHitObjectASliderAndHasItsStartCircleFinishedAfterClickEvents =
-                (currentSliderPointer != NULL && currentSliderPointer->isStartCircleFinished());
+                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
+                } break;
 
-            // note blocking / notelock (2.1)
-            if(!isCurrentHitObjectASliderAndHasItsStartCircleFinishedBeforeClickEvents &&
-               isCurrentHitObjectASliderAndHasItsStartCircleFinishedAfterClickEvents) {
-                // in here if a slider had its startcircle clicked successfully in this update iteration
+                case 3: {
+                    UString errorMessage = "Error: No timingpoints in beatmap :(";
+                    debugLog("Osu Error: No timingpoints in beatmap %s\n",
+                             m_selectedDifficulty2->getFilePath().c_str());
 
-                if(notelockType == 2)  // osu!stable
-                {
-                    // edge case: frame perfect double tapping on overlapping sliders would incorrectly eat the second
-                    // input, because the isStartCircleFinished() 2b edge case check handling happens before
-                    // m_hitobjects[i]->onClickEvent(m_clicks); so, we check if the currentSliderPointer got its
-                    // isStartCircleFinished() within this m_hitobjects[i]->onClickEvent(m_clicks); and unlock
-                    // blockNextNotes if that is the case note that we still only unlock within duration + tolerance2B
-                    // (same as in (1))
-                    if((i + 1) < m_hitobjects.size()) {
-                        if((m_hitobjects[i + 1]->getTime() <=
-                            (m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration() + tolerance2B)))
-                            blockNextNotes = false;
-                    }
-                }
-            }
+                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
+                } break;
 
-            // note blocking / notelock (2.2)
-            if(!isCurrentHitObjectFinishedBeforeClickEvents && isCurrentHitObjectFinishedAfterClickEvents) {
-                // in here if a hitobject has been clicked (and finished completely) successfully in this update
-                // iteration
+                case 4: {
+                    UString errorMessage = "Error: No hitobjects in beatmap :(";
+                    debugLog("Osu Error: No hitobjects in beatmap %s\n", m_selectedDifficulty2->getFilePath().c_str());
 
-                blockNextNotes = false;
+                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
+                } break;
 
-                if(notelockType == 1)  // McOsu
-                {
-                    // auto miss all previous unfinished hitobjects, always
-                    // (can stop reverse iteration once we get to the first finished hitobject)
+                case 5: {
+                    UString errorMessage = "Error: Too many hitobjects in beatmap :(";
+                    debugLog("Osu Error: Too many hitobjects in beatmap %s\n",
+                             m_selectedDifficulty2->getFilePath().c_str());
 
-                    for(int m = i - 1; m >= 0; m--) {
-                        if(!m_hitobjects[m]->isFinished()) {
-                            const OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[m]);
+                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
+                } break;
+            }
 
-                            const bool isSlider = (sliderPointer != NULL);
-                            const bool isSpinner = (!isSlider && !isCircle);
+            return false;
+        }
 
-                            if(!isSpinner)  // spinners are completely ignored (transparent)
-                            {
-                                if(m_hitobjects[i]->getTime() >
-                                   (m_hitobjects[m]->getTime() +
-                                    m_hitobjects[m]->getDuration()))  // NOTE: 2b exception. only force miss if objects
-                                                                      // are not overlapping.
-                                    m_hitobjects[m]->miss(m_iCurMusicPosWithOffsets);
-                            }
-                        } else
-                            break;
-                    }
-                } else if(notelockType == 2)  // osu!stable
-                {
-                    // (nothing, handled in (1) and (2.1) blocks)
-                } else if(notelockType == 3)  // osu!lazer 2020
-                {
-                    // auto miss all previous unfinished hitobjects if the current music time is > their time (center)
-                    // (can stop reverse iteration once we get to the first finished hitobject)
+        // move temp result data into beatmap
+        m_iRandomSeed = result.randomSeed;
+        m_hitobjects = std::move(result.hitobjects);
+        m_breaks = std::move(result.breaks);
+        m_osu->getSkin()->setBeatmapComboColors(std::move(result.combocolors));  // update combo colors in skin
 
-                    for(int m = i - 1; m >= 0; m--) {
-                        if(!m_hitobjects[m]->isFinished()) {
-                            const OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[m]);
+        // load beatmap skin
+        m_osu->getSkin()->loadBeatmapOverride(m_selectedDifficulty2->getFolder());
+    }
 
-                            const bool isSlider = (sliderPointer != NULL);
-                            const bool isSpinner = (!isSlider && !isCircle);
+    // the drawing order is different from the playing/input order.
+    // for drawing, if multiple hitobjects occupy the exact same time (duration) then they get drawn on top of the
+    // active hitobject
+    m_hitobjectsSortedByEndTime = m_hitobjects;
 
-                            if(!isSpinner)  // spinners are completely ignored (transparent)
-                            {
-                                if(m_iCurMusicPosWithOffsets > m_hitobjects[m]->getTime()) {
-                                    if(m_hitobjects[i]->getTime() >
-                                       (m_hitobjects[m]->getTime() +
-                                        m_hitobjects[m]->getDuration()))  // NOTE: 2b exception. only force miss if
-                                                                          // objects are not overlapping.
-                                        m_hitobjects[m]->miss(m_iCurMusicPosWithOffsets);
-                                }
-                            }
-                        } else
-                            break;
-                    }
-                }
-            }
+    // sort hitobjects by endtime
+    struct HitObjectSortComparator {
+        bool operator()(OsuHitObject const *a, OsuHitObject const *b) const {
+            // strict weak ordering!
+            if((a->getTime() + a->getDuration()) == (b->getTime() + b->getDuration()))
+                return a->getSortHack() < b->getSortHack();
+            else
+                return (a->getTime() + a->getDuration()) < (b->getTime() + b->getDuration());
+        }
+    };
+    std::sort(m_hitobjectsSortedByEndTime.begin(), m_hitobjectsSortedByEndTime.end(), HitObjectSortComparator());
 
-            // ************ live pp block start ************ //
-            if(isCircle && m_hitobjects[i]->isFinished()) m_iCurrentNumCircles++;
-            if(isSlider && m_hitobjects[i]->isFinished()) m_iCurrentNumSliders++;
-            if(isSpinner && m_hitobjects[i]->isFinished()) m_iCurrentNumSpinners++;
+    // after the hitobjects have been loaded we can calculate the stacks
+    calculateStacks();
+    computeDrainRate();
 
-            if(m_hitobjects[i]->isFinished()) m_iCurrentHitObjectIndex = i;
-            // ************ live pp block end ************** //
+    // start preloading (delays the play start until it's set to false, see isLoading())
+    m_bIsPreLoading = true;
+    m_iPreLoadingIndex = 0;
 
-            // notes per second
-            const long npsHalfGateSizeMS = (long)(500.0f * getSpeedMultiplier());
-            if(m_hitobjects[i]->getTime() > m_iCurMusicPosWithOffsets - npsHalfGateSizeMS &&
-               m_hitobjects[i]->getTime() < m_iCurMusicPosWithOffsets + npsHalfGateSizeMS)
-                m_iNPS++;
+    // build stars
+    m_fStarCacheTime =
+        engine->getTime() +
+        osu_pp_live_timeout
+            .getFloat();  // first time delay only. subsequent updates should immediately show the loading spinner
+    updateStarCache();
 
-            // note density
-            if(m_hitobjects[i]->isVisible()) m_iND++;
-        }
+    // load music
+    unloadMusic();  // need to reload in case of speed/pitch changes (just to be sure)
+    loadMusic(false, m_bForceStreamPlayback);
 
-        // miss hiterrorbar slots
-        // this gets the closest previous unfinished hitobject, as well as all following hitobjects which are in 50
-        // range and could be clicked
-        if(osu_hiterrorbar_misaims.getBool()) {
-            m_misaimObjects.clear();
-            OsuHitObject *lastUnfinishedHitObject = NULL;
-            const long hitWindow50 = (long)OsuGameRules::getHitWindow50(this);
-            for(int i = 0; i < m_hitobjects.size(); i++)  // this shouldn't hurt performance too much, since no
-                                                          // expensive operations are happening within the loop
-            {
-                if(!m_hitobjects[i]->isFinished()) {
-                    if(m_iCurMusicPosWithOffsets >= m_hitobjects[i]->getTime())
-                        lastUnfinishedHitObject = m_hitobjects[i];
-                    else if(std::abs(m_hitobjects[i]->getTime() - m_iCurMusicPosWithOffsets) < hitWindow50)
-                        m_misaimObjects.push_back(m_hitobjects[i]);
-                    else
-                        break;
-                }
-            }
-            if(lastUnfinishedHitObject != NULL &&
-               std::abs(lastUnfinishedHitObject->getTime() - m_iCurMusicPosWithOffsets) < hitWindow50)
-                m_misaimObjects.insert(m_misaimObjects.begin(), lastUnfinishedHitObject);
+    m_music->setLoop(false);
+    m_bIsPaused = false;
+    m_bContinueScheduled = false;
 
-            // now, go through the remaining clicks, and go through the unfinished hitobjects.
-            // handle misaim clicks sequentially (setting the misaim flag on the hitobjects to only allow 1 entry in the
-            // hiterrorbar for misses per object) clicks don't have to be consumed here, as they are deleted below
-            // anyway
-            for(int c = 0; c < m_clicks.size(); c++) {
-                for(int i = 0; i < m_misaimObjects.size(); i++) {
-                    if(m_misaimObjects[i]->hasMisAimed())  // only 1 slot per object!
-                        continue;
+    m_bInBreak = osu_background_fade_after_load.getBool();
+    anim->deleteExistingAnimation(&m_fBreakBackgroundFade);
+    m_fBreakBackgroundFade = osu_background_fade_after_load.getBool() ? 1.0f : 0.0f;
+    m_iPreviousSectionPassFailTime = -1;
+    m_fShouldFlashSectionPass = 0.0f;
+    m_fShouldFlashSectionFail = 0.0f;
 
-                    m_misaimObjects[i]->misAimed();
-                    const long delta = (long)m_clicks[c].musicPos - (long)m_misaimObjects[i]->getTime();
-                    m_osu->getHUD()->addHitError(delta, false, true);
+    m_music->setPositionMS(0);
+    m_iCurMusicPos = 0;
 
-                    break;  // the current click has been dealt with (and the hitobject has been misaimed)
-                }
-            }
-        }
+    // we are waiting for an asynchronous start of the beatmap in the next update()
+    m_bIsWaiting = true;
+    m_fWaitTime = engine->getTimeReal();
 
-        // all remaining clicks which have not been consumed by any hitobjects can safely be deleted
-        if(m_clicks.size() > 0) {
-            if(osu_play_hitsound_on_click_while_playing.getBool()) m_osu->getSkin()->playHitCircleSound(0);
+    // NOTE: loading failures are handled dynamically in update(), so temporarily assume everything has worked in here
+    m_bIsPlaying = true;
+    return m_bIsPlaying;
+}
 
-            // nightmare mod: extra clicks = sliderbreak
-            if((m_osu->getModNightmare() || osu_mod_jigsaw1.getBool()) && !m_bIsInSkippableSection && !m_bInBreak &&
-               m_iCurrentHitObjectIndex > 0) {
-                addSliderBreak();
-                addHitResult(NULL, OsuScore::HIT::HIT_MISS_SLIDERBREAK, 0, false, true, true, true, true,
-                             false);  // only decrease health
-            }
+void OsuBeatmap::restart(bool quick) {
+    engine->getSound()->stop(getSkin()->getFailsound());
 
-            m_clicks.clear();
-        }
-        m_keyUps.clear();
-    }
+    if(!m_bIsWaiting) {
+        m_bIsRestartScheduled = true;
+        m_bIsRestartScheduledQuick = quick;
+    } else if(m_bIsPaused)
+        pause(false);
+}
 
-    // empty section detection & skipping
-    if(m_hitobjects.size() > 0) {
-        const long legacyOffset = (m_iPreviousHitObjectTime < m_hitobjects[0]->getTime() ? 0 : 1000);  // Mc
-        const long nextHitObjectDelta = m_iNextHitObjectTime - (long)m_iCurMusicPosWithOffsets;
-        if(nextHitObjectDelta > 0 && nextHitObjectDelta > (long)osu_skip_time.getInt() &&
-           m_iCurMusicPosWithOffsets > (m_iPreviousHitObjectTime + legacyOffset))
-            m_bIsInSkippableSection = true;
-        else if(!osu_end_skip.getBool() && nextHitObjectDelta < 0)
-            m_bIsInSkippableSection = true;
-        else
-            m_bIsInSkippableSection = false;
+void OsuBeatmap::actualRestart() {
+    // reset everything
+    resetScore();
+    resetHitObjects(-1000);
 
-        m_osu->m_chat->updateVisibility();
+    // we are waiting for an asynchronous start of the beatmap in the next update()
+    m_bIsWaiting = true;
+    m_fWaitTime = engine->getTimeReal();
 
-        // While we want to allow the chat to pop up during breaks, we don't
-        // want to be able to skip after the start in multiplayer rooms
-        if(bancho.is_playing_a_multi_map() && m_iCurrentHitObjectIndex > 0) {
-            m_bIsInSkippableSection = false;
+    // if the first hitobject starts immediately, add artificial wait time before starting the music
+    if(m_hitobjects.size() > 0) {
+        if(m_hitobjects[0]->getTime() < (long)osu_early_note_time.getInt()) {
+            m_bIsWaiting = true;
+            m_fWaitTime = engine->getTimeReal() + osu_early_note_time.getFloat() / 1000.0f;
         }
     }
 
-    // warning arrow logic
-    if(m_hitobjects.size() > 0) {
-        const long legacyOffset = (m_iPreviousHitObjectTime < m_hitobjects[0]->getTime() ? 0 : 1000);  // Mc
-        const long minGapSize = 1000;
-        const long lastVisibleMin = 400;
-        const long blinkDelta = 100;
+    // pause temporarily if playing
+    if(m_music->isPlaying()) engine->getSound()->pause(m_music);
 
-        const long gapSize = m_iNextHitObjectTime - (m_iPreviousHitObjectTime + legacyOffset);
-        const long nextDelta = (m_iNextHitObjectTime - m_iCurMusicPosWithOffsets);
-        const bool drawWarningArrows = gapSize > minGapSize && nextDelta > 0;
-        if(drawWarningArrows &&
-           ((nextDelta <= lastVisibleMin + blinkDelta * 13 && nextDelta > lastVisibleMin + blinkDelta * 12) ||
-            (nextDelta <= lastVisibleMin + blinkDelta * 11 && nextDelta > lastVisibleMin + blinkDelta * 10) ||
-            (nextDelta <= lastVisibleMin + blinkDelta * 9 && nextDelta > lastVisibleMin + blinkDelta * 8) ||
-            (nextDelta <= lastVisibleMin + blinkDelta * 7 && nextDelta > lastVisibleMin + blinkDelta * 6) ||
-            (nextDelta <= lastVisibleMin + blinkDelta * 5 && nextDelta > lastVisibleMin + blinkDelta * 4) ||
-            (nextDelta <= lastVisibleMin + blinkDelta * 3 && nextDelta > lastVisibleMin + blinkDelta * 2) ||
-            (nextDelta <= lastVisibleMin + blinkDelta * 1 && nextDelta > lastVisibleMin)))
-            m_bShouldFlashWarningArrows = true;
-        else
-            m_bShouldFlashWarningArrows = false;
-    }
+    // reset/restore frequency (from potential fail before)
+    m_music->setFrequency(0);
 
-    // break time detection, and background fade during breaks
-    const OsuDatabaseBeatmap::BREAK breakEvent =
-        getBreakForTimeRange(m_iPreviousHitObjectTime, m_iCurMusicPosWithOffsets, m_iNextHitObjectTime);
-    const bool isInBreak = ((int)m_iCurMusicPosWithOffsets >= breakEvent.startTime &&
-                            (int)m_iCurMusicPosWithOffsets <= breakEvent.endTime);
-    if(isInBreak != m_bInBreak) {
-        m_bInBreak = !m_bInBreak;
+    m_music->setLoop(false);
+    m_bIsPaused = false;
+    m_bContinueScheduled = false;
 
-        if(!osu_background_dont_fade_during_breaks.getBool() || m_fBreakBackgroundFade != 0.0f) {
-            if(m_bInBreak && !osu_background_dont_fade_during_breaks.getBool()) {
-                const int breakDuration = breakEvent.endTime - breakEvent.startTime;
-                if(breakDuration > (int)(osu_background_fade_min_duration.getFloat() * 1000.0f))
-                    anim->moveLinear(&m_fBreakBackgroundFade, 1.0f, osu_background_fade_in_duration.getFloat(), true);
-            } else
-                anim->moveLinear(&m_fBreakBackgroundFade, 0.0f, osu_background_fade_out_duration.getFloat(), true);
-        }
-    }
+    m_bInBreak = false;
+    anim->deleteExistingAnimation(&m_fBreakBackgroundFade);
+    m_fBreakBackgroundFade = 0.0f;
+    m_iPreviousSectionPassFailTime = -1;
+    m_fShouldFlashSectionPass = 0.0f;
+    m_fShouldFlashSectionFail = 0.0f;
 
-    // section pass/fail logic
-    if(m_hitobjects.size() > 0) {
-        const long minGapSize = 2880;
-        const long fadeStart = 1280;
-        const long fadeEnd = 1480;
+    onModUpdate();  // sanity
 
-        const long gapSize = m_iNextHitObjectTime - m_iPreviousHitObjectTime;
-        const long start =
-            (gapSize / 2 > minGapSize ? m_iPreviousHitObjectTime + (gapSize / 2) : m_iNextHitObjectTime - minGapSize);
-        const long nextDelta = m_iCurMusicPosWithOffsets - start;
-        const bool inSectionPassFail =
-            (gapSize > minGapSize && nextDelta > 0) && m_iCurMusicPosWithOffsets > m_hitobjects[0]->getTime() &&
-            m_iCurMusicPosWithOffsets <
-                (m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getTime() +
-                 m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getDuration()) &&
-            !m_bFailed && m_bInBreak && (breakEvent.endTime - breakEvent.startTime) > minGapSize;
+    // reset position
+    m_music->setPositionMS(0);
+    m_iCurMusicPos = 0;
 
-        const bool passing = (m_fHealth >= 0.5);
+    m_bIsPlaying = true;
+}
 
-        // draw logic
-        if(passing) {
-            if(inSectionPassFail && ((nextDelta <= fadeEnd && nextDelta >= 280) ||
-                                     (nextDelta <= 230 && nextDelta >= 160) || (nextDelta <= 100 && nextDelta >= 20))) {
-                const float fadeAlpha = 1.0f - (float)(nextDelta - fadeStart) / (float)(fadeEnd - fadeStart);
-                m_fShouldFlashSectionPass = (nextDelta > fadeStart ? fadeAlpha : 1.0f);
-            } else
-                m_fShouldFlashSectionPass = 0.0f;
-        } else {
-            if(inSectionPassFail &&
-               ((nextDelta <= fadeEnd && nextDelta >= 280) || (nextDelta <= 230 && nextDelta >= 130))) {
-                const float fadeAlpha = 1.0f - (float)(nextDelta - fadeStart) / (float)(fadeEnd - fadeStart);
-                m_fShouldFlashSectionFail = (nextDelta > fadeStart ? fadeAlpha : 1.0f);
-            } else
-                m_fShouldFlashSectionFail = 0.0f;
-        }
+void OsuBeatmap::pause(bool quitIfWaiting) {
+    if(m_selectedDifficulty2 == NULL) return;
 
-        // sound logic
-        if(inSectionPassFail) {
-            if(m_iPreviousSectionPassFailTime != start &&
-               ((passing && nextDelta >= 20) || (!passing && nextDelta >= 130))) {
-                m_iPreviousSectionPassFailTime = start;
+    const bool isFirstPause = !m_bContinueScheduled;
 
-                if(!wasSeekFrame) {
-                    if(passing)
-                        engine->getSound()->play(m_osu->getSkin()->getSectionPassSound());
-                    else
-                        engine->getSound()->play(m_osu->getSkin()->getSectionFailSound());
-                }
-            }
-        }
+    // NOTE: this assumes that no beatmap ever goes far beyond the end of the music
+    // NOTE: if pure virtual audio time is ever supported (playing without SoundEngine) then this needs to be adapted
+    // fix pausing after music ends breaking beatmap state (by just not allowing it to be paused)
+    if(m_fAfterMusicIsFinishedVirtualAudioTimeStart >= 0.0f) {
+        const float delta = engine->getTimeReal() - m_fAfterMusicIsFinishedVirtualAudioTimeStart;
+        if(delta < 5.0f)  // WARNING: sanity limit, always allow escaping after 5 seconds of overflow time
+            return;
     }
 
-    // hp drain & failing
-    if(osu_drain_type.getInt() > 1) {
-        const int drainType = osu_drain_type.getInt();
-
-        // handle constant drain
-        if(drainType == 2 || drainType == 3)  // osu!stable + osu!lazer 2020
-        {
-            if(m_fDrainRate > 0.0) {
-                if(m_bIsPlaying                  // not paused
-                   && !m_bInBreak                // not in a break
-                   && !m_bIsInSkippableSection)  // not in a skippable section
-                {
-                    // special case: break drain edge cases
-                    bool drainAfterLastHitobjectBeforeBreakStart = false;
-                    bool drainBeforeFirstHitobjectAfterBreakEnd = false;
-
-                    if(drainType == 2)  // osu!stable
-                    {
-                        drainAfterLastHitobjectBeforeBreakStart =
-                            (m_selectedDifficulty2->getVersion() < 8 ? osu_drain_stable_break_before_old.getBool()
-                                                                     : osu_drain_stable_break_before.getBool());
-                        drainBeforeFirstHitobjectAfterBreakEnd = osu_drain_stable_break_after.getBool();
-                    } else if(drainType == 3)  // osu!lazer 2020
-                    {
-                        drainAfterLastHitobjectBeforeBreakStart = osu_drain_lazer_break_before.getBool();
-                        drainBeforeFirstHitobjectAfterBreakEnd = osu_drain_lazer_break_after.getBool();
-                    }
-
-                    const bool isBetweenHitobjectsAndBreak = (int)m_iPreviousHitObjectTime <= breakEvent.startTime &&
-                                                             (int)m_iNextHitObjectTime >= breakEvent.endTime &&
-                                                             m_iCurMusicPosWithOffsets > m_iPreviousHitObjectTime;
-                    const bool isLastHitobjectBeforeBreakStart =
-                        isBetweenHitobjectsAndBreak && (int)m_iCurMusicPosWithOffsets <= breakEvent.startTime;
-                    const bool isFirstHitobjectAfterBreakEnd =
-                        isBetweenHitobjectsAndBreak && (int)m_iCurMusicPosWithOffsets >= breakEvent.endTime;
-
-                    if(!isBetweenHitobjectsAndBreak ||
-                       (drainAfterLastHitobjectBeforeBreakStart && isLastHitobjectBeforeBreakStart) ||
-                       (drainBeforeFirstHitobjectAfterBreakEnd && isFirstHitobjectAfterBreakEnd)) {
-                        // special case: spinner nerf
-                        double spinnerDrainNerf = 1.0;
-
-                        if(drainType == 2)  // osu!stable
-                        {
-                            OsuBeatmapStandard *standardPointer = dynamic_cast<OsuBeatmapStandard *>(this);
-                            if(standardPointer != NULL && standardPointer->isSpinnerActive())
-                                spinnerDrainNerf = (double)osu_drain_stable_spinner_nerf.getFloat();
-                        }
-
-                        addHealth(
-                            -m_fDrainRate * engine->getFrameTime() * (double)getSpeedMultiplier() * spinnerDrainNerf,
-                            false);
-                    }
-                }
+    if(m_bIsPlaying)  // if we are playing, aka if this is the first time pausing
+    {
+        if(m_bIsWaiting && quitIfWaiting)  // if we are still m_bIsWaiting, pausing the game via the escape key is the
+                                           // same as stopping playing
+            stop();
+        else {
+            // first time pause pauses the music
+            // case 1: the beatmap is already "finished", jump to the ranking screen if some small amount of time past
+            // the last objects endTime case 2: in the middle somewhere, pause as usual
+            OsuHitObject *lastHitObject = m_hitobjectsSortedByEndTime.size() > 0
+                                              ? m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]
+                                              : NULL;
+            if(lastHitObject != NULL && lastHitObject->isFinished() &&
+               (m_iCurMusicPos >
+                lastHitObject->getTime() + lastHitObject->getDuration() + (long)osu_end_skip_time.getInt()) &&
+               osu_end_skip.getBool())
+                stop(false);
+            else {
+                engine->getSound()->pause(m_music);
+                m_bIsPlaying = false;
+                m_bIsPaused = true;
             }
         }
-
-        // handle generic fail state (1) (see addHealth())
-        {
-            bool hasFailed = false;
-
-            switch(drainType) {
-                case 2:  // osu!stable
-                    hasFailed = (m_fHealth < 0.001) && osu_drain_stable_passive_fail.getBool();
-                    break;
-
-                case 3:  // osu!lazer 2020
-                    hasFailed = (m_fHealth < 0.001) && osu_drain_lazer_passive_fail.getBool();
-                    break;
-
-                default:
-                    hasFailed = (m_fHealth < 0.001);
-                    break;
+    } else if(m_bIsPaused && !m_bContinueScheduled) {  // if this is the first time unpausing
+        if(m_osu->getModAuto() || m_osu->getModAutopilot() || m_bIsInSkippableSection || m_bIsWatchingReplay) {
+            if(!m_bIsWaiting) {  // only force play() if we were not early waiting
+                engine->getSound()->play(m_music);
             }
 
-            if(hasFailed && !m_osu->getModNF()) fail();
+            m_bIsPlaying = true;
+            m_bIsPaused = false;
+        } else {  // otherwise, schedule a continue (wait for user to click, handled in update())
+            // first time unpause schedules a continue
+            m_bIsPaused = false;
+            m_bContinueScheduled = true;
         }
+    } else  // if this is not the first time pausing/unpausing, then just toggle the pause state (the visibility of the
+            // pause menu is handled in the Osu class, a bit shit)
+        m_bIsPaused = !m_bIsPaused;
 
-        // revive in mp
-        if(m_fHealth > 0.999 && m_osu->getScore()->isDead()) m_osu->getScore()->setDead(false);
-
-        // handle fail animation
-        if(m_bFailed) {
-            if(m_fFailAnim <= 0.0f) {
-                if(m_music->isPlaying() || !m_osu->getPauseMenu()->isVisible()) {
-                    engine->getSound()->pause(m_music);
-                    m_bIsPaused = true;
+    if(m_bIsPaused) onPaused(isFirstPause);
 
-                    m_osu->getPauseMenu()->setVisible(true);
-                    m_osu->updateConfineCursor();
-                }
-            } else
-                m_music->setFrequency(
-                    m_fMusicFrequencyBackup * m_fFailAnim > 100 ? m_fMusicFrequencyBackup * m_fFailAnim : 100);
-        }
-    }
+    // if we have failed, and the user early exits to the pause menu, stop the failing animation
+    if(m_bFailed) anim->deleteExistingAnimation(&m_fFailAnim);
 }
 
-void OsuBeatmap::onKeyDown(KeyboardEvent &e) {
-    if(e == KEY_O && engine->getKeyboard()->isControlDown()) {
-        m_osu->toggleOptionsMenu();
-        e.consume();
+void OsuBeatmap::pausePreviewMusic(bool toggle) {
+    if(m_music != NULL) {
+        if(m_music->isPlaying())
+            engine->getSound()->pause(m_music);
+        else if(toggle)
+            engine->getSound()->play(m_music);
     }
 }
 
-void OsuBeatmap::onKeyUp(KeyboardEvent &e) {
-    // nothing
-}
-
-void OsuBeatmap::skipEmptySection() {
-    if(!m_bIsInSkippableSection) return;
-    m_bIsInSkippableSection = false;
-    m_osu->m_chat->updateVisibility();
+bool OsuBeatmap::isPreviewMusicPlaying() {
+    if(m_music != NULL) return m_music->isPlaying();
 
-    const float offset = 2500.0f;
-    float offsetMultiplier = m_osu->getSpeedMultiplier();
-    {
-        // only compensate if not within "normal" osu mod range (would make the game feel too different regarding time
-        // from skip until first hitobject)
-        if(offsetMultiplier >= 0.74f && offsetMultiplier <= 1.51f) offsetMultiplier = 1.0f;
+    return false;
+}
 
-        // don't compensate speed increases at all actually
-        if(offsetMultiplier > 1.0f) offsetMultiplier = 1.0f;
+void OsuBeatmap::stop(bool quit) {
+    if(m_selectedDifficulty2 == NULL) return;
 
-        // and cap slowdowns at sane value (~ spinner fadein start)
-        if(offsetMultiplier <= 0.2f) offsetMultiplier = 0.2f;
-    }
+    if(getSkin()->getFailsound()->isPlaying()) engine->getSound()->stop(getSkin()->getFailsound());
 
-    const long nextHitObjectDelta = m_iNextHitObjectTime - (long)m_iCurMusicPosWithOffsets;
+    m_currentHitObject = NULL;
 
-    if(!osu_end_skip.getBool() && nextHitObjectDelta < 0)
-        m_music->setPositionMS(std::max(m_music->getLengthMS(), (unsigned long)1) - 1);
-    else
-        m_music->setPositionMS(std::max(m_iNextHitObjectTime - (long)(offset * offsetMultiplier), (long)0));
+    m_bIsPlaying = false;
+    m_bIsPaused = false;
+    m_bContinueScheduled = false;
 
-    engine->getSound()->play(m_osu->getSkin()->getMenuHit());
-}
+    onBeforeStop(quit);
 
-void OsuBeatmap::keyPressed1(bool mouse) {
-    if(m_bContinueScheduled) m_bClickedContinue = !m_osu->getModSelector()->isMouseInside();
+    unloadObjects();
 
-    if(osu_mod_fullalternate.getBool() && m_bPrevKeyWasKey1) {
-        if(m_iCurrentHitObjectIndex > m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex) {
-            engine->getSound()->play(getSkin()->getCombobreak());
-            return;
+    if(bancho.is_playing_a_multi_map()) {
+        if(quit) {
+            m_osu->onPlayEnd(true);
+            m_osu->m_room->ragequit();
+        } else {
+            m_osu->m_room->onClientScoreChange(true);
+            Packet packet;
+            packet.id = FINISH_MATCH;
+            send_packet(packet);
         }
+    } else {
+        m_osu->onPlayEnd(quit);
     }
+}
 
-    // key overlay & counter
-    m_osu->getHUD()->animateInputoverlay(mouse ? 3 : 1, true);
-
+void OsuBeatmap::fail() {
     if(m_bFailed) return;
 
-    if(!m_bInBreak && !m_bIsInSkippableSection && m_bIsPlaying) m_osu->getScore()->addKeyCount(mouse ? 3 : 1);
-
-    m_bPrevKeyWasKey1 = true;
-    m_bClick1Held = true;
-
-    CLICK click;
-    click.musicPos = m_iCurMusicPosWithOffsets;
-
-    if((!m_osu->getModAuto() && !m_osu->getModRelax()) || !osu_auto_and_relax_block_user_input.getBool())
-        m_clicks.push_back(click);
+    // Change behavior of relax mod when online
+    if(bancho.is_online() && m_osu->getModRelax()) return;
 
-    if(mouse) {
-        current_keys = current_keys | OsuReplay::M1;
-    } else {
-        current_keys = current_keys | OsuReplay::M1 | OsuReplay::K1;
-    }
-}
+    if(!bancho.is_playing_a_multi_map() && osu_drain_kill.getBool()) {
+        engine->getSound()->play(getSkin()->getFailsound());
 
-void OsuBeatmap::keyPressed2(bool mouse) {
-    if(m_bContinueScheduled) m_bClickedContinue = !m_osu->getModSelector()->isMouseInside();
+        m_bFailed = true;
+        m_fFailAnim = 1.0f;
+        anim->moveLinear(&m_fFailAnim, 0.0f, osu_fail_time.getFloat(),
+                         true);  // trigger music slowdown and delayed menu, see update()
+    } else if(!m_osu->getScore()->isDead()) {
+        anim->deleteExistingAnimation(&m_fHealth2);
+        m_fHealth = 0.0;
+        m_fHealth2 = 0.0f;
 
-    if(osu_mod_fullalternate.getBool() && !m_bPrevKeyWasKey1) {
-        if(m_iCurrentHitObjectIndex > m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex) {
-            engine->getSound()->play(getSkin()->getCombobreak());
-            return;
+        if(osu_drain_kill_notification_duration.getFloat() > 0.0f) {
+            if(!m_osu->getScore()->hasDied())
+                m_osu->getNotificationOverlay()->addNotification("You have failed, but you can keep playing!",
+                                                                 0xffffffff, false,
+                                                                 osu_drain_kill_notification_duration.getFloat());
         }
     }
 
-    // key overlay & counter
-    m_osu->getHUD()->animateInputoverlay(mouse ? 4 : 2, true);
+    if(!m_osu->getScore()->isDead()) m_osu->getScore()->setDead(true);
+}
 
-    if(m_bFailed) return;
+void OsuBeatmap::cancelFailing() {
+    if(!m_bFailed || m_fFailAnim <= 0.0f) return;
 
-    if(!m_bInBreak && !m_bIsInSkippableSection && m_bIsPlaying) m_osu->getScore()->addKeyCount(mouse ? 4 : 2);
+    m_bFailed = false;
 
-    m_bPrevKeyWasKey1 = false;
-    m_bClick2Held = true;
+    anim->deleteExistingAnimation(&m_fFailAnim);
+    m_fFailAnim = 1.0f;
 
-    CLICK click;
-    click.musicPos = m_iCurMusicPosWithOffsets;
+    if(m_music != NULL) m_music->setFrequency(0.0f);
 
-    if((!m_osu->getModAuto() && !m_osu->getModRelax()) || !osu_auto_and_relax_block_user_input.getBool())
-        m_clicks.push_back(click);
-
-    if(mouse) {
-        current_keys = current_keys | OsuReplay::M2;
-    } else {
-        current_keys = current_keys | OsuReplay::M2 | OsuReplay::K2;
-    }
+    if(getSkin()->getFailsound()->isPlaying()) engine->getSound()->stop(getSkin()->getFailsound());
 }
 
-void OsuBeatmap::keyReleased1(bool mouse) {
-    // key overlay
-    m_osu->getHUD()->animateInputoverlay(1, false);
-    m_osu->getHUD()->animateInputoverlay(3, false);
-
-    m_bClick1Held = false;
+void OsuBeatmap::setVolume(float volume) {
+    if(m_music != NULL) m_music->setVolume(volume);
+}
 
-    current_keys = current_keys & ~(OsuReplay::M1 | OsuReplay::K1);
+void OsuBeatmap::setSpeed(float speed) {
+    if(m_music != NULL) m_music->setSpeed(speed);
 }
 
-void OsuBeatmap::keyReleased2(bool mouse) {
-    // key overlay
-    m_osu->getHUD()->animateInputoverlay(2, false);
-    m_osu->getHUD()->animateInputoverlay(4, false);
+void OsuBeatmap::seekPercent(double percent) {
+    if(m_selectedDifficulty2 == NULL || (!m_bIsPlaying && !m_bIsPaused) || m_music == NULL || m_bFailed) return;
 
-    m_bClick2Held = false;
+    m_bWasSeekFrame = true;
+    m_fWaitTime = 0.0f;
 
-    current_keys = current_keys & ~(OsuReplay::M2 | OsuReplay::K2);
-}
+    m_music->setPosition(percent);
+    m_music->setVolume(m_osu_volume_music_ref->getFloat());
+    m_music->setSpeed(m_osu->getSpeedMultiplier());
 
-void OsuBeatmap::select() {
-    // if possible, continue playing where we left off
-    if(m_music != NULL && (m_music->isPlaying())) m_iContinueMusicPos = m_music->getPositionMS();
+    resetHitObjects(m_music->getPositionMS());
+    resetScore();
 
-    selectDifficulty2(m_selectedDifficulty2);
+    m_iPreviousSectionPassFailTime = -1;
 
-    loadMusic();
-    handlePreviewPlay();
-}
+    if(m_bIsWaiting) {
+        m_bIsWaiting = false;
+        m_bIsPlaying = true;
+        m_bIsRestartScheduledQuick = false;
 
-void OsuBeatmap::selectDifficulty2(OsuDatabaseBeatmap *difficulty2) {
-    if(difficulty2 != NULL) {
-        m_selectedDifficulty2 = difficulty2;
+        engine->getSound()->play(m_music);
 
-        // need to recheck/reload the music here since every difficulty might be using a different sound file
-        loadMusic();
-        handlePreviewPlay();
+        onPlayStart();
     }
 
-    if(osu_beatmap_preview_mods_live.getBool()) onModUpdate();
+    debugLog("Disabling score submission due to seeking\n");
+    vanilla = false;
 }
 
-void OsuBeatmap::deselect() {
-    m_iContinueMusicPos = 0;
+void OsuBeatmap::seekPercentPlayable(double percent) {
+    if(m_selectedDifficulty2 == NULL || (!m_bIsPlaying && !m_bIsPaused) || m_music == NULL || m_bFailed) return;
 
-    unloadObjects();
-}
+    m_bWasSeekFrame = true;
+    m_fWaitTime = 0.0f;
 
-bool OsuBeatmap::play() {
-    if(m_selectedDifficulty2 == NULL) return false;
+    double actualPlayPercent = percent;
+    if(m_hitobjects.size() > 0)
+        actualPlayPercent = (((double)m_hitobjects[m_hitobjects.size() - 1]->getTime() +
+                              (double)m_hitobjects[m_hitobjects.size() - 1]->getDuration()) *
+                             percent) /
+                            (double)m_music->getLengthMS();
 
-    static const int OSU_COORD_WIDTH = 512;
-    static const int OSU_COORD_HEIGHT = 384;
-    m_osu->flashlight_position = Vector2{OSU_COORD_WIDTH / 2, OSU_COORD_HEIGHT / 2};
-    m_osu->holding_slider = false;
+    seekPercent(actualPlayPercent);
+}
 
-    // reset everything, including deleting any previously loaded hitobjects from another diff which we might just have
-    // played
-    unloadObjects();
-    resetScore();
+unsigned long OsuBeatmap::getTime() const {
+    if(m_music != NULL && m_music->isAsyncReady())
+        return m_music->getPositionMS();
+    else
+        return 0;
+}
 
-    onBeforeLoad();
+unsigned long OsuBeatmap::getStartTimePlayable() const {
+    if(m_hitobjects.size() > 0)
+        return (unsigned long)m_hitobjects[0]->getTime();
+    else
+        return 0;
+}
 
-    // actually load the difficulty (and the hitobjects)
-    {
-        OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT result = OsuDatabaseBeatmap::loadGameplay(m_selectedDifficulty2, this);
-        if(result.errorCode != 0) {
-            switch(result.errorCode) {
-                case 1: {
-                    UString errorMessage = "Error: Couldn't load beatmap metadata :(";
-                    debugLog("Osu Error: Couldn't load beatmap metadata %s\n",
-                             m_selectedDifficulty2->getFilePath().c_str());
+unsigned long OsuBeatmap::getLength() const {
+    if(m_music != NULL && m_music->isAsyncReady())
+        return m_music->getLengthMS();
+    else if(m_selectedDifficulty2 != NULL)
+        return m_selectedDifficulty2->getLengthMS();
+    else
+        return 0;
+}
 
-                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
-                } break;
+unsigned long OsuBeatmap::getLengthPlayable() const {
+    if(m_hitobjects.size() > 0)
+        return (unsigned long)((m_hitobjects[m_hitobjects.size() - 1]->getTime() +
+                                m_hitobjects[m_hitobjects.size() - 1]->getDuration()) -
+                               m_hitobjects[0]->getTime());
+    else
+        return getLength();
+}
 
-                case 2: {
-                    UString errorMessage = "Error: Couldn't load beatmap file :(";
-                    debugLog("Osu Error: Couldn't load beatmap file %s\n",
-                             m_selectedDifficulty2->getFilePath().c_str());
+float OsuBeatmap::getPercentFinished() const {
+    if(m_music != NULL)
+        return (float)m_iCurMusicPos / (float)m_music->getLengthMS();
+    else
+        return 0.0f;
+}
 
-                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
-                } break;
+float OsuBeatmap::getPercentFinishedPlayable() const {
+    if(m_bIsWaiting) return 1.0f - (m_fWaitTime - engine->getTimeReal()) / (osu_early_note_time.getFloat() / 1000.0f);
 
-                case 3: {
-                    UString errorMessage = "Error: No timingpoints in beatmap :(";
-                    debugLog("Osu Error: No timingpoints in beatmap %s\n",
-                             m_selectedDifficulty2->getFilePath().c_str());
+    if(m_hitobjects.size() > 0)
+        return (float)m_iCurMusicPos / ((float)m_hitobjects[m_hitobjects.size() - 1]->getTime() +
+                                        (float)m_hitobjects[m_hitobjects.size() - 1]->getDuration());
+    else
+        return (float)m_iCurMusicPos / (float)m_music->getLengthMS();
+}
 
-                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
-                } break;
+int OsuBeatmap::getMostCommonBPM() const {
+    if(m_selectedDifficulty2 != NULL) {
+        if(m_music != NULL)
+            return (int)(m_selectedDifficulty2->getMostCommonBPM() * m_music->getSpeed());
+        else
+            return (int)(m_selectedDifficulty2->getMostCommonBPM() * m_osu->getSpeedMultiplier());
+    } else
+        return 0;
+}
 
-                case 4: {
-                    UString errorMessage = "Error: No hitobjects in beatmap :(";
-                    debugLog("Osu Error: No hitobjects in beatmap %s\n", m_selectedDifficulty2->getFilePath().c_str());
+float OsuBeatmap::getSpeedMultiplier() const {
+    if(m_music != NULL)
+        return std::max(m_music->getSpeed(), 0.05f);
+    else
+        return 1.0f;
+}
 
-                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
-                } break;
+OsuSkin *OsuBeatmap::getSkin() const { return m_osu->getSkin(); }
 
-                case 5: {
-                    UString errorMessage = "Error: Too many hitobjects in beatmap :(";
-                    debugLog("Osu Error: Too many hitobjects in beatmap %s\n",
-                             m_selectedDifficulty2->getFilePath().c_str());
+float OsuBeatmap::getRawAR() const {
+    if(m_selectedDifficulty2 == NULL) return 5.0f;
 
-                    if(m_osu != NULL) m_osu->getNotificationOverlay()->addNotification(errorMessage, 0xffff0000);
-                } break;
-            }
+    return clamp<float>(m_selectedDifficulty2->getAR() * m_osu->getDifficultyMultiplier(), 0.0f, 10.0f);
+}
 
-            return false;
-        }
+float OsuBeatmap::getAR() const {
+    if(m_selectedDifficulty2 == NULL) return 5.0f;
 
-        // move temp result data into beatmap
-        m_iRandomSeed = result.randomSeed;
-        m_hitobjects = std::move(result.hitobjects);
-        m_breaks = std::move(result.breaks);
-        m_osu->getSkin()->setBeatmapComboColors(std::move(result.combocolors));  // update combo colors in skin
+    float AR = getRawAR();
 
-        // load beatmap skin
-        m_osu->getSkin()->loadBeatmapOverride(m_selectedDifficulty2->getFolder());
-    }
+    if(osu_ar_override.getFloat() >= 0.0f) AR = osu_ar_override.getFloat();
 
-    // the drawing order is different from the playing/input order.
-    // for drawing, if multiple hitobjects occupy the exact same time (duration) then they get drawn on top of the
-    // active hitobject
-    m_hitobjectsSortedByEndTime = m_hitobjects;
+    if(osu_ar_overridenegative.getFloat() < 0.0f) AR = osu_ar_overridenegative.getFloat();
 
-    // sort hitobjects by endtime
-    struct HitObjectSortComparator {
-        bool operator()(OsuHitObject const *a, OsuHitObject const *b) const {
-            // strict weak ordering!
-            if((a->getTime() + a->getDuration()) == (b->getTime() + b->getDuration()))
-                return a->getSortHack() < b->getSortHack();
-            else
-                return (a->getTime() + a->getDuration()) < (b->getTime() + b->getDuration());
-        }
-    };
-    std::sort(m_hitobjectsSortedByEndTime.begin(), m_hitobjectsSortedByEndTime.end(), HitObjectSortComparator());
+    if(osu_ar_override_lock.getBool())
+        AR = OsuGameRules::getRawConstantApproachRateForSpeedMultiplier(
+            OsuGameRules::getRawApproachTime(AR),
+            (m_music != NULL && m_bIsPlaying ? getSpeedMultiplier() : m_osu->getSpeedMultiplier()));
 
-    onLoad();
+    if(osu_mod_artimewarp.getBool() && m_hitobjects.size() > 0) {
+        const float percent =
+            1.0f - ((double)(m_iCurMusicPos - m_hitobjects[0]->getTime()) /
+                    (double)(m_hitobjects[m_hitobjects.size() - 1]->getTime() +
+                             m_hitobjects[m_hitobjects.size() - 1]->getDuration() - m_hitobjects[0]->getTime())) *
+                       (1.0f - osu_mod_artimewarp_multiplier.getFloat());
+        AR *= percent;
+    }
 
-    // load music
-    unloadMusicInt();  // need to reload in case of speed/pitch changes (just to be sure)
-    loadMusic(false, m_bForceStreamPlayback);
+    if(osu_mod_arwobble.getBool())
+        AR += std::sin((m_iCurMusicPos / 1000.0f) * osu_mod_arwobble_interval.getFloat()) *
+              osu_mod_arwobble_strength.getFloat();
 
-    m_music->setLoop(false);
-    m_bIsPaused = false;
-    m_bContinueScheduled = false;
+    return AR;
+}
 
-    m_bInBreak = osu_background_fade_after_load.getBool();
-    anim->deleteExistingAnimation(&m_fBreakBackgroundFade);
-    m_fBreakBackgroundFade = osu_background_fade_after_load.getBool() ? 1.0f : 0.0f;
-    m_iPreviousSectionPassFailTime = -1;
-    m_fShouldFlashSectionPass = 0.0f;
-    m_fShouldFlashSectionFail = 0.0f;
+float OsuBeatmap::getCS() const {
+    if(m_selectedDifficulty2 == NULL) return 5.0f;
 
-    m_music->setPositionMS(0);
-    m_iCurMusicPos = 0;
+    float CS = clamp<float>(m_selectedDifficulty2->getCS() * m_osu->getCSDifficultyMultiplier(), 0.0f, 10.0f);
 
-    // we are waiting for an asynchronous start of the beatmap in the next update()
-    m_bIsWaiting = true;
-    m_fWaitTime = engine->getTimeReal();
+    if(osu_cs_override.getFloat() >= 0.0f) CS = osu_cs_override.getFloat();
 
-    // NOTE: loading failures are handled dynamically in update(), so temporarily assume everything has worked in here
-    m_bIsPlaying = true;
-    return m_bIsPlaying;
-}
+    if(osu_cs_overridenegative.getFloat() < 0.0f) CS = osu_cs_overridenegative.getFloat();
 
-void OsuBeatmap::restart(bool quick) {
-    engine->getSound()->stop(getSkin()->getFailsound());
+    if(osu_mod_minimize.getBool() && m_hitobjects.size() > 0) {
+        if(m_hitobjects.size() > 0) {
+            const float percent =
+                1.0f + ((double)(m_iCurMusicPos - m_hitobjects[0]->getTime()) /
+                        (double)(m_hitobjects[m_hitobjects.size() - 1]->getTime() +
+                                 m_hitobjects[m_hitobjects.size() - 1]->getDuration() - m_hitobjects[0]->getTime())) *
+                           osu_mod_minimize_multiplier.getFloat();
+            CS *= percent;
+        }
+    }
 
-    if(!m_bIsWaiting) {
-        m_bIsRestartScheduled = true;
-        m_bIsRestartScheduledQuick = quick;
-    } else if(m_bIsPaused)
-        pause(false);
+    if(osu_cs_cap_sanity.getBool()) CS = std::min(CS, 12.1429f);
+
+    return CS;
 }
 
-void OsuBeatmap::actualRestart() {
-    // reset everything
-    resetScore();
-    resetHitObjects(-1000);
+float OsuBeatmap::getHP() const {
+    if(m_selectedDifficulty2 == NULL) return 5.0f;
 
-    // we are waiting for an asynchronous start of the beatmap in the next update()
-    m_bIsWaiting = true;
-    m_fWaitTime = engine->getTimeReal();
+    float HP = clamp<float>(m_selectedDifficulty2->getHP() * m_osu->getDifficultyMultiplier(), 0.0f, 10.0f);
+    if(osu_hp_override.getFloat() >= 0.0f) HP = osu_hp_override.getFloat();
 
-    // if the first hitobject starts immediately, add artificial wait time before starting the music
-    if(m_hitobjects.size() > 0) {
-        if(m_hitobjects[0]->getTime() < (long)osu_early_note_time.getInt()) {
-            m_bIsWaiting = true;
-            m_fWaitTime = engine->getTimeReal() + osu_early_note_time.getFloat() / 1000.0f;
-        }
+    return HP;
+}
+
+float OsuBeatmap::getRawOD() const {
+    if(m_selectedDifficulty2 == NULL) return 5.0f;
+
+    return clamp<float>(m_selectedDifficulty2->getOD() * m_osu->getDifficultyMultiplier(), 0.0f, 10.0f);
+}
+
+float OsuBeatmap::getOD() const {
+    float OD = getRawOD();
+
+    if(osu_od_override.getFloat() >= 0.0f) OD = osu_od_override.getFloat();
+
+    if(osu_od_override_lock.getBool())
+        OD = OsuGameRules::getRawConstantOverallDifficultyForSpeedMultiplier(
+            OsuGameRules::getRawHitWindow300(OD),
+            (m_music != NULL && m_bIsPlaying ? getSpeedMultiplier() : m_osu->getSpeedMultiplier()));
+
+    return OD;
+}
+
+bool OsuBeatmap::isKey1Down() {
+    if(m_bIsWatchingReplay) {
+        return current_keys & (OsuReplay::M1 | OsuReplay::K1);
+    } else {
+        return m_bClick1Held;
     }
+}
 
-    // pause temporarily if playing
-    if(m_music->isPlaying()) engine->getSound()->pause(m_music);
+bool OsuBeatmap::isKey2Down() {
+    if(m_bIsWatchingReplay) {
+        return current_keys & (OsuReplay::M2 | OsuReplay::K2);
+    } else {
+        return m_bClick2Held;
+    }
+}
 
-    // reset/restore frequency (from potential fail before)
-    m_music->setFrequency(0);
+bool OsuBeatmap::isClickHeld() {
+    if(m_bIsWatchingReplay) {
+        return current_keys & (OsuReplay::M1 | OsuReplay::K1 | OsuReplay::M2 | OsuReplay::K2);
+    } else {
+        return m_bClick1Held || m_bClick2Held;
+    }
+}
 
-    m_music->setLoop(false);
-    m_bIsPaused = false;
-    m_bContinueScheduled = false;
+bool OsuBeatmap::isLastKeyDownKey1() {
+    if(m_bIsWatchingReplay) {
+        return last_keys & (OsuReplay::M1 | OsuReplay::K1);
+    } else {
+        return m_bPrevKeyWasKey1;
+    }
+}
 
-    m_bInBreak = false;
-    anim->deleteExistingAnimation(&m_fBreakBackgroundFade);
-    m_fBreakBackgroundFade = 0.0f;
-    m_iPreviousSectionPassFailTime = -1;
-    m_fShouldFlashSectionPass = 0.0f;
-    m_fShouldFlashSectionFail = 0.0f;
+UString OsuBeatmap::getTitle() const {
+    if(m_selectedDifficulty2 != NULL)
+        return m_selectedDifficulty2->getTitle();
+    else
+        return "NULL";
+}
 
-    onModUpdate();  // sanity
+UString OsuBeatmap::getArtist() const {
+    if(m_selectedDifficulty2 != NULL)
+        return m_selectedDifficulty2->getArtist();
+    else
+        return "NULL";
+}
 
-    // reset position
-    m_music->setPositionMS(0);
-    m_iCurMusicPos = 0;
+unsigned long OsuBeatmap::getBreakDurationTotal() const {
+    unsigned long breakDurationTotal = 0;
+    for(int i = 0; i < m_breaks.size(); i++) {
+        breakDurationTotal += (unsigned long)(m_breaks[i].endTime - m_breaks[i].startTime);
+    }
 
-    m_bIsPlaying = true;
+    return breakDurationTotal;
 }
 
-void OsuBeatmap::pause(bool quitIfWaiting) {
-    if(m_selectedDifficulty2 == NULL) return;
+OsuDatabaseBeatmap::BREAK OsuBeatmap::getBreakForTimeRange(long startMS, long positionMS, long endMS) const {
+    OsuDatabaseBeatmap::BREAK curBreak;
 
-    const bool isFirstPause = !m_bContinueScheduled;
+    curBreak.startTime = -1;
+    curBreak.endTime = -1;
 
-    // NOTE: this assumes that no beatmap ever goes far beyond the end of the music
-    // NOTE: if pure virtual audio time is ever supported (playing without SoundEngine) then this needs to be adapted
-    // fix pausing after music ends breaking beatmap state (by just not allowing it to be paused)
-    if(m_fAfterMusicIsFinishedVirtualAudioTimeStart >= 0.0f) {
-        const float delta = engine->getTimeReal() - m_fAfterMusicIsFinishedVirtualAudioTimeStart;
-        if(delta < 5.0f)  // WARNING: sanity limit, always allow escaping after 5 seconds of overflow time
-            return;
+    for(int i = 0; i < m_breaks.size(); i++) {
+        if(m_breaks[i].startTime >= (int)startMS && m_breaks[i].endTime <= (int)endMS) {
+            if((int)positionMS >= curBreak.startTime) curBreak = m_breaks[i];
+        }
     }
 
-    if(m_bIsPlaying)  // if we are playing, aka if this is the first time pausing
+    return curBreak;
+}
+
+OsuScore::HIT OsuBeatmap::addHitResult(OsuHitObject *hitObject, OsuScore::HIT hit, long delta, bool isEndOfCombo,
+                                       bool ignoreOnHitErrorBar, bool hitErrorBarOnly, bool ignoreCombo,
+                                       bool ignoreScore, bool ignoreHealth) {
+    // Frames are already written on every keypress/release.
+    // For some edge cases, we need to write extra frames to avoid replaybugs.
     {
-        if(m_bIsWaiting && quitIfWaiting)  // if we are still m_bIsWaiting, pausing the game via the escape key is the
-                                           // same as stopping playing
-            stop();
-        else {
-            // first time pause pauses the music
-            // case 1: the beatmap is already "finished", jump to the ranking screen if some small amount of time past
-            // the last objects endTime case 2: in the middle somewhere, pause as usual
-            OsuHitObject *lastHitObject = m_hitobjectsSortedByEndTime.size() > 0
-                                              ? m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]
-                                              : NULL;
-            if(lastHitObject != NULL && lastHitObject->isFinished() &&
-               (m_iCurMusicPos >
-                lastHitObject->getTime() + lastHitObject->getDuration() + (long)osu_end_skip_time.getInt()) &&
-               osu_end_skip.getBool())
-                stop(false);
-            else {
-                engine->getSound()->pause(m_music);
-                m_bIsPlaying = false;
-                m_bIsPaused = true;
-            }
+        bool should_write_frame = false;
+
+        // Slider interactions
+        // Surely buzz sliders won't be an issue... Clueless
+        should_write_frame |= (hit == OsuScore::HIT::HIT_SLIDER10);
+        should_write_frame |= (hit == OsuScore::HIT::HIT_SLIDER30);
+        should_write_frame |= (hit == OsuScore::HIT::HIT_MISS_SLIDERBREAK);
+
+        // Relax: no keypresses, instead we write on every hitresult
+        if(m_osu->getModRelax()) {
+            should_write_frame |= (hit == OsuScore::HIT::HIT_50);
+            should_write_frame |= (hit == OsuScore::HIT::HIT_100);
+            should_write_frame |= (hit == OsuScore::HIT::HIT_300);
+            should_write_frame |= (hit == OsuScore::HIT::HIT_MISS);
         }
-    } else if(m_bIsPaused && !m_bContinueScheduled) {  // if this is the first time unpausing
-        if(m_osu->getModAuto() || m_osu->getModAutopilot() || m_bIsInSkippableSection || m_bIsWatchingReplay) {
-            if(!m_bIsWaiting) {  // only force play() if we were not early waiting
-                engine->getSound()->play(m_music);
-            }
 
-            m_bIsPlaying = true;
-            m_bIsPaused = false;
-        } else {  // otherwise, schedule a continue (wait for user to click, handled in update())
-            // first time unpause schedules a continue
-            m_bIsPaused = false;
-            m_bContinueScheduled = true;
+        OsuBeatmap *beatmap = (OsuBeatmap *)m_osu->getSelectedBeatmap();
+        if(should_write_frame && !hitErrorBarOnly && beatmap != nullptr) {
+            beatmap->write_frame();
+        }
+    }
+
+    // handle perfect & sudden death
+    if(m_osu->getModSS()) {
+        if(!hitErrorBarOnly && hit != OsuScore::HIT::HIT_300 && hit != OsuScore::HIT::HIT_300G &&
+           hit != OsuScore::HIT::HIT_SLIDER10 && hit != OsuScore::HIT::HIT_SLIDER30 &&
+           hit != OsuScore::HIT::HIT_SPINNERSPIN && hit != OsuScore::HIT::HIT_SPINNERBONUS) {
+            restart();
+            return OsuScore::HIT::HIT_MISS;
+        }
+    } else if(m_osu->getModSD()) {
+        if(hit == OsuScore::HIT::HIT_MISS) {
+            if(osu_mod_suddendeath_restart.getBool() && !bancho.is_in_a_multi_room())
+                restart();
+            else
+                fail();
+
+            return OsuScore::HIT::HIT_MISS;
+        }
+    }
+
+    // miss sound
+    if(hit == OsuScore::HIT::HIT_MISS) playMissSound();
+
+    // score
+    m_osu->getScore()->addHitResult(this, hitObject, hit, delta, ignoreOnHitErrorBar, hitErrorBarOnly, ignoreCombo,
+                                    ignoreScore);
+
+    // health
+    OsuScore::HIT returnedHit = OsuScore::HIT::HIT_MISS;
+    if(!ignoreHealth) {
+        addHealth(m_osu->getScore()->getHealthIncrease(this, hit), true);
+
+        // geki/katu handling
+        if(isEndOfCombo) {
+            const int comboEndBitmask = m_osu->getScore()->getComboEndBitmask();
+
+            if(comboEndBitmask == 0) {
+                returnedHit = OsuScore::HIT::HIT_300G;
+                addHealth(m_osu->getScore()->getHealthIncrease(this, returnedHit), true);
+                m_osu->getScore()->addHitResultComboEnd(returnedHit);
+            } else if((comboEndBitmask & 2) == 0) {
+                switch(hit) {
+                    case OsuScore::HIT::HIT_100:
+                        returnedHit = OsuScore::HIT::HIT_100K;
+                        addHealth(m_osu->getScore()->getHealthIncrease(this, returnedHit), true);
+                        m_osu->getScore()->addHitResultComboEnd(returnedHit);
+                        break;
+
+                    case OsuScore::HIT::HIT_300:
+                        returnedHit = OsuScore::HIT::HIT_300K;
+                        addHealth(m_osu->getScore()->getHealthIncrease(this, returnedHit), true);
+                        m_osu->getScore()->addHitResultComboEnd(returnedHit);
+                        break;
+                }
+            } else if(hit != OsuScore::HIT::HIT_MISS)
+                addHealth(m_osu->getScore()->getHealthIncrease(this, OsuScore::HIT::HIT_MU), true);
+
+            m_osu->getScore()->setComboEndBitmask(0);
+        }
+    }
+
+    return returnedHit;
+}
+
+void OsuBeatmap::addSliderBreak() {
+    // handle perfect & sudden death
+    if(m_osu->getModSS()) {
+        restart();
+        return;
+    } else if(m_osu->getModSD()) {
+        if(osu_mod_suddendeath_restart.getBool())
+            restart();
+        else
+            fail();
+
+        return;
+    }
+
+    // miss sound
+    playMissSound();
+
+    // score
+    m_osu->getScore()->addSliderBreak();
+}
+
+void OsuBeatmap::addScorePoints(int points, bool isSpinner) { m_osu->getScore()->addPoints(points, isSpinner); }
+
+void OsuBeatmap::addHealth(double percent, bool isFromHitResult) {
+    const int drainType = osu_drain_type.getInt();
+    if(drainType < 2) return;
+
+    // never drain before first hitobject
+    if(m_hitobjects.size() > 0 && m_iCurMusicPosWithOffsets < m_hitobjects[0]->getTime()) return;
+
+    // never drain after last hitobject
+    if(m_hitobjectsSortedByEndTime.size() > 0 &&
+       m_iCurMusicPosWithOffsets > (m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getTime() +
+                                    m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getDuration()))
+        return;
+
+    if(m_bFailed) {
+        anim->deleteExistingAnimation(&m_fHealth2);
+
+        m_fHealth = 0.0;
+        m_fHealth2 = 0.0f;
+
+        return;
+    }
+
+    if(isFromHitResult && percent > 0.0) {
+        m_osu->getHUD()->animateKiBulge();
+
+        if(m_fHealth > 0.9) m_osu->getHUD()->animateKiExplode();
+    }
+
+    m_fHealth = clamp<double>(m_fHealth + percent, 0.0, 1.0);
+
+    // handle generic fail state (2)
+    const bool isDead = m_fHealth < 0.001;
+    if(isDead && !m_osu->getModNF()) {
+        if(m_osu->getModEZ() && m_osu->getScore()->getNumEZRetries() > 0)  // retries with ez
+        {
+            m_osu->getScore()->setNumEZRetries(m_osu->getScore()->getNumEZRetries() - 1);
+
+            // special case: set health to 160/200 (osu!stable behavior, seems fine for all drains)
+            m_fHealth = osu_drain_stable_hpbar_recovery.getFloat() / m_osu_drain_stable_hpbar_maximum_ref->getFloat();
+            m_fHealth2 = (float)m_fHealth;
+
+            anim->deleteExistingAnimation(&m_fHealth2);
+        } else if(isFromHitResult && percent < 0.0)  // judgement fail
+        {
+            switch(drainType) {
+                case 2:  // osu!stable
+                    if(!osu_drain_stable_passive_fail.getBool()) fail();
+                    break;
+
+                case 3:  // osu!lazer 2020
+                    if(!osu_drain_lazer_passive_fail.getBool()) fail();
+                    break;
+
+                case 4:  // osu!lazer 2018
+                    fail();
+                    break;
+            }
+        }
+    }
+}
+
+void OsuBeatmap::updateTimingPoints(long curPos) {
+    if(curPos < 0) return;  // aspire pls >:(
+
+    /// debugLog("updateTimingPoints( %ld )\n", curPos);
+
+    const OsuDatabaseBeatmap::TIMING_INFO t =
+        m_selectedDifficulty2->getTimingInfoForTime(curPos + (long)osu_timingpoints_offset.getInt());
+    m_osu->getSkin()->setSampleSet(
+        t.sampleType);  // normal/soft/drum is stored in the sample type! the sample set number is for custom sets
+    m_osu->getSkin()->setSampleVolume(clamp<float>(t.volume / 100.0f, 0.0f, 1.0f));
+}
+
+long OsuBeatmap::getPVS() {
+    // this is an approximation with generous boundaries, it doesn't need to be exact (just good enough to filter 10000
+    // hitobjects down to a few hundred or so) it will be used in both positive and negative directions (previous and
+    // future hitobjects) to speed up loops which iterate over all hitobjects
+    return OsuGameRules::getApproachTime(this) + OsuGameRules::getFadeInTime() +
+           (long)OsuGameRules::getHitWindowMiss(this) + 1500;  // sanity
+}
+
+bool OsuBeatmap::canDraw() {
+    if(!m_bIsPlaying && !m_bIsPaused && !m_bContinueScheduled && !m_bIsWaiting) return false;
+    if(m_selectedDifficulty2 == NULL || m_music == NULL)  // sanity check
+        return false;
+
+    return true;
+}
+
+bool OsuBeatmap::canUpdate() {
+    if(!m_bIsPlaying && !m_bIsPaused && !m_bContinueScheduled) return false;
+
+    if(m_osu->getInstanceID() > 1) {
+        m_music = engine->getResourceManager()->getSound("OSU_BEATMAP_MUSIC");
+        if(m_music == NULL) return false;
+    }
+
+    return true;
+}
+
+void OsuBeatmap::handlePreviewPlay() {
+    if(m_music != NULL && (!m_music->isPlaying() || m_music->getPosition() > 0.95f) && m_selectedDifficulty2 != NULL) {
+        // this is an assumption, but should be good enough for most songs
+        // reset playback position when the song has nearly reached the end (when the user switches back to the results
+        // screen or the songbrowser after playing)
+        if(m_music->getPosition() > 0.95f) m_iContinueMusicPos = 0;
+
+        engine->getSound()->stop(m_music);
+
+        if(engine->getSound()->play(m_music)) {
+            if(m_music->getFrequency() < m_fMusicFrequencyBackup)  // player has died, reset frequency
+                m_music->setFrequency(m_fMusicFrequencyBackup);
+
+            if(m_osu->getMainMenu()->isVisible())
+                m_music->setPositionMS(0);
+            else if(m_iContinueMusicPos != 0)
+                m_music->setPositionMS(m_iContinueMusicPos);
+            else
+                m_music->setPositionMS(m_selectedDifficulty2->getPreviewTime() < 0
+                                           ? (unsigned long)(m_music->getLengthMS() * 0.40f)
+                                           : m_selectedDifficulty2->getPreviewTime());
+
+            m_music->setVolume(m_osu_volume_music_ref->getFloat());
+            m_music->setSpeed(m_osu->getSpeedMultiplier());
+        }
+    }
+
+    // always loop during preview
+    if(m_music != NULL) m_music->setLoop(osu_beatmap_preview_music_loop.getBool());
+}
+
+void OsuBeatmap::loadMusic(bool stream, bool prescan) {
+    if(m_osu->getInstanceID() > 1) {
+        m_music = engine->getResourceManager()->getSound("OSU_BEATMAP_MUSIC");
+        return;
+    }
+
+    stream = stream || m_bForceStreamPlayback;
+    m_iResourceLoadUpdateDelayHack = 0;
+
+    // load the song (again)
+    if(m_selectedDifficulty2 != NULL &&
+       (m_music == NULL || m_selectedDifficulty2->getFullSoundFilePath() != m_music->getFilePath() ||
+        !m_music->isReady())) {
+        unloadMusic();
+
+        // if it's not a stream then we are loading the entire song into memory for playing
+        if(!stream) engine->getResourceManager()->requestNextLoadAsync();
+
+        m_music = engine->getResourceManager()->loadSoundAbs(
+            m_selectedDifficulty2->getFullSoundFilePath(), "OSU_BEATMAP_MUSIC", stream, false, false, false,
+            m_bForceStreamPlayback &&
+                prescan);  // m_bForceStreamPlayback = prescan necessary! otherwise big mp3s will go out of sync
+        m_music->setVolume(m_osu_volume_music_ref->getFloat());
+        m_fMusicFrequencyBackup = m_music->getFrequency();
+        m_music->setSpeed(m_osu->getSpeedMultiplier());
+    }
+}
+
+void OsuBeatmap::unloadMusic() {
+    if(m_osu->getInstanceID() < 2) {
+        engine->getSound()->stop(m_music);
+        engine->getResourceManager()->destroyResource(m_music);
+    }
+
+    m_music = NULL;
+}
+
+void OsuBeatmap::unloadObjects() {
+    for(int i = 0; i < m_hitobjects.size(); i++) {
+        delete m_hitobjects[i];
+    }
+    m_hitobjects = std::vector<OsuHitObject *>();
+    m_hitobjectsSortedByEndTime = std::vector<OsuHitObject *>();
+    m_misaimObjects = std::vector<OsuHitObject *>();
+
+    m_breaks = std::vector<OsuDatabaseBeatmap::BREAK>();
+
+    m_clicks = std::vector<CLICK>();
+    m_keyUps = std::vector<CLICK>();
+}
+
+void OsuBeatmap::resetHitObjects(long curPos) {
+    for(int i = 0; i < m_hitobjects.size(); i++) {
+        m_hitobjects[i]->onReset(curPos);
+        m_hitobjects[i]->update(curPos);  // fgt
+        m_hitobjects[i]->onReset(curPos);
+    }
+    m_osu->getHUD()->resetHitErrorBar();
+}
+
+void OsuBeatmap::resetScore() {
+    vanilla = convar->isVanilla();
+
+    replay.clear();
+    replay.push_back(OsuReplay::Frame{
+        .cur_music_pos = -1,
+        .milliseconds_since_last_frame = 0,
+        .x = 256,
+        .y = -500,
+        .key_flags = 0,
+    });
+    replay.push_back(OsuReplay::Frame{
+        .cur_music_pos = -1,
+        .milliseconds_since_last_frame = -1,
+        .x = 256,
+        .y = -500,
+        .key_flags = 0,
+    });
+
+    last_event_time = engine->getTimeReal();
+    last_event_ms = 0;
+    current_keys = 0;
+    last_keys = 0;
+    m_iCurMusicPos = 0;
+    m_iCurMusicPosWithOffsets = 0;
+
+    m_fHealth = 1.0;
+    m_fHealth2 = 1.0f;
+    m_bFailed = false;
+    m_fFailAnim = 1.0f;
+    anim->deleteExistingAnimation(&m_fFailAnim);
+
+    m_osu->getScore()->reset();
+    m_osu->holding_slider = false;
+    m_osu->m_hud->resetScoreboard();
+
+    m_bIsFirstMissSound = true;
+}
+
+void OsuBeatmap::playMissSound() {
+    if((m_bIsFirstMissSound && m_osu->getScore()->getCombo() > 0) ||
+       m_osu->getScore()->getCombo() > osu_combobreak_sound_combo.getInt()) {
+        m_bIsFirstMissSound = false;
+        engine->getSound()->play(getSkin()->getCombobreak());
+    }
+}
+
+unsigned long OsuBeatmap::getMusicPositionMSInterpolated() {
+    if(!osu_interpolate_music_pos.getBool() || isLoading())
+        return m_music->getPositionMS();
+    else {
+        const double interpolationMultiplier = 1.0;
+
+        // TODO: fix snapping at beginning for maps with instant start
+
+        unsigned long returnPos = 0;
+        const double curPos = (double)m_music->getPositionMS();
+        const float speed = m_music->getSpeed();
+
+        // not reinventing the wheel, the interpolation magic numbers here are (c) peppy
+
+        const double realTime = engine->getTimeReal();
+        const double interpolationDelta = (realTime - m_fLastRealTimeForInterpolationDelta) * 1000.0 * speed;
+        const double interpolationDeltaLimit =
+            ((realTime - m_fLastAudioTimeAccurateSet) * 1000.0 < 1500 || speed < 1.0f ? 11 : 33) *
+            interpolationMultiplier;
+
+        if(m_music->isPlaying() && !m_bWasSeekFrame) {
+            double newInterpolatedPos = m_fInterpolatedMusicPos + interpolationDelta;
+            double delta = newInterpolatedPos - curPos;
+
+            // debugLog("delta = %ld\n", (long)delta);
+
+            // approach and recalculate delta
+            newInterpolatedPos -= delta / 8.0 / interpolationMultiplier;
+            delta = newInterpolatedPos - curPos;
+
+            if(std::abs(delta) > interpolationDeltaLimit * 2)  // we're fucked, snap back to curPos
+            {
+                m_fInterpolatedMusicPos = (double)curPos;
+            } else if(delta < -interpolationDeltaLimit)  // undershot
+            {
+                m_fInterpolatedMusicPos += interpolationDelta * 2;
+                m_fLastAudioTimeAccurateSet = realTime;
+            } else if(delta < interpolationDeltaLimit)  // normal
+            {
+                m_fInterpolatedMusicPos = newInterpolatedPos;
+            } else  // overshot
+            {
+                m_fInterpolatedMusicPos += interpolationDelta / 2;
+                m_fLastAudioTimeAccurateSet = realTime;
+            }
+
+            // calculate final return value
+            returnPos = (unsigned long)std::round(m_fInterpolatedMusicPos);
+
+            bool nightcoring = m_osu->getModNC() || m_osu->getModDC();
+            if(speed < 1.0f && osu_compensate_music_speed.getBool() && !nightcoring) {
+                returnPos += (unsigned long)(((1.0f - speed) / 0.75f) * 5);
+            }
+        } else  // no interpolation
+        {
+            returnPos = curPos;
+            m_fInterpolatedMusicPos = (unsigned long)returnPos;
+            m_fLastAudioTimeAccurateSet = realTime;
+        }
+
+        m_fLastRealTimeForInterpolationDelta =
+            realTime;  // this is more accurate than engine->getFrameTime() for the delta calculation, since it
+                       // correctly handles all possible delays inbetween
+
+        // debugLog("returning %lu \n", returnPos);
+        // debugLog("delta = %lu\n", (long)returnPos - m_iCurMusicPos);
+        // debugLog("raw delta = %ld\n", (long)returnPos - (long)curPos);
+
+        return returnPos;
+    }
+}
+
+void OsuBeatmap::draw(Graphics *g) {
+    if(!canDraw()) return;
+
+    // draw background
+    drawBackground(g);
+
+    // draw loading circle
+    if(isLoading()) m_osu->getHUD()->drawLoadingSmall(g);
+
+    if(isLoadingStarCache() && engine->getTime() > m_fStarCacheTime) {
+        float progressPercent = 0.0f;
+        if(m_hitobjects.size() > 0)
+            progressPercent = (float)m_starCacheLoader->getProgress() / (float)m_hitobjects.size();
+
+        g->setColor(0x44ffffff);
+        UString loadingMessage =
+            UString::format("Calculating stars for realtime pp/stars (%i%%) ...", (int)(progressPercent * 100.0f));
+        UString loadingMessage2 = "(To get rid of this delay, disable [Draw Statistics: pp/Stars***])";
+        g->pushTransform();
+        {
+            g->translate(
+                (int)(m_osu->getScreenWidth() / 2 - m_osu->getSubTitleFont()->getStringWidth(loadingMessage) / 2),
+                m_osu->getScreenHeight() - m_osu->getSubTitleFont()->getHeight() - 25);
+            g->drawString(m_osu->getSubTitleFont(), loadingMessage);
+        }
+        g->popTransform();
+        g->pushTransform();
+        {
+            g->translate(
+                (int)(m_osu->getScreenWidth() / 2 - m_osu->getSubTitleFont()->getStringWidth(loadingMessage2) / 2),
+                m_osu->getScreenHeight() - 15);
+            g->drawString(m_osu->getSubTitleFont(), loadingMessage2);
+        }
+        g->popTransform();
+    } else if(bancho.is_playing_a_multi_map() && !bancho.room.all_players_loaded) {
+        if(!m_bIsPreLoading && !isLoadingStarCache())  // usability
+        {
+            g->setColor(0x44ffffff);
+            UString loadingMessage = "Waiting for players ...";
+            g->pushTransform();
+            {
+                g->translate(
+                    (int)(m_osu->getScreenWidth() / 2 - m_osu->getSubTitleFont()->getStringWidth(loadingMessage) / 2),
+                    m_osu->getScreenHeight() - m_osu->getSubTitleFont()->getHeight() - 15);
+                g->drawString(m_osu->getSubTitleFont(), loadingMessage);
+            }
+            g->popTransform();
+        }
+    }
+
+    if(isLoading()) return;  // only start drawing the rest of the playfield if everything has loaded
+
+    // draw playfield border
+    if(osu_draw_playfield_border.getBool() && !OsuGameRules::osu_mod_fps.getBool())
+        m_osu->getHUD()->drawPlayfieldBorder(g, m_vPlayfieldCenter, m_vPlayfieldSize, m_fHitcircleDiameter);
+
+    // draw hiterrorbar
+    if(!m_osu_mod_fposu_ref->getBool()) m_osu->getHUD()->drawHitErrorBar(g, this);
+
+    // draw first person crosshair
+    if(OsuGameRules::osu_mod_fps.getBool()) {
+        const int length = 15;
+        Vector2 center =
+            osuCoords2Pixels(Vector2(OsuGameRules::OSU_COORD_WIDTH / 2, OsuGameRules::OSU_COORD_HEIGHT / 2));
+        g->setColor(0xff777777);
+        g->drawLine(center.x, (int)(center.y - length), center.x, (int)(center.y + length + 1));
+        g->drawLine((int)(center.x - length), center.y, (int)(center.x + length + 1), center.y);
+    }
+
+    // draw followpoints
+    if(osu_draw_followpoints.getBool() && !OsuGameRules::osu_mod_mafham.getBool()) drawFollowPoints(g);
+
+    // draw all hitobjects in reverse
+    if(m_osu_draw_hitobjects_ref->getBool()) drawHitObjects(g);
+
+    if(osu_mandala.getBool()) {
+        for(int i = 0; i < osu_mandala_num.getInt(); i++) {
+            m_iMandalaIndex = i;
+            drawHitObjects(g);
+        }
+    }
+
+    // debug stuff
+    if(osu_debug_hiterrorbar_misaims.getBool()) {
+        for(int i = 0; i < m_misaimObjects.size(); i++) {
+            g->setColor(0xbb00ff00);
+            Vector2 pos = osuCoords2Pixels(m_misaimObjects[i]->getRawPosAt(0));
+            g->fillRect(pos.x - 50, pos.y - 50, 100, 100);
+        }
+    }
+}
+
+void OsuBeatmap::drawFollowPoints(Graphics *g) {
+    OsuSkin *skin = m_osu->getSkin();
+
+    const long curPos = m_iCurMusicPosWithOffsets;
+
+    // I absolutely hate this, followpoints can be abused for cheesing high AR reading since they always fade in with a
+    // fixed 800 ms custom approach time. Capping it at the current approach rate seems sensible, but unfortunately
+    // that's not what osu is doing. It was non-osu-compliant-clamped since this client existed, but let's see how many
+    // people notice a change after all this time (26.02.2020)
+
+    // 0.7x means animation lasts only 0.7 of it's time
+    const double animationMutiplier = m_osu->getSpeedMultiplier() / m_osu->getAnimationSpeedMultiplier();
+    const long followPointApproachTime =
+        animationMutiplier *
+        (osu_followpoints_clamp.getBool()
+             ? std::min((long)OsuGameRules::getApproachTime(this), (long)osu_followpoints_approachtime.getFloat())
+             : (long)osu_followpoints_approachtime.getFloat());
+    const bool followPointsConnectCombos = osu_followpoints_connect_combos.getBool();
+    const bool followPointsConnectSpinners = osu_followpoints_connect_spinners.getBool();
+    const float followPointSeparationMultiplier = std::max(osu_followpoints_separation_multiplier.getFloat(), 0.1f);
+    const float followPointPrevFadeTime = animationMutiplier * m_osu_followpoints_prevfadetime_ref->getFloat();
+    const float followPointScaleMultiplier = osu_followpoints_scale_multiplier.getFloat();
+
+    // include previous object in followpoints
+    int lastObjectIndex = -1;
+
+    for(int index = m_iPreviousFollowPointObjectIndex; index < m_hitobjects.size(); index++) {
+        lastObjectIndex = index - 1;
+
+        // ignore future spinners
+        OsuSpinner *spinnerPointer = dynamic_cast<OsuSpinner *>(m_hitobjects[index]);
+        if(spinnerPointer != NULL && !followPointsConnectSpinners)  // if this is a spinner
+        {
+            lastObjectIndex = -1;
+            continue;
+        }
+
+        // NOTE: "m_hitobjects[index]->getComboNumber() != 1" breaks (not literally) on new combos
+        // NOTE: the "getComboNumber()" call has been replaced with isEndOfCombo() because of
+        // osu_ignore_beatmap_combo_numbers and osu_number_max
+        const bool isCurrentHitObjectNewCombo =
+            (lastObjectIndex >= 0 ? m_hitobjects[lastObjectIndex]->isEndOfCombo() : false);
+        const bool isCurrentHitObjectSpinner = (lastObjectIndex >= 0 && followPointsConnectSpinners
+                                                    ? dynamic_cast<OsuSpinner *>(m_hitobjects[lastObjectIndex]) != NULL
+                                                    : false);
+        if(lastObjectIndex >= 0 && (!isCurrentHitObjectNewCombo || followPointsConnectCombos ||
+                                    (isCurrentHitObjectSpinner && followPointsConnectSpinners))) {
+            // ignore previous spinners
+            spinnerPointer = dynamic_cast<OsuSpinner *>(m_hitobjects[lastObjectIndex]);
+            if(spinnerPointer != NULL && !followPointsConnectSpinners)  // if this is a spinner
+            {
+                lastObjectIndex = -1;
+                continue;
+            }
+
+            // get time & pos of the last and current object
+            const long lastObjectEndTime =
+                m_hitobjects[lastObjectIndex]->getTime() + m_hitobjects[lastObjectIndex]->getDuration() + 1;
+            const long objectStartTime = m_hitobjects[index]->getTime();
+            const long timeDiff = objectStartTime - lastObjectEndTime;
+
+            const Vector2 startPoint = osuCoords2Pixels(m_hitobjects[lastObjectIndex]->getRawPosAt(lastObjectEndTime));
+            const Vector2 endPoint = osuCoords2Pixels(m_hitobjects[index]->getRawPosAt(objectStartTime));
+
+            const float xDiff = endPoint.x - startPoint.x;
+            const float yDiff = endPoint.y - startPoint.y;
+            const Vector2 diff = endPoint - startPoint;
+            const float dist =
+                std::round(diff.length() * 100.0f) / 100.0f;  // rounded to avoid flicker with playfield rotations
+
+            // draw all points between the two objects
+            const int followPointSeparation = Osu::getUIScale(m_osu, 32) * followPointSeparationMultiplier;
+            for(int j = (int)(followPointSeparation * 1.5f); j < (dist - followPointSeparation);
+                j += followPointSeparation) {
+                const float animRatio = ((float)j / dist);
+
+                const Vector2 animPosStart = startPoint + (animRatio - 0.1f) * diff;
+                const Vector2 finalPos = startPoint + animRatio * diff;
+
+                const long fadeInTime = (long)(lastObjectEndTime + animRatio * timeDiff) - followPointApproachTime;
+                const long fadeOutTime = (long)(lastObjectEndTime + animRatio * timeDiff);
+
+                // draw
+                float alpha = 1.0f;
+                float followAnimPercent =
+                    clamp<float>((float)(curPos - fadeInTime) / (float)followPointPrevFadeTime, 0.0f, 1.0f);
+                followAnimPercent = -followAnimPercent * (followAnimPercent - 2.0f);  // quad out
+
+                // NOTE: only internal osu default skin uses scale + move transforms here, it is impossible to achieve
+                // this effect with user skins
+                const float scale = osu_followpoints_anim.getBool() ? 1.5f - 0.5f * followAnimPercent : 1.0f;
+                const Vector2 followPos = osu_followpoints_anim.getBool()
+                                              ? animPosStart + (finalPos - animPosStart) * followAnimPercent
+                                              : finalPos;
+
+                // bullshit performance optimization: only draw followpoints if within screen bounds (plus a bit of a
+                // margin) there is only one beatmap where this matters currently: https://osu.ppy.sh/b/1145513
+                if(followPos.x < -m_osu->getScreenWidth() || followPos.x > m_osu->getScreenWidth() * 2 ||
+                   followPos.y < -m_osu->getScreenHeight() || followPos.y > m_osu->getScreenHeight() * 2)
+                    continue;
+
+                // calculate trail alpha
+                if(curPos >= fadeInTime && curPos < fadeOutTime) {
+                    // future trail
+                    const float delta = curPos - fadeInTime;
+                    alpha = (float)delta / (float)followPointApproachTime;
+                } else if(curPos >= fadeOutTime && curPos < (fadeOutTime + (long)followPointPrevFadeTime)) {
+                    // previous trail
+                    const long delta = curPos - fadeOutTime;
+                    alpha = 1.0f - (float)delta / (float)(followPointPrevFadeTime);
+                } else
+                    alpha = 0.0f;
+
+                // draw it
+                g->setColor(0xffffffff);
+                g->setAlpha(alpha);
+                g->pushTransform();
+                {
+                    g->rotate(rad2deg(std::atan2(yDiff, xDiff)));
+
+                    skin->getFollowPoint2()->setAnimationTimeOffset(skin->getAnimationSpeed(), fadeInTime);
+
+                    // NOTE: getSizeBaseRaw() depends on the current animation time being set correctly beforehand!
+                    // (otherwise you get incorrect scales, e.g. for animated elements with inconsistent @2x mixed in)
+                    // the followpoints are scaled by one eighth of the hitcirclediameter (not the raw diameter, but the
+                    // scaled diameter)
+                    const float followPointImageScale =
+                        ((m_fHitcircleDiameter / 8.0f) / skin->getFollowPoint2()->getSizeBaseRaw().x) *
+                        followPointScaleMultiplier;
+
+                    skin->getFollowPoint2()->drawRaw(g, followPos, followPointImageScale * scale);
+                }
+                g->popTransform();
+            }
+        }
+
+        // store current index as previous index
+        lastObjectIndex = index;
+
+        // iterate up until the "nextest" element
+        if(m_hitobjects[index]->getTime() >= curPos + followPointApproachTime) break;
+    }
+}
+
+void OsuBeatmap::drawHitObjects(Graphics *g) {
+    const long curPos = m_iCurMusicPosWithOffsets;
+    const long pvs = getPVS();
+    const bool usePVS = m_osu_pvs->getBool();
+
+    if(!OsuGameRules::osu_mod_mafham.getBool()) {
+        if(!osu_draw_reverse_order.getBool()) {
+            for(int i = m_hitobjectsSortedByEndTime.size() - 1; i >= 0; i--) {
+                // PVS optimization (reversed)
+                if(usePVS) {
+                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
+                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
+                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
+                        break;
+                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
+                        continue;
+                }
+
+                m_hitobjectsSortedByEndTime[i]->draw(g);
+            }
+        } else {
+            for(int i = 0; i < m_hitobjectsSortedByEndTime.size(); i++) {
+                // PVS optimization
+                if(usePVS) {
+                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
+                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
+                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
+                        continue;
+                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
+                        break;
+                }
+
+                m_hitobjectsSortedByEndTime[i]->draw(g);
+            }
+        }
+        for(int i = 0; i < m_hitobjectsSortedByEndTime.size(); i++) {
+            // NOTE: to fix mayday simultaneous sliders with increasing endtime getting culled here, would have to
+            // switch from m_hitobjectsSortedByEndTime to m_hitobjects PVS optimization
+            if(usePVS) {
+                if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
+                   (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
+                                       m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
+                    continue;
+                if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
+                    break;
+            }
+
+            m_hitobjectsSortedByEndTime[i]->draw2(g);
+        }
+    } else {
+        const int mafhamRenderLiveSize = OsuGameRules::osu_mod_mafham_render_livesize.getInt();
+
+        if(m_mafhamActiveRenderTarget == NULL) m_mafhamActiveRenderTarget = m_osu->getFrameBuffer();
+
+        if(m_mafhamFinishedRenderTarget == NULL) m_mafhamFinishedRenderTarget = m_osu->getFrameBuffer2();
+
+        // if we have a chunk to render into the scene buffer
+        const bool shouldDrawBuffer =
+            (m_hitobjectsSortedByEndTime.size() - m_iCurrentHitObjectIndex) > mafhamRenderLiveSize;
+        bool shouldRenderChunk = m_iMafhamHitObjectRenderIndex < m_hitobjectsSortedByEndTime.size() && shouldDrawBuffer;
+        if(shouldRenderChunk) {
+            m_bInMafhamRenderChunk = true;
+
+            m_mafhamActiveRenderTarget->setClearColorOnDraw(m_iMafhamHitObjectRenderIndex == 0);
+            m_mafhamActiveRenderTarget->setClearDepthOnDraw(m_iMafhamHitObjectRenderIndex == 0);
+
+            m_mafhamActiveRenderTarget->enable();
+            {
+                g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_PREMUL_ALPHA);
+                {
+                    int chunkCounter = 0;
+                    for(int i = m_hitobjectsSortedByEndTime.size() - 1 - m_iMafhamHitObjectRenderIndex; i >= 0;
+                        i--, m_iMafhamHitObjectRenderIndex++) {
+                        chunkCounter++;
+                        if(chunkCounter > osu_mod_mafham_render_chunksize.getInt())
+                            break;  // continue chunk render in next frame
+
+                        if(i <= m_iCurrentHitObjectIndex + mafhamRenderLiveSize)  // skip live objects
+                        {
+                            m_iMafhamHitObjectRenderIndex = m_hitobjectsSortedByEndTime.size();  // stop chunk render
+                            break;
+                        }
+
+                        // PVS optimization (reversed)
+                        if(usePVS) {
+                            if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
+                               (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
+                                                   m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
+                            {
+                                m_iMafhamHitObjectRenderIndex =
+                                    m_hitobjectsSortedByEndTime.size();  // stop chunk render
+                                break;
+                            }
+                            if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
+                                continue;
+                        }
+
+                        m_hitobjectsSortedByEndTime[i]->draw(g);
+
+                        m_iMafhamActiveRenderHitObjectIndex = i;
+                    }
+                }
+                g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_ALPHA);
+            }
+            m_mafhamActiveRenderTarget->disable();
+
+            m_bInMafhamRenderChunk = false;
+        }
+        shouldRenderChunk = m_iMafhamHitObjectRenderIndex < m_hitobjectsSortedByEndTime.size() && shouldDrawBuffer;
+        if(!shouldRenderChunk && m_bMafhamRenderScheduled) {
+            // finished, we can now swap the active framebuffer with the one we just finished
+            m_bMafhamRenderScheduled = false;
+
+            RenderTarget *temp = m_mafhamFinishedRenderTarget;
+            m_mafhamFinishedRenderTarget = m_mafhamActiveRenderTarget;
+            m_mafhamActiveRenderTarget = temp;
+
+            m_iMafhamFinishedRenderHitObjectIndex = m_iMafhamActiveRenderHitObjectIndex;
+            m_iMafhamActiveRenderHitObjectIndex = m_hitobjectsSortedByEndTime.size();  // reset
+        }
+
+        // draw scene buffer
+        if(shouldDrawBuffer) {
+            g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_PREMUL_COLOR);
+            { m_mafhamFinishedRenderTarget->draw(g, 0, 0); }
+            g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_ALPHA);
+        }
+
+        // draw followpoints
+        if(osu_draw_followpoints.getBool()) drawFollowPoints(g);
+
+        // draw live hitobjects (also, code duplication yay)
+        {
+            for(int i = m_hitobjectsSortedByEndTime.size() - 1; i >= 0; i--) {
+                // PVS optimization (reversed)
+                if(usePVS) {
+                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
+                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
+                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
+                        break;
+                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
+                        continue;
+                }
+
+                if(i > m_iCurrentHitObjectIndex + mafhamRenderLiveSize ||
+                   (i > m_iMafhamFinishedRenderHitObjectIndex - 1 && shouldDrawBuffer))  // skip non-live objects
+                    continue;
+
+                m_hitobjectsSortedByEndTime[i]->draw(g);
+            }
+
+            for(int i = 0; i < m_hitobjectsSortedByEndTime.size(); i++) {
+                // PVS optimization
+                if(usePVS) {
+                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
+                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
+                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
+                        continue;
+                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
+                        break;
+                }
+
+                if(i >= m_iCurrentHitObjectIndex + mafhamRenderLiveSize ||
+                   (i >= m_iMafhamFinishedRenderHitObjectIndex - 1 && shouldDrawBuffer))  // skip non-live objects
+                    break;
+
+                m_hitobjectsSortedByEndTime[i]->draw2(g);
+            }
+        }
+    }
+}
+
+void OsuBeatmap::update() {
+    if(!canUpdate()) return;
+
+    // some things need to be updated before loading has finished, so control flow is a bit weird here.
+
+    // live update hitobject and playfield metrics
+    updateHitobjectMetrics();
+    updatePlayfieldMetrics();
+
+    // wobble mod
+    if(osu_mod_wobble.getBool()) {
+        const float speedMultiplierCompensation = 1.0f / getSpeedMultiplier();
+        m_fPlayfieldRotation =
+            (m_iCurMusicPos / 1000.0f) * 30.0f * speedMultiplierCompensation * osu_mod_wobble_rotation_speed.getFloat();
+        m_fPlayfieldRotation = std::fmod(m_fPlayfieldRotation, 360.0f);
+    } else
+        m_fPlayfieldRotation = 0.0f;
+
+    // do hitobject updates among other things
+    // yes, this needs to happen after updating metrics and playfield rotation
+    update2();
+
+    // handle preloading (only for distributed slider vertexbuffer generation atm)
+    if(m_bIsPreLoading) {
+        if(Osu::debug->getBool() && m_iPreLoadingIndex == 0)
+            debugLog("OsuBeatmap: Preloading slider vertexbuffers ...\n");
+
+        double startTime = engine->getTimeReal();
+        double delta = 0.0;
+
+        // hardcoded deadline of 10 ms, will temporarily bring us down to 45fps on average (better than freezing)
+        while(delta < 0.010 && m_bIsPreLoading) {
+            if(m_iPreLoadingIndex >= m_hitobjects.size()) {
+                m_bIsPreLoading = false;
+                debugLog("OsuBeatmap: Preloading done.\n");
+                break;
+            } else {
+                OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[m_iPreLoadingIndex]);
+                if(sliderPointer != NULL) sliderPointer->rebuildVertexBuffer();
+            }
+
+            m_iPreLoadingIndex++;
+            delta = engine->getTimeReal() - startTime;
+        }
+    }
+
+    // notify all other players (including ourself) once we've finished loading
+    if(bancho.is_playing_a_multi_map()) {
+        if(!isActuallyLoading()) {
+            if(!bancho.room.player_loaded) {
+                bancho.room.player_loaded = true;
+
+                Packet packet;
+                packet.id = MATCH_LOAD_COMPLETE;
+                send_packet(packet);
+            }
+        }
+    }
+
+    if(isLoading()) return;  // only continue if we have loaded everything
+
+    // update auto (after having updated the hitobjects)
+    if(m_osu->getModAuto() || m_osu->getModAutopilot()) updateAutoCursorPos();
+
+    // spinner detection (used by osu!stable drain, and by OsuHUD for not drawing the hiterrorbar)
+    if(m_currentHitObject != NULL) {
+        OsuSpinner *spinnerPointer = dynamic_cast<OsuSpinner *>(m_currentHitObject);
+        if(spinnerPointer != NULL && m_iCurMusicPosWithOffsets > m_currentHitObject->getTime() &&
+           m_iCurMusicPosWithOffsets < m_currentHitObject->getTime() + m_currentHitObject->getDuration())
+            m_bIsSpinnerActive = true;
+        else
+            m_bIsSpinnerActive = false;
+    }
+
+    // scene buffering logic
+    if(OsuGameRules::osu_mod_mafham.getBool()) {
+        if(!m_bMafhamRenderScheduled &&
+           m_iCurrentHitObjectIndex !=
+               m_iMafhamPrevHitObjectIndex)  // if we are not already rendering and the index changed
+        {
+            m_iMafhamPrevHitObjectIndex = m_iCurrentHitObjectIndex;
+            m_iMafhamHitObjectRenderIndex = 0;
+            m_bMafhamRenderScheduled = true;
+        }
+    }
+
+    // full alternate mod lenience
+    if(m_osu_mod_fullalternate_ref->getBool()) {
+        if(m_bInBreak || m_bIsInSkippableSection || m_bIsSpinnerActive || m_iCurrentHitObjectIndex < 1)
+            m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex = m_iCurrentHitObjectIndex + 1;
+    }
+
+    if(last_keys != current_keys) {
+        write_frame();
+    } else if(last_event_time + 0.01666666666 <= engine->getTimeReal()) {
+        write_frame();
+    }
+}
+
+void OsuBeatmap::update2() {
+    long osu_universal_offset_hardcoded = convar->getConVarByName("osu_universal_offset_hardcoded")->getInt();
+
+    if(m_bContinueScheduled) {
+        // If we paused while m_bIsWaiting (green progressbar), then we have to let the 'if (m_bIsWaiting)' block handle
+        // the sound play() call
+        bool isEarlyNoteContinue = (!m_bIsPaused && m_bIsWaiting);
+        if(m_bClickedContinue || isEarlyNoteContinue) {
+            m_bClickedContinue = false;
+            m_bContinueScheduled = false;
+            m_bIsPaused = false;
+
+            if(!isEarlyNoteContinue) {
+                engine->getSound()->play(m_music);
+            }
+
+            m_bIsPlaying = true;  // usually this should be checked with the result of the above play() call, but since
+                                  // we are continuing we can assume that everything works
+
+            // for nightmare mod, to avoid a miss because of the continue click
+            {
+                m_clicks.clear();
+                m_keyUps.clear();
+            }
+        }
+    }
+
+    // handle restarts
+    if(m_bIsRestartScheduled) {
+        m_bIsRestartScheduled = false;
+        actualRestart();
+        return;
+    }
+
+    // update current music position (this variable does not include any offsets!)
+    m_iCurMusicPos = getMusicPositionMSInterpolated();
+    m_iContinueMusicPos = m_music->getPositionMS();
+    const bool wasSeekFrame = m_bWasSeekFrame;
+    m_bWasSeekFrame = false;
+
+    // handle timewarp
+    if(osu_mod_timewarp.getBool()) {
+        if(m_hitobjects.size() > 0 && m_iCurMusicPos > m_hitobjects[0]->getTime()) {
+            const float percentFinished =
+                ((double)(m_iCurMusicPos - m_hitobjects[0]->getTime()) /
+                 (double)(m_hitobjects[m_hitobjects.size() - 1]->getTime() +
+                          m_hitobjects[m_hitobjects.size() - 1]->getDuration() - m_hitobjects[0]->getTime()));
+            float warp_multiplier = std::max(osu_mod_timewarp_multiplier.getFloat(), 1.f);
+            const float speed =
+                m_osu->getSpeedMultiplier() + percentFinished * m_osu->getSpeedMultiplier() * (warp_multiplier - 1.0f);
+            m_music->setSpeed(speed);
+        }
+    }
+
+    // HACKHACK: clean this mess up
+    // waiting to start (file loading, retry)
+    // NOTE: this is dependent on being here AFTER m_iCurMusicPos has been set above, because it modifies it to fake a
+    // negative start (else everything would just freeze for the waiting period)
+    if(m_bIsWaiting) {
+        if(isLoading()) {
+            m_fWaitTime = engine->getTimeReal();
+
+            // if the first hitobject starts immediately, add artificial wait time before starting the music
+            if(!m_bIsRestartScheduledQuick && m_hitobjects.size() > 0) {
+                if(m_hitobjects[0]->getTime() < (long)osu_early_note_time.getInt())
+                    m_fWaitTime = engine->getTimeReal() + osu_early_note_time.getFloat() / 1000.0f;
+            }
+        } else {
+            if(engine->getTimeReal() > m_fWaitTime) {
+                if(!m_bIsPaused) {
+                    m_bIsWaiting = false;
+                    m_bIsPlaying = true;
+
+                    engine->getSound()->play(m_music);
+                    m_music->setPositionMS(0);
+                    m_music->setVolume(m_osu_volume_music_ref->getFloat());
+                    m_music->setSpeed(m_osu->getSpeedMultiplier());
+
+                    // if we are quick restarting, jump just before the first hitobject (even if there is a long waiting
+                    // period at the beginning with nothing etc.)
+                    if(m_bIsRestartScheduledQuick && m_hitobjects.size() > 0 &&
+                       m_hitobjects[0]->getTime() > (long)osu_quick_retry_time.getInt())
+                        m_music->setPositionMS(
+                            std::max((long)0, m_hitobjects[0]->getTime() - (long)osu_quick_retry_time.getInt()));
+
+                    m_bIsRestartScheduledQuick = false;
+
+                    onPlayStart();
+                }
+            } else
+                m_iCurMusicPos = (engine->getTimeReal() - m_fWaitTime) * 1000.0f * m_osu->getSpeedMultiplier();
+        }
+
+        // ugh. force update all hitobjects while waiting (necessary because of pvs optimization)
+        long curPos = m_iCurMusicPos + (long)(osu_universal_offset.getFloat() * m_osu->getSpeedMultiplier()) +
+                      osu_universal_offset_hardcoded - m_selectedDifficulty2->getLocalOffset() -
+                      m_selectedDifficulty2->getOnlineOffset() -
+                      (m_selectedDifficulty2->getVersion() < 5 ? osu_old_beatmap_offset.getInt() : 0);
+        if(curPos > -1)  // otherwise auto would already click elements that start at exactly 0 (while the map has not
+                         // even started)
+            curPos = -1;
+
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            m_hitobjects[i]->update(curPos);
+        }
+    }
+
+    // only continue updating hitobjects etc. if we have loaded everything
+    if(isLoading()) return;
+
+    // handle music loading fail
+    if(!m_music->isReady()) {
+        m_iResourceLoadUpdateDelayHack++;  // HACKHACK: async loading takes 1 additional engine update() until both
+                                           // isAsyncReady() and isReady() return true
+        if(m_iResourceLoadUpdateDelayHack > 1 &&
+           !m_bForceStreamPlayback)  // first: try loading a stream version of the music file
+        {
+            m_bForceStreamPlayback = true;
+            unloadMusic();
+            loadMusic(true, m_bForceStreamPlayback);
+
+            // we are waiting for an asynchronous start of the beatmap in the next update()
+            m_bIsWaiting = true;
+            m_fWaitTime = engine->getTimeReal();
+        } else if(m_iResourceLoadUpdateDelayHack >
+                  3)  // second: if that still doesn't work, stop and display an error message
+        {
+            m_osu->getNotificationOverlay()->addNotification("Couldn't load music file :(", 0xffff0000);
+            stop(true);
+        }
+    }
+
+    // detect and handle music end
+    if(!m_bIsWaiting && m_music->isReady()) {
+        const bool isMusicFinished = m_music->isFinished();
+
+        // trigger virtual audio time after music finishes
+        if(!isMusicFinished)
+            m_fAfterMusicIsFinishedVirtualAudioTimeStart = -1.0f;
+        else if(m_fAfterMusicIsFinishedVirtualAudioTimeStart < 0.0f)
+            m_fAfterMusicIsFinishedVirtualAudioTimeStart = engine->getTimeReal();
+
+        if(isMusicFinished) {
+            // continue with virtual audio time until the last hitobject is done (plus sanity offset given via
+            // osu_end_delay_time) because some beatmaps have hitobjects going until >= the exact end of the music ffs
+            // NOTE: this overwrites m_iCurMusicPos for the rest of the update loop
+            m_iCurMusicPos = (long)m_music->getLengthMS() +
+                             (long)((engine->getTimeReal() - m_fAfterMusicIsFinishedVirtualAudioTimeStart) * 1000.0f);
+        }
+
+        const bool hasAnyHitObjects = (m_hitobjects.size() > 0);
+        const bool isTimePastLastHitObjectPlusLenience =
+            (m_iCurMusicPos > (m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getTime() +
+                               m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getDuration() +
+                               (long)osu_end_delay_time.getInt()));
+        if(!hasAnyHitObjects || (osu_end_skip.getBool() && isTimePastLastHitObjectPlusLenience) ||
+           (!osu_end_skip.getBool() && isMusicFinished)) {
+            if(!m_bFailed) {
+                stop(false);
+                return;
+            }
+        }
+    }
+
+    // update timing (points)
+    m_iCurMusicPosWithOffsets = m_iCurMusicPos + (long)(osu_universal_offset.getFloat() * m_osu->getSpeedMultiplier()) +
+                                osu_universal_offset_hardcoded - m_selectedDifficulty2->getLocalOffset() -
+                                m_selectedDifficulty2->getOnlineOffset() -
+                                (m_selectedDifficulty2->getVersion() < 5 ? osu_old_beatmap_offset.getInt() : 0);
+    updateTimingPoints(m_iCurMusicPosWithOffsets);
+
+    // for performance reasons, a lot of operations are crammed into 1 loop over all hitobjects:
+    // update all hitobjects,
+    // handle click events,
+    // also get the time of the next/previous hitobject and their indices for later,
+    // and get the current hitobject,
+    // also handle miss hiterrorbar slots,
+    // also calculate nps and nd,
+    // also handle note blocking
+    m_currentHitObject = NULL;
+    m_iNextHitObjectTime = 0;
+    m_iPreviousHitObjectTime = 0;
+    m_iPreviousFollowPointObjectIndex = 0;
+    m_iNPS = 0;
+    m_iND = 0;
+    m_iCurrentNumCircles = 0;
+    m_iCurrentNumSliders = 0;
+    m_iCurrentNumSpinners = 0;
+    {
+        bool blockNextNotes = false;
+
+        const long pvs =
+            !OsuGameRules::osu_mod_mafham.getBool()
+                ? getPVS()
+                : (m_hitobjects.size() > 0
+                       ? (m_hitobjects[clamp<int>(m_iCurrentHitObjectIndex +
+                                                      OsuGameRules::osu_mod_mafham_render_livesize.getInt() + 1,
+                                                  0, m_hitobjects.size() - 1)]
+                              ->getTime() -
+                          m_iCurMusicPosWithOffsets + 1500)
+                       : getPVS());
+        const bool usePVS = m_osu_pvs->getBool();
+
+        const int notelockType = osu_notelock_type.getInt();
+        const long tolerance2B = (long)osu_notelock_stable_tolerance2b.getInt();
+
+        m_iCurrentHitObjectIndex = 0;  // reset below here, since it's needed for mafham pvs
+
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            // the order must be like this:
+            // 0) miscellaneous stuff (minimal performance impact)
+            // 1) prev + next time vars
+            // 2) PVS optimization
+            // 3) main hitobject update
+            // 4) note blocking
+            // 5) click events
+            //
+            // (because the hitobjects need to know about note blocking before handling the click events)
+
+            // ************ live pp block start ************ //
+            const bool isCircle = m_hitobjects[i]->isCircle();
+            const bool isSlider = m_hitobjects[i]->isSlider();
+            const bool isSpinner = m_hitobjects[i]->isSpinner();
+            // ************ live pp block end ************** //
+
+            // determine previous & next object time, used for auto + followpoints + warning arrows + empty section
+            // skipping
+            if(m_iNextHitObjectTime == 0) {
+                if(m_hitobjects[i]->getTime() > m_iCurMusicPosWithOffsets)
+                    m_iNextHitObjectTime = m_hitobjects[i]->getTime();
+                else {
+                    m_currentHitObject = m_hitobjects[i];
+                    const long actualPrevHitObjectTime = m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration();
+                    m_iPreviousHitObjectTime = actualPrevHitObjectTime;
+
+                    if(m_iCurMusicPosWithOffsets >
+                       actualPrevHitObjectTime + (long)osu_followpoints_prevfadetime.getFloat())
+                        m_iPreviousFollowPointObjectIndex = i;
+                }
+            }
+
+            // PVS optimization
+            if(usePVS) {
+                if(m_hitobjects[i]->isFinished() &&
+                   (m_iCurMusicPosWithOffsets - pvs >
+                    m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration()))  // past objects
+                {
+                    // ************ live pp block start ************ //
+                    if(isCircle) m_iCurrentNumCircles++;
+                    if(isSlider) m_iCurrentNumSliders++;
+                    if(isSpinner) m_iCurrentNumSpinners++;
+
+                    m_iCurrentHitObjectIndex = i;
+                    // ************ live pp block end ************** //
+
+                    continue;
+                }
+                if(m_hitobjects[i]->getTime() > m_iCurMusicPosWithOffsets + pvs)  // future objects
+                    break;
+            }
+
+            // ************ live pp block start ************ //
+            if(m_iCurMusicPosWithOffsets >= m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration())
+                m_iCurrentHitObjectIndex = i;
+            // ************ live pp block end ************** //
+
+            // main hitobject update
+            m_hitobjects[i]->update(m_iCurMusicPosWithOffsets);
+
+            // note blocking / notelock (1)
+            const OsuSlider *currentSliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[i]);
+            if(notelockType > 0) {
+                m_hitobjects[i]->setBlocked(blockNextNotes);
+
+                if(notelockType == 1)  // McOsu
+                {
+                    // (nothing, handled in (2) block)
+                } else if(notelockType == 2)  // osu!stable
+                {
+                    if(!m_hitobjects[i]->isFinished()) {
+                        blockNextNotes = true;
+
+                        // Sliders are "finished" after they end
+                        // Extra handling for simultaneous/2b hitobjects, as these would otherwise get blocked
+                        // NOTE: this will still unlock some simultaneous/2b patterns too early
+                        //       (slider slider circle [circle]), but nobody from that niche has complained so far
+                        {
+                            const bool isSlider = (currentSliderPointer != NULL);
+                            const bool isSpinner = (!isSlider && !isCircle);
+
+                            if(isSlider || isSpinner) {
+                                if((i + 1) < m_hitobjects.size()) {
+                                    if((isSpinner || currentSliderPointer->isStartCircleFinished()) &&
+                                       (m_hitobjects[i + 1]->getTime() <=
+                                        (m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration() + tolerance2B)))
+                                        blockNextNotes = false;
+                                }
+                            }
+                        }
+                    }
+                } else if(notelockType == 3)  // osu!lazer 2020
+                {
+                    if(!m_hitobjects[i]->isFinished()) {
+                        const bool isSlider = (currentSliderPointer != NULL);
+                        const bool isSpinner = (!isSlider && !isCircle);
+
+                        if(!isSpinner)  // spinners are completely ignored (transparent)
+                        {
+                            blockNextNotes = (m_iCurMusicPosWithOffsets <= m_hitobjects[i]->getTime());
+
+                            // sliders are "finished" after their startcircle
+                            {
+                                // sliders with finished startcircles do not block
+                                if(currentSliderPointer != NULL && currentSliderPointer->isStartCircleFinished())
+                                    blockNextNotes = false;
+                            }
+                        }
+                    }
+                }
+            } else
+                m_hitobjects[i]->setBlocked(false);
+
+            // click events (this also handles hitsounds!)
+            const bool isCurrentHitObjectASliderAndHasItsStartCircleFinishedBeforeClickEvents =
+                (currentSliderPointer != NULL && currentSliderPointer->isStartCircleFinished());
+            const bool isCurrentHitObjectFinishedBeforeClickEvents = m_hitobjects[i]->isFinished();
+            {
+                if(m_clicks.size() > 0) m_hitobjects[i]->onClickEvent(m_clicks);
+
+                if(m_keyUps.size() > 0) m_hitobjects[i]->onKeyUpEvent(m_keyUps);
+            }
+            const bool isCurrentHitObjectFinishedAfterClickEvents = m_hitobjects[i]->isFinished();
+            const bool isCurrentHitObjectASliderAndHasItsStartCircleFinishedAfterClickEvents =
+                (currentSliderPointer != NULL && currentSliderPointer->isStartCircleFinished());
+
+            // note blocking / notelock (2.1)
+            if(!isCurrentHitObjectASliderAndHasItsStartCircleFinishedBeforeClickEvents &&
+               isCurrentHitObjectASliderAndHasItsStartCircleFinishedAfterClickEvents) {
+                // in here if a slider had its startcircle clicked successfully in this update iteration
+
+                if(notelockType == 2)  // osu!stable
+                {
+                    // edge case: frame perfect double tapping on overlapping sliders would incorrectly eat the second
+                    // input, because the isStartCircleFinished() 2b edge case check handling happens before
+                    // m_hitobjects[i]->onClickEvent(m_clicks); so, we check if the currentSliderPointer got its
+                    // isStartCircleFinished() within this m_hitobjects[i]->onClickEvent(m_clicks); and unlock
+                    // blockNextNotes if that is the case note that we still only unlock within duration + tolerance2B
+                    // (same as in (1))
+                    if((i + 1) < m_hitobjects.size()) {
+                        if((m_hitobjects[i + 1]->getTime() <=
+                            (m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration() + tolerance2B)))
+                            blockNextNotes = false;
+                    }
+                }
+            }
+
+            // note blocking / notelock (2.2)
+            if(!isCurrentHitObjectFinishedBeforeClickEvents && isCurrentHitObjectFinishedAfterClickEvents) {
+                // in here if a hitobject has been clicked (and finished completely) successfully in this update
+                // iteration
+
+                blockNextNotes = false;
+
+                if(notelockType == 1)  // McOsu
+                {
+                    // auto miss all previous unfinished hitobjects, always
+                    // (can stop reverse iteration once we get to the first finished hitobject)
+
+                    for(int m = i - 1; m >= 0; m--) {
+                        if(!m_hitobjects[m]->isFinished()) {
+                            const OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[m]);
+
+                            const bool isSlider = (sliderPointer != NULL);
+                            const bool isSpinner = (!isSlider && !isCircle);
+
+                            if(!isSpinner)  // spinners are completely ignored (transparent)
+                            {
+                                if(m_hitobjects[i]->getTime() >
+                                   (m_hitobjects[m]->getTime() +
+                                    m_hitobjects[m]->getDuration()))  // NOTE: 2b exception. only force miss if objects
+                                                                      // are not overlapping.
+                                    m_hitobjects[m]->miss(m_iCurMusicPosWithOffsets);
+                            }
+                        } else
+                            break;
+                    }
+                } else if(notelockType == 2)  // osu!stable
+                {
+                    // (nothing, handled in (1) and (2.1) blocks)
+                } else if(notelockType == 3)  // osu!lazer 2020
+                {
+                    // auto miss all previous unfinished hitobjects if the current music time is > their time (center)
+                    // (can stop reverse iteration once we get to the first finished hitobject)
+
+                    for(int m = i - 1; m >= 0; m--) {
+                        if(!m_hitobjects[m]->isFinished()) {
+                            const OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[m]);
+
+                            const bool isSlider = (sliderPointer != NULL);
+                            const bool isSpinner = (!isSlider && !isCircle);
+
+                            if(!isSpinner)  // spinners are completely ignored (transparent)
+                            {
+                                if(m_iCurMusicPosWithOffsets > m_hitobjects[m]->getTime()) {
+                                    if(m_hitobjects[i]->getTime() >
+                                       (m_hitobjects[m]->getTime() +
+                                        m_hitobjects[m]->getDuration()))  // NOTE: 2b exception. only force miss if
+                                                                          // objects are not overlapping.
+                                        m_hitobjects[m]->miss(m_iCurMusicPosWithOffsets);
+                                }
+                            }
+                        } else
+                            break;
+                    }
+                }
+            }
+
+            // ************ live pp block start ************ //
+            if(isCircle && m_hitobjects[i]->isFinished()) m_iCurrentNumCircles++;
+            if(isSlider && m_hitobjects[i]->isFinished()) m_iCurrentNumSliders++;
+            if(isSpinner && m_hitobjects[i]->isFinished()) m_iCurrentNumSpinners++;
+
+            if(m_hitobjects[i]->isFinished()) m_iCurrentHitObjectIndex = i;
+            // ************ live pp block end ************** //
+
+            // notes per second
+            const long npsHalfGateSizeMS = (long)(500.0f * getSpeedMultiplier());
+            if(m_hitobjects[i]->getTime() > m_iCurMusicPosWithOffsets - npsHalfGateSizeMS &&
+               m_hitobjects[i]->getTime() < m_iCurMusicPosWithOffsets + npsHalfGateSizeMS)
+                m_iNPS++;
+
+            // note density
+            if(m_hitobjects[i]->isVisible()) m_iND++;
+        }
+
+        // miss hiterrorbar slots
+        // this gets the closest previous unfinished hitobject, as well as all following hitobjects which are in 50
+        // range and could be clicked
+        if(osu_hiterrorbar_misaims.getBool()) {
+            m_misaimObjects.clear();
+            OsuHitObject *lastUnfinishedHitObject = NULL;
+            const long hitWindow50 = (long)OsuGameRules::getHitWindow50(this);
+            for(int i = 0; i < m_hitobjects.size(); i++)  // this shouldn't hurt performance too much, since no
+                                                          // expensive operations are happening within the loop
+            {
+                if(!m_hitobjects[i]->isFinished()) {
+                    if(m_iCurMusicPosWithOffsets >= m_hitobjects[i]->getTime())
+                        lastUnfinishedHitObject = m_hitobjects[i];
+                    else if(std::abs(m_hitobjects[i]->getTime() - m_iCurMusicPosWithOffsets) < hitWindow50)
+                        m_misaimObjects.push_back(m_hitobjects[i]);
+                    else
+                        break;
+                }
+            }
+            if(lastUnfinishedHitObject != NULL &&
+               std::abs(lastUnfinishedHitObject->getTime() - m_iCurMusicPosWithOffsets) < hitWindow50)
+                m_misaimObjects.insert(m_misaimObjects.begin(), lastUnfinishedHitObject);
+
+            // now, go through the remaining clicks, and go through the unfinished hitobjects.
+            // handle misaim clicks sequentially (setting the misaim flag on the hitobjects to only allow 1 entry in the
+            // hiterrorbar for misses per object) clicks don't have to be consumed here, as they are deleted below
+            // anyway
+            for(int c = 0; c < m_clicks.size(); c++) {
+                for(int i = 0; i < m_misaimObjects.size(); i++) {
+                    if(m_misaimObjects[i]->hasMisAimed())  // only 1 slot per object!
+                        continue;
+
+                    m_misaimObjects[i]->misAimed();
+                    const long delta = (long)m_clicks[c].musicPos - (long)m_misaimObjects[i]->getTime();
+                    m_osu->getHUD()->addHitError(delta, false, true);
+
+                    break;  // the current click has been dealt with (and the hitobject has been misaimed)
+                }
+            }
+        }
+
+        // all remaining clicks which have not been consumed by any hitobjects can safely be deleted
+        if(m_clicks.size() > 0) {
+            if(osu_play_hitsound_on_click_while_playing.getBool()) m_osu->getSkin()->playHitCircleSound(0);
+
+            // nightmare mod: extra clicks = sliderbreak
+            if((m_osu->getModNightmare() || osu_mod_jigsaw1.getBool()) && !m_bIsInSkippableSection && !m_bInBreak &&
+               m_iCurrentHitObjectIndex > 0) {
+                addSliderBreak();
+                addHitResult(NULL, OsuScore::HIT::HIT_MISS_SLIDERBREAK, 0, false, true, true, true, true,
+                             false);  // only decrease health
+            }
+
+            m_clicks.clear();
+        }
+        m_keyUps.clear();
+    }
+
+    // empty section detection & skipping
+    if(m_hitobjects.size() > 0) {
+        const long legacyOffset = (m_iPreviousHitObjectTime < m_hitobjects[0]->getTime() ? 0 : 1000);  // Mc
+        const long nextHitObjectDelta = m_iNextHitObjectTime - (long)m_iCurMusicPosWithOffsets;
+        if(nextHitObjectDelta > 0 && nextHitObjectDelta > (long)osu_skip_time.getInt() &&
+           m_iCurMusicPosWithOffsets > (m_iPreviousHitObjectTime + legacyOffset))
+            m_bIsInSkippableSection = true;
+        else if(!osu_end_skip.getBool() && nextHitObjectDelta < 0)
+            m_bIsInSkippableSection = true;
+        else
+            m_bIsInSkippableSection = false;
+
+        m_osu->m_chat->updateVisibility();
+
+        // While we want to allow the chat to pop up during breaks, we don't
+        // want to be able to skip after the start in multiplayer rooms
+        if(bancho.is_playing_a_multi_map() && m_iCurrentHitObjectIndex > 0) {
+            m_bIsInSkippableSection = false;
+        }
+    }
+
+    // warning arrow logic
+    if(m_hitobjects.size() > 0) {
+        const long legacyOffset = (m_iPreviousHitObjectTime < m_hitobjects[0]->getTime() ? 0 : 1000);  // Mc
+        const long minGapSize = 1000;
+        const long lastVisibleMin = 400;
+        const long blinkDelta = 100;
+
+        const long gapSize = m_iNextHitObjectTime - (m_iPreviousHitObjectTime + legacyOffset);
+        const long nextDelta = (m_iNextHitObjectTime - m_iCurMusicPosWithOffsets);
+        const bool drawWarningArrows = gapSize > minGapSize && nextDelta > 0;
+        if(drawWarningArrows &&
+           ((nextDelta <= lastVisibleMin + blinkDelta * 13 && nextDelta > lastVisibleMin + blinkDelta * 12) ||
+            (nextDelta <= lastVisibleMin + blinkDelta * 11 && nextDelta > lastVisibleMin + blinkDelta * 10) ||
+            (nextDelta <= lastVisibleMin + blinkDelta * 9 && nextDelta > lastVisibleMin + blinkDelta * 8) ||
+            (nextDelta <= lastVisibleMin + blinkDelta * 7 && nextDelta > lastVisibleMin + blinkDelta * 6) ||
+            (nextDelta <= lastVisibleMin + blinkDelta * 5 && nextDelta > lastVisibleMin + blinkDelta * 4) ||
+            (nextDelta <= lastVisibleMin + blinkDelta * 3 && nextDelta > lastVisibleMin + blinkDelta * 2) ||
+            (nextDelta <= lastVisibleMin + blinkDelta * 1 && nextDelta > lastVisibleMin)))
+            m_bShouldFlashWarningArrows = true;
+        else
+            m_bShouldFlashWarningArrows = false;
+    }
+
+    // break time detection, and background fade during breaks
+    const OsuDatabaseBeatmap::BREAK breakEvent =
+        getBreakForTimeRange(m_iPreviousHitObjectTime, m_iCurMusicPosWithOffsets, m_iNextHitObjectTime);
+    const bool isInBreak = ((int)m_iCurMusicPosWithOffsets >= breakEvent.startTime &&
+                            (int)m_iCurMusicPosWithOffsets <= breakEvent.endTime);
+    if(isInBreak != m_bInBreak) {
+        m_bInBreak = !m_bInBreak;
+
+        if(!osu_background_dont_fade_during_breaks.getBool() || m_fBreakBackgroundFade != 0.0f) {
+            if(m_bInBreak && !osu_background_dont_fade_during_breaks.getBool()) {
+                const int breakDuration = breakEvent.endTime - breakEvent.startTime;
+                if(breakDuration > (int)(osu_background_fade_min_duration.getFloat() * 1000.0f))
+                    anim->moveLinear(&m_fBreakBackgroundFade, 1.0f, osu_background_fade_in_duration.getFloat(), true);
+            } else
+                anim->moveLinear(&m_fBreakBackgroundFade, 0.0f, osu_background_fade_out_duration.getFloat(), true);
+        }
+    }
+
+    // section pass/fail logic
+    if(m_hitobjects.size() > 0) {
+        const long minGapSize = 2880;
+        const long fadeStart = 1280;
+        const long fadeEnd = 1480;
+
+        const long gapSize = m_iNextHitObjectTime - m_iPreviousHitObjectTime;
+        const long start =
+            (gapSize / 2 > minGapSize ? m_iPreviousHitObjectTime + (gapSize / 2) : m_iNextHitObjectTime - minGapSize);
+        const long nextDelta = m_iCurMusicPosWithOffsets - start;
+        const bool inSectionPassFail =
+            (gapSize > minGapSize && nextDelta > 0) && m_iCurMusicPosWithOffsets > m_hitobjects[0]->getTime() &&
+            m_iCurMusicPosWithOffsets <
+                (m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getTime() +
+                 m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getDuration()) &&
+            !m_bFailed && m_bInBreak && (breakEvent.endTime - breakEvent.startTime) > minGapSize;
+
+        const bool passing = (m_fHealth >= 0.5);
+
+        // draw logic
+        if(passing) {
+            if(inSectionPassFail && ((nextDelta <= fadeEnd && nextDelta >= 280) ||
+                                     (nextDelta <= 230 && nextDelta >= 160) || (nextDelta <= 100 && nextDelta >= 20))) {
+                const float fadeAlpha = 1.0f - (float)(nextDelta - fadeStart) / (float)(fadeEnd - fadeStart);
+                m_fShouldFlashSectionPass = (nextDelta > fadeStart ? fadeAlpha : 1.0f);
+            } else
+                m_fShouldFlashSectionPass = 0.0f;
+        } else {
+            if(inSectionPassFail &&
+               ((nextDelta <= fadeEnd && nextDelta >= 280) || (nextDelta <= 230 && nextDelta >= 130))) {
+                const float fadeAlpha = 1.0f - (float)(nextDelta - fadeStart) / (float)(fadeEnd - fadeStart);
+                m_fShouldFlashSectionFail = (nextDelta > fadeStart ? fadeAlpha : 1.0f);
+            } else
+                m_fShouldFlashSectionFail = 0.0f;
+        }
+
+        // sound logic
+        if(inSectionPassFail) {
+            if(m_iPreviousSectionPassFailTime != start &&
+               ((passing && nextDelta >= 20) || (!passing && nextDelta >= 130))) {
+                m_iPreviousSectionPassFailTime = start;
+
+                if(!wasSeekFrame) {
+                    if(passing)
+                        engine->getSound()->play(m_osu->getSkin()->getSectionPassSound());
+                    else
+                        engine->getSound()->play(m_osu->getSkin()->getSectionFailSound());
+                }
+            }
+        }
+    }
+
+    // hp drain & failing
+    if(osu_drain_type.getInt() > 1) {
+        const int drainType = osu_drain_type.getInt();
+
+        // handle constant drain
+        if(drainType == 2 || drainType == 3)  // osu!stable + osu!lazer 2020
+        {
+            if(m_fDrainRate > 0.0) {
+                if(m_bIsPlaying                  // not paused
+                   && !m_bInBreak                // not in a break
+                   && !m_bIsInSkippableSection)  // not in a skippable section
+                {
+                    // special case: break drain edge cases
+                    bool drainAfterLastHitobjectBeforeBreakStart = false;
+                    bool drainBeforeFirstHitobjectAfterBreakEnd = false;
+
+                    if(drainType == 2)  // osu!stable
+                    {
+                        drainAfterLastHitobjectBeforeBreakStart =
+                            (m_selectedDifficulty2->getVersion() < 8 ? osu_drain_stable_break_before_old.getBool()
+                                                                     : osu_drain_stable_break_before.getBool());
+                        drainBeforeFirstHitobjectAfterBreakEnd = osu_drain_stable_break_after.getBool();
+                    } else if(drainType == 3)  // osu!lazer 2020
+                    {
+                        drainAfterLastHitobjectBeforeBreakStart = osu_drain_lazer_break_before.getBool();
+                        drainBeforeFirstHitobjectAfterBreakEnd = osu_drain_lazer_break_after.getBool();
+                    }
+
+                    const bool isBetweenHitobjectsAndBreak = (int)m_iPreviousHitObjectTime <= breakEvent.startTime &&
+                                                             (int)m_iNextHitObjectTime >= breakEvent.endTime &&
+                                                             m_iCurMusicPosWithOffsets > m_iPreviousHitObjectTime;
+                    const bool isLastHitobjectBeforeBreakStart =
+                        isBetweenHitobjectsAndBreak && (int)m_iCurMusicPosWithOffsets <= breakEvent.startTime;
+                    const bool isFirstHitobjectAfterBreakEnd =
+                        isBetweenHitobjectsAndBreak && (int)m_iCurMusicPosWithOffsets >= breakEvent.endTime;
+
+                    if(!isBetweenHitobjectsAndBreak ||
+                       (drainAfterLastHitobjectBeforeBreakStart && isLastHitobjectBeforeBreakStart) ||
+                       (drainBeforeFirstHitobjectAfterBreakEnd && isFirstHitobjectAfterBreakEnd)) {
+                        // special case: spinner nerf
+                        double spinnerDrainNerf = 1.0;
+
+                        if(drainType == 2)  // osu!stable
+                        {
+                            if(isSpinnerActive()) spinnerDrainNerf = (double)osu_drain_stable_spinner_nerf.getFloat();
+                        }
+
+                        addHealth(
+                            -m_fDrainRate * engine->getFrameTime() * (double)getSpeedMultiplier() * spinnerDrainNerf,
+                            false);
+                    }
+                }
+            }
+        }
+
+        // handle generic fail state (1) (see addHealth())
+        {
+            bool hasFailed = false;
+
+            switch(drainType) {
+                case 2:  // osu!stable
+                    hasFailed = (m_fHealth < 0.001) && osu_drain_stable_passive_fail.getBool();
+                    break;
+
+                case 3:  // osu!lazer 2020
+                    hasFailed = (m_fHealth < 0.001) && osu_drain_lazer_passive_fail.getBool();
+                    break;
+
+                default:
+                    hasFailed = (m_fHealth < 0.001);
+                    break;
+            }
+
+            if(hasFailed && !m_osu->getModNF()) fail();
+        }
+
+        // revive in mp
+        if(m_fHealth > 0.999 && m_osu->getScore()->isDead()) m_osu->getScore()->setDead(false);
+
+        // handle fail animation
+        if(m_bFailed) {
+            if(m_fFailAnim <= 0.0f) {
+                if(m_music->isPlaying() || !m_osu->getPauseMenu()->isVisible()) {
+                    engine->getSound()->pause(m_music);
+                    m_bIsPaused = true;
+
+                    m_osu->getPauseMenu()->setVisible(true);
+                    m_osu->updateConfineCursor();
+                }
+            } else
+                m_music->setFrequency(
+                    m_fMusicFrequencyBackup * m_fFailAnim > 100 ? m_fMusicFrequencyBackup * m_fFailAnim : 100);
         }
-    } else  // if this is not the first time pausing/unpausing, then just toggle the pause state (the visibility of the
-            // pause menu is handled in the Osu class, a bit shit)
-        m_bIsPaused = !m_bIsPaused;
+    }
+}
 
-    if(m_bIsPaused) onPaused(isFirstPause);
+void OsuBeatmap::write_frame() {
+    if(!m_bIsPlaying || m_bFailed || m_bIsWatchingReplay) return;
 
-    // if we have failed, and the user early exits to the pause menu, stop the failing animation
-    if(m_bFailed) anim->deleteExistingAnimation(&m_fFailAnim);
-}
+    long delta = m_iCurMusicPosWithOffsets - last_event_ms;
+    if(delta < 0) return;
+    if(delta == 0 && last_keys == current_keys) return;
 
-void OsuBeatmap::pausePreviewMusic(bool toggle) {
-    if(m_music != NULL) {
-        if(m_music->isPlaying())
-            engine->getSound()->pause(m_music);
-        else if(toggle)
-            engine->getSound()->play(m_music);
-    }
+    Vector2 pos = pixels2OsuCoords(getCursorPos());
+    if(osu_playfield_mirror_horizontal.getBool()) pos.y = OsuGameRules::OSU_COORD_HEIGHT - pos.y;
+    if(osu_playfield_mirror_vertical.getBool()) pos.x = OsuGameRules::OSU_COORD_WIDTH - pos.x;
+
+    replay.push_back(OsuReplay::Frame{
+        .cur_music_pos = m_iCurMusicPosWithOffsets,
+        .milliseconds_since_last_frame = delta,
+        .x = pos.x,
+        .y = pos.y,
+        .key_flags = current_keys,
+    });
+    last_event_time = m_fLastRealTimeForInterpolationDelta;
+    last_event_ms = m_iCurMusicPosWithOffsets;
+    last_keys = current_keys;
 }
 
-bool OsuBeatmap::isPreviewMusicPlaying() {
-    if(m_music != NULL) return m_music->isPlaying();
+void OsuBeatmap::onModUpdate(bool rebuildSliderVertexBuffers, bool recomputeDrainRate) {
+    if(Osu::debug->getBool()) debugLog("OsuBeatmap::onModUpdate() @ %f\n", engine->getTime());
 
-    return false;
-}
+    updatePlayfieldMetrics();
+    updateHitobjectMetrics();
 
-void OsuBeatmap::stop(bool quit) {
-    if(m_selectedDifficulty2 == NULL) return;
+    if(recomputeDrainRate) computeDrainRate();
 
-    if(getSkin()->getFailsound()->isPlaying()) engine->getSound()->stop(getSkin()->getFailsound());
+    if(m_music != NULL) {
+        m_music->setSpeed(m_osu->getSpeedMultiplier());
+    }
 
-    m_currentHitObject = NULL;
+    // recalculate slider vertexbuffers
+    if(m_osu->getModHR() != m_bWasHREnabled ||
+       osu_playfield_mirror_horizontal.getBool() != m_bWasHorizontalMirrorEnabled ||
+       osu_playfield_mirror_vertical.getBool() != m_bWasVerticalMirrorEnabled) {
+        m_bWasHREnabled = m_osu->getModHR();
+        m_bWasHorizontalMirrorEnabled = osu_playfield_mirror_horizontal.getBool();
+        m_bWasVerticalMirrorEnabled = osu_playfield_mirror_vertical.getBool();
 
-    m_bIsPlaying = false;
-    m_bIsPaused = false;
-    m_bContinueScheduled = false;
+        calculateStacks();
 
-    onBeforeStop(quit);
+        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
+    }
+    if(m_osu->getModEZ() != m_bWasEZEnabled) {
+        calculateStacks();
 
-    unloadObjects();
+        m_bWasEZEnabled = m_osu->getModEZ();
+        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
+    }
+    if(getHitcircleDiameter() != m_fPrevHitCircleDiameter && m_hitobjects.size() > 0) {
+        calculateStacks();
 
-    if(bancho.is_playing_a_multi_map()) {
-        if(quit) {
-            m_osu->onPlayEnd(true);
-            m_osu->m_room->ragequit();
-        } else {
-            m_osu->m_room->onClientScoreChange(true);
-            Packet packet;
-            packet.id = FINISH_MATCH;
-            send_packet(packet);
-        }
-    } else {
-        m_osu->onPlayEnd(quit);
+        m_fPrevHitCircleDiameter = getHitcircleDiameter();
+        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
     }
-}
+    if(osu_playfield_rotation.getFloat() != m_fPrevPlayfieldRotationFromConVar) {
+        m_fPrevPlayfieldRotationFromConVar = osu_playfield_rotation.getFloat();
+        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
+    }
+    if(osu_playfield_stretch_x.getFloat() != m_fPrevPlayfieldStretchX) {
+        calculateStacks();
 
-void OsuBeatmap::fail() {
-    if(m_bFailed) return;
+        m_fPrevPlayfieldStretchX = osu_playfield_stretch_x.getFloat();
+        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
+    }
+    if(osu_playfield_stretch_y.getFloat() != m_fPrevPlayfieldStretchY) {
+        calculateStacks();
 
-    // Change behavior of relax mod when online
-    if(bancho.is_online() && m_osu->getModRelax()) return;
+        m_fPrevPlayfieldStretchY = osu_playfield_stretch_y.getFloat();
+        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
+    }
+    if(OsuGameRules::osu_mod_mafham.getBool() != m_bWasMafhamEnabled) {
+        m_bWasMafhamEnabled = OsuGameRules::osu_mod_mafham.getBool();
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            m_hitobjects[i]->update(m_iCurMusicPosWithOffsets);
+        }
+    }
 
-    if(!bancho.is_playing_a_multi_map() && osu_drain_kill.getBool()) {
-        engine->getSound()->play(getSkin()->getFailsound());
+    // recalculate star cache for live pp
+    if(m_osu_draw_statistics_pp_ref->getBool() ||
+       m_osu_draw_statistics_livestars_ref->getBool())  // sanity + performance/usability
+    {
+        bool didCSChange = false;
+        if(getHitcircleDiameter() != m_fPrevHitCircleDiameterForStarCache && m_hitobjects.size() > 0) {
+            m_fPrevHitCircleDiameterForStarCache = getHitcircleDiameter();
+            didCSChange = true;
+        }
 
-        m_bFailed = true;
-        m_fFailAnim = 1.0f;
-        anim->moveLinear(&m_fFailAnim, 0.0f, osu_fail_time.getFloat(),
-                         true);  // trigger music slowdown and delayed menu, see update()
-    } else if(!m_osu->getScore()->isDead()) {
-        anim->deleteExistingAnimation(&m_fHealth2);
-        m_fHealth = 0.0;
-        m_fHealth2 = 0.0f;
+        bool didSpeedChange = false;
+        if(m_osu->getSpeedMultiplier() != m_fPrevSpeedForStarCache && m_hitobjects.size() > 0) {
+            m_fPrevSpeedForStarCache =
+                m_osu->getSpeedMultiplier();  // this is not using the beatmap function for speed on purpose, because
+                                              // that wouldn't work while the music is still loading
+            didSpeedChange = true;
+        }
 
-        if(osu_drain_kill_notification_duration.getFloat() > 0.0f) {
-            if(!m_osu->getScore()->hasDied())
-                m_osu->getNotificationOverlay()->addNotification("You have failed, but you can keep playing!",
-                                                                 0xffffffff, false,
-                                                                 osu_drain_kill_notification_duration.getFloat());
+        if(didCSChange || didSpeedChange) {
+            if(m_selectedDifficulty2 != NULL) updateStarCache();
         }
     }
-
-    if(!m_osu->getScore()->isDead()) m_osu->getScore()->setDead(true);
 }
 
-void OsuBeatmap::cancelFailing() {
-    if(!m_bFailed || m_fFailAnim <= 0.0f) return;
+bool OsuBeatmap::isLoading() {
+    return (isActuallyLoading() || (bancho.is_playing_a_multi_map() && !bancho.room.all_players_loaded));
+}
 
-    m_bFailed = false;
+bool OsuBeatmap::isActuallyLoading() { return (!m_music->isAsyncReady() || m_bIsPreLoading || isLoadingStarCache()); }
 
-    anim->deleteExistingAnimation(&m_fFailAnim);
-    m_fFailAnim = 1.0f;
+Vector2 OsuBeatmap::pixels2OsuCoords(Vector2 pixelCoords) const {
+    // un-first-person
+    if(OsuGameRules::osu_mod_fps.getBool()) {
+        // HACKHACK: this is the worst hack possible (engine->isDrawing()), but it works
+        // the problem is that this same function is called while draw()ing and update()ing
+        if(!((engine->isDrawing() && (m_osu->getModAuto() || m_osu->getModAutopilot())) ||
+             !(m_osu->getModAuto() || m_osu->getModAutopilot())))
+            pixelCoords += getFirstPersonCursorDelta();
+    }
 
-    if(m_music != NULL) m_music->setFrequency(0.0f);
+    // un-offset and un-scale, reverse order
+    pixelCoords -= m_vPlayfieldOffset;
+    pixelCoords /= m_fScaleFactor;
 
-    if(getSkin()->getFailsound()->isPlaying()) engine->getSound()->stop(getSkin()->getFailsound());
+    return pixelCoords;
 }
 
-void OsuBeatmap::setVolume(float volume) {
-    if(m_music != NULL) m_music->setVolume(volume);
-}
+Vector2 OsuBeatmap::osuCoords2Pixels(Vector2 coords) const {
+    if(m_osu->getModHR()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
+    if(osu_playfield_mirror_horizontal.getBool()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
+    if(osu_playfield_mirror_vertical.getBool()) coords.x = OsuGameRules::OSU_COORD_WIDTH - coords.x;
+
+    // wobble
+    if(osu_mod_wobble.getBool()) {
+        const float speedMultiplierCompensation = 1.0f / getSpeedMultiplier();
+        coords.x += std::sin((m_iCurMusicPos / 1000.0f) * 5 * speedMultiplierCompensation *
+                             osu_mod_wobble_frequency.getFloat()) *
+                    osu_mod_wobble_strength.getFloat();
+        coords.y += std::sin((m_iCurMusicPos / 1000.0f) * 4 * speedMultiplierCompensation *
+                             osu_mod_wobble_frequency.getFloat()) *
+                    osu_mod_wobble_strength.getFloat();
+    }
 
-void OsuBeatmap::setSpeed(float speed) {
-    if(m_music != NULL) m_music->setSpeed(speed);
-}
+    // wobble2
+    if(osu_mod_wobble2.getBool()) {
+        const float speedMultiplierCompensation = 1.0f / getSpeedMultiplier();
+        Vector2 centerDelta = coords - Vector2(OsuGameRules::OSU_COORD_WIDTH, OsuGameRules::OSU_COORD_HEIGHT) / 2;
+        coords.x += centerDelta.x * 0.25f *
+                    std::sin((m_iCurMusicPos / 1000.0f) * 5 * speedMultiplierCompensation *
+                             osu_mod_wobble_frequency.getFloat()) *
+                    osu_mod_wobble_strength.getFloat();
+        coords.y += centerDelta.y * 0.25f *
+                    std::sin((m_iCurMusicPos / 1000.0f) * 3 * speedMultiplierCompensation *
+                             osu_mod_wobble_frequency.getFloat()) *
+                    osu_mod_wobble_strength.getFloat();
+    }
 
-void OsuBeatmap::seekPercent(double percent) {
-    if(m_selectedDifficulty2 == NULL || (!m_bIsPlaying && !m_bIsPaused) || m_music == NULL || m_bFailed) return;
+    // rotation
+    if(m_fPlayfieldRotation + osu_playfield_rotation.getFloat() != 0.0f) {
+        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-    m_bWasSeekFrame = true;
-    m_fWaitTime = 0.0f;
+        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
+        Matrix4 rot;
+        rot.rotateZ(m_fPlayfieldRotation + osu_playfield_rotation.getFloat());  // (m_iCurMusicPos/1000.0f)*30
 
-    m_music->setPosition(percent);
-    m_music->setVolume(m_osu_volume_music_ref->getFloat());
-    m_music->setSpeed(m_osu->getSpeedMultiplier());
+        coords3 = coords3 * rot;
+        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-    resetHitObjects(m_music->getPositionMS());
-    resetScore();
+        coords.x = coords3.x;
+        coords.y = coords3.y;
+    }
 
-    m_iPreviousSectionPassFailTime = -1;
+    if(osu_mandala.getBool()) {
+        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-    if(m_bIsWaiting) {
-        m_bIsWaiting = false;
-        m_bIsPlaying = true;
-        m_bIsRestartScheduledQuick = false;
+        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
+        Matrix4 rot;
+        rot.rotateZ((360.0f / osu_mandala_num.getInt()) * (m_iMandalaIndex + 1));  // (m_iCurMusicPos/1000.0f)*30
 
-        engine->getSound()->play(m_music);
+        coords3 = coords3 * rot;
+        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-        onPlayStart();
+        coords.x = coords3.x;
+        coords.y = coords3.y;
     }
 
-    debugLog("Disabling score submission due to seeking\n");
-    vanilla = false;
-}
-
-void OsuBeatmap::seekPercentPlayable(double percent) {
-    if(m_selectedDifficulty2 == NULL || (!m_bIsPlaying && !m_bIsPaused) || m_music == NULL || m_bFailed) return;
-
-    m_bWasSeekFrame = true;
-    m_fWaitTime = 0.0f;
-
-    double actualPlayPercent = percent;
-    if(m_hitobjects.size() > 0)
-        actualPlayPercent = (((double)m_hitobjects[m_hitobjects.size() - 1]->getTime() +
-                              (double)m_hitobjects[m_hitobjects.size() - 1]->getDuration()) *
-                             percent) /
-                            (double)m_music->getLengthMS();
+    // if wobble, clamp coordinates
+    if(osu_mod_wobble.getBool() || osu_mod_wobble2.getBool()) {
+        coords.x = clamp<float>(coords.x, 0.0f, OsuGameRules::OSU_COORD_WIDTH);
+        coords.y = clamp<float>(coords.y, 0.0f, OsuGameRules::OSU_COORD_HEIGHT);
+    }
 
-    seekPercent(actualPlayPercent);
-}
+    if(m_bFailed) {
+        float failTimePercentInv = 1.0f - m_fFailAnim;  // goes from 0 to 1 over the duration of osu_fail_time
+        failTimePercentInv *= failTimePercentInv;
 
-unsigned long OsuBeatmap::getTime() const {
-    if(m_music != NULL && m_music->isAsyncReady())
-        return m_music->getPositionMS();
-    else
-        return 0;
-}
+        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-unsigned long OsuBeatmap::getStartTimePlayable() const {
-    if(m_hitobjects.size() > 0)
-        return (unsigned long)m_hitobjects[0]->getTime();
-    else
-        return 0;
-}
+        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
+        Matrix4 rot;
+        rot.rotateZ(failTimePercentInv * 60.0f);
 
-unsigned long OsuBeatmap::getLength() const {
-    if(m_music != NULL && m_music->isAsyncReady())
-        return m_music->getLengthMS();
-    else if(m_selectedDifficulty2 != NULL)
-        return m_selectedDifficulty2->getLengthMS();
-    else
-        return 0;
-}
+        coords3 = coords3 * rot;
+        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-unsigned long OsuBeatmap::getLengthPlayable() const {
-    if(m_hitobjects.size() > 0)
-        return (unsigned long)((m_hitobjects[m_hitobjects.size() - 1]->getTime() +
-                                m_hitobjects[m_hitobjects.size() - 1]->getDuration()) -
-                               m_hitobjects[0]->getTime());
-    else
-        return getLength();
-}
+        coords.x = coords3.x + failTimePercentInv * OsuGameRules::OSU_COORD_WIDTH * 0.25f;
+        coords.y = coords3.y + failTimePercentInv * OsuGameRules::OSU_COORD_HEIGHT * 1.25f;
+    }
 
-float OsuBeatmap::getPercentFinished() const {
-    if(m_music != NULL)
-        return (float)m_iCurMusicPos / (float)m_music->getLengthMS();
-    else
-        return 0.0f;
-}
+    // playfield stretching/transforming
+    coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;  // center
+    coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
+    {
+        if(osu_playfield_circular.getBool()) {
+            // normalize to -1 +1
+            coords.x /= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
+            coords.y /= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
+
+            // clamp (for sqrt) and transform
+            coords.x = clamp<float>(coords.x, -1.0f, 1.0f);
+            coords.y = clamp<float>(coords.y, -1.0f, 1.0f);
+            coords = mapNormalizedCoordsOntoUnitCircle(coords);
+
+            // and scale back up
+            coords.x *= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
+            coords.y *= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
+        }
 
-float OsuBeatmap::getPercentFinishedPlayable() const {
-    if(m_bIsWaiting) return 1.0f - (m_fWaitTime - engine->getTimeReal()) / (osu_early_note_time.getFloat() / 1000.0f);
+        // stretch
+        coords.x *= 1.0f + osu_playfield_stretch_x.getFloat();
+        coords.y *= 1.0f + osu_playfield_stretch_y.getFloat();
+    }
+    coords.x += OsuGameRules::OSU_COORD_WIDTH / 2;  // undo center
+    coords.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
+
+    // scale and offset
+    coords *= m_fScaleFactor;
+    coords += m_vPlayfieldOffset;  // the offset is already scaled, just add it
+
+    // first person mod, centered cursor
+    if(OsuGameRules::osu_mod_fps.getBool()) {
+        // this is the worst hack possible (engine->isDrawing()), but it works
+        // the problem is that this same function is called while draw()ing and update()ing
+        if((engine->isDrawing() && (m_osu->getModAuto() || m_osu->getModAutopilot())) ||
+           !(m_osu->getModAuto() || m_osu->getModAutopilot()))
+            coords += getFirstPersonCursorDelta();
+    }
 
-    if(m_hitobjects.size() > 0)
-        return (float)m_iCurMusicPos / ((float)m_hitobjects[m_hitobjects.size() - 1]->getTime() +
-                                        (float)m_hitobjects[m_hitobjects.size() - 1]->getDuration());
-    else
-        return (float)m_iCurMusicPos / (float)m_music->getLengthMS();
+    return coords;
 }
 
-int OsuBeatmap::getMostCommonBPM() const {
-    if(m_selectedDifficulty2 != NULL) {
-        if(m_music != NULL)
-            return (int)(m_selectedDifficulty2->getMostCommonBPM() * m_music->getSpeed());
-        else
-            return (int)(m_selectedDifficulty2->getMostCommonBPM() * m_osu->getSpeedMultiplier());
-    } else
-        return 0;
-}
+Vector2 OsuBeatmap::osuCoords2RawPixels(Vector2 coords) const {
+    // scale and offset
+    coords *= m_fScaleFactor;
+    coords += m_vPlayfieldOffset;  // the offset is already scaled, just add it
 
-float OsuBeatmap::getSpeedMultiplier() const {
-    if(m_music != NULL)
-        return std::max(m_music->getSpeed(), 0.05f);
-    else
-        return 1.0f;
+    return coords;
 }
 
-OsuSkin *OsuBeatmap::getSkin() const { return m_osu->getSkin(); }
+Vector2 OsuBeatmap::osuCoords2LegacyPixels(Vector2 coords) const {
+    if(m_osu->getModHR()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
+    if(osu_playfield_mirror_horizontal.getBool()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
+    if(osu_playfield_mirror_vertical.getBool()) coords.x = OsuGameRules::OSU_COORD_WIDTH - coords.x;
 
-float OsuBeatmap::getRawAR() const {
-    if(m_selectedDifficulty2 == NULL) return 5.0f;
+    // rotation
+    if(m_fPlayfieldRotation + osu_playfield_rotation.getFloat() != 0.0f) {
+        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-    return clamp<float>(m_selectedDifficulty2->getAR() * m_osu->getDifficultyMultiplier(), 0.0f, 10.0f);
-}
+        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
+        Matrix4 rot;
+        rot.rotateZ(m_fPlayfieldRotation + osu_playfield_rotation.getFloat());
 
-float OsuBeatmap::getAR() const {
-    if(m_selectedDifficulty2 == NULL) return 5.0f;
+        coords3 = coords3 * rot;
+        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
+        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-    float AR = getRawAR();
+        coords.x = coords3.x;
+        coords.y = coords3.y;
+    }
 
-    if(osu_ar_override.getFloat() >= 0.0f) AR = osu_ar_override.getFloat();
+    // VR center
+    coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
+    coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
 
-    if(osu_ar_overridenegative.getFloat() < 0.0f) AR = osu_ar_overridenegative.getFloat();
+    if(osu_playfield_circular.getBool()) {
+        // normalize to -1 +1
+        coords.x /= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
+        coords.y /= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
 
-    if(osu_ar_override_lock.getBool())
-        AR = OsuGameRules::getRawConstantApproachRateForSpeedMultiplier(
-            OsuGameRules::getRawApproachTime(AR),
-            (m_music != NULL && m_bIsPlaying ? getSpeedMultiplier() : m_osu->getSpeedMultiplier()));
+        // clamp (for sqrt) and transform
+        coords.x = clamp<float>(coords.x, -1.0f, 1.0f);
+        coords.y = clamp<float>(coords.y, -1.0f, 1.0f);
+        coords = mapNormalizedCoordsOntoUnitCircle(coords);
 
-    if(osu_mod_artimewarp.getBool() && m_hitobjects.size() > 0) {
-        const float percent =
-            1.0f - ((double)(m_iCurMusicPos - m_hitobjects[0]->getTime()) /
-                    (double)(m_hitobjects[m_hitobjects.size() - 1]->getTime() +
-                             m_hitobjects[m_hitobjects.size() - 1]->getDuration() - m_hitobjects[0]->getTime())) *
-                       (1.0f - osu_mod_artimewarp_multiplier.getFloat());
-        AR *= percent;
+        // and scale back up
+        coords.x *= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
+        coords.y *= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
     }
 
-    if(osu_mod_arwobble.getBool())
-        AR += std::sin((m_iCurMusicPos / 1000.0f) * osu_mod_arwobble_interval.getFloat()) *
-              osu_mod_arwobble_strength.getFloat();
+    // VR scale
+    coords.x *= 1.0f + osu_playfield_stretch_x.getFloat();
+    coords.y *= 1.0f + osu_playfield_stretch_y.getFloat();
 
-    return AR;
+    return coords;
 }
 
-float OsuBeatmap::getCS() const {
-    if(m_selectedDifficulty2 == NULL) return 5.0f;
-
-    float CS = clamp<float>(m_selectedDifficulty2->getCS() * m_osu->getCSDifficultyMultiplier(), 0.0f, 10.0f);
-
-    if(osu_cs_override.getFloat() >= 0.0f) CS = osu_cs_override.getFloat();
-
-    if(osu_cs_overridenegative.getFloat() < 0.0f) CS = osu_cs_overridenegative.getFloat();
-
-    if(osu_mod_minimize.getBool() && m_hitobjects.size() > 0) {
-        if(m_hitobjects.size() > 0) {
-            const float percent =
-                1.0f + ((double)(m_iCurMusicPos - m_hitobjects[0]->getTime()) /
-                        (double)(m_hitobjects[m_hitobjects.size() - 1]->getTime() +
-                                 m_hitobjects[m_hitobjects.size() - 1]->getDuration() - m_hitobjects[0]->getTime())) *
-                           osu_mod_minimize_multiplier.getFloat();
-            CS *= percent;
-        }
+Vector2 OsuBeatmap::getCursorPos() const {
+    if(OsuGameRules::osu_mod_fps.getBool() && !m_bIsPaused) {
+        if(m_osu->getModAuto() || m_osu->getModAutopilot())
+            return m_vAutoCursorPos;
+        else
+            return m_vPlayfieldCenter;
+    } else if(m_osu->getModAuto() || m_osu->getModAutopilot())
+        return m_vAutoCursorPos;
+    else {
+        Vector2 pos = engine->getMouse()->getPos();
+        if(osu_mod_shirone.getBool() && m_osu->getScore()->getCombo() > 0)  // <3
+            return pos + Vector2(std::sin((m_iCurMusicPos / 20.0f) * 1.15f) *
+                                     ((float)m_osu->getScore()->getCombo() / osu_mod_shirone_combo.getFloat()),
+                                 std::cos((m_iCurMusicPos / 20.0f) * 1.3f) *
+                                     ((float)m_osu->getScore()->getCombo() / osu_mod_shirone_combo.getFloat()));
+        else
+            return pos;
     }
-
-    if(osu_cs_cap_sanity.getBool()) CS = std::min(CS, 12.1429f);
-
-    return CS;
 }
 
-float OsuBeatmap::getHP() const {
-    if(m_selectedDifficulty2 == NULL) return 5.0f;
-
-    float HP = clamp<float>(m_selectedDifficulty2->getHP() * m_osu->getDifficultyMultiplier(), 0.0f, 10.0f);
-    if(osu_hp_override.getFloat() >= 0.0f) HP = osu_hp_override.getFloat();
-
-    return HP;
+Vector2 OsuBeatmap::getFirstPersonCursorDelta() const {
+    return m_vPlayfieldCenter -
+           (m_osu->getModAuto() || m_osu->getModAutopilot() ? m_vAutoCursorPos : engine->getMouse()->getPos());
 }
 
-float OsuBeatmap::getRawOD() const {
-    if(m_selectedDifficulty2 == NULL) return 5.0f;
+float OsuBeatmap::getHitcircleDiameter() const { return m_fHitcircleDiameter; }
 
-    return clamp<float>(m_selectedDifficulty2->getOD() * m_osu->getDifficultyMultiplier(), 0.0f, 10.0f);
+void OsuBeatmap::onPlayStart() {
+    debugLog("OsuBeatmap::onPlayStart()\n");
+
+    // if there are calculations in there that need the hitobjects to be loaded, also applies speed/pitch
+    onModUpdate(false, false);
 }
 
-float OsuBeatmap::getOD() const {
-    float OD = getRawOD();
+void OsuBeatmap::onBeforeStop(bool quit) {
+    debugLog("OsuBeatmap::onBeforeStop()\n");
+
+    // kill any running star cache loader
+    stopStarCacheLoader();
+
+    // calculate stars
+    double aim = 0.0;
+    double aimSliderFactor = 0.0;
+    double speed = 0.0;
+    double speedNotes = 0.0;
+    const std::string &osuFilePath = m_selectedDifficulty2->getFilePath();
+    const float AR = getAR();
+    const float CS = getCS();
+    const float OD = getOD();
+    const float speedMultiplier = m_osu->getSpeedMultiplier();  // NOTE: not this->getSpeedMultiplier()!
+    const bool relax = m_osu->getModRelax();
+    const bool touchDevice = m_osu->getModTD();
+
+    OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres =
+        OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, AR, CS, speedMultiplier);
+    const double totalStars = OsuDifficultyCalculator::calculateStarDiffForHitObjects(
+        diffres.diffobjects, CS, OD, speedMultiplier, relax, touchDevice, &aim, &aimSliderFactor, &speed, &speedNotes);
+
+    m_fAimStars = (float)aim;
+    m_fSpeedStars = (float)speed;
+
+    // calculate final pp
+    const int numHitObjects = m_hitobjects.size();
+    const int numCircles = m_selectedDifficulty2->getNumCircles();
+    const int numSliders = m_selectedDifficulty2->getNumSliders();
+    const int numSpinners = m_selectedDifficulty2->getNumSpinners();
+    const int maxPossibleCombo = m_iMaxPossibleCombo;
+    const int highestCombo = m_osu->getScore()->getComboMax();
+    const int numMisses = m_osu->getScore()->getNumMisses();
+    const int num300s = m_osu->getScore()->getNum300s();
+    const int num100s = m_osu->getScore()->getNum100s();
+    const int num50s = m_osu->getScore()->getNum50s();
+    const float pp = OsuDifficultyCalculator::calculatePPv2(
+        m_osu, this, aim, aimSliderFactor, speed, speedNotes, numHitObjects, numCircles, numSliders, numSpinners,
+        maxPossibleCombo, highestCombo, numMisses, num300s, num100s, num50s);
+    m_osu->getScore()->setStarsTomTotal(totalStars);
+    m_osu->getScore()->setStarsTomAim(m_fAimStars);
+    m_osu->getScore()->setStarsTomSpeed(m_fSpeedStars);
+    m_osu->getScore()->setPPv2(pp);
+
+    // save local score, but only under certain conditions
+    const bool isComplete = (num300s + num100s + num50s + numMisses >= numHitObjects);
+    const bool isZero = (m_osu->getScore()->getScore() < 1);
+    const bool isCheated =
+        (m_osu->getModAuto() || (m_osu->getModAutopilot() && m_osu->getModRelax())) || m_osu->getScore()->isUnranked();
+
+    OsuDatabase::Score score;
+    score.isLegacyScore = false;
+    score.isImportedLegacyScore = false;
+    score.version = OsuScore::VERSION;
+    score.unixTimestamp =
+        std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
+
+    if(bancho.is_online()) {
+        score.player_id = bancho.user_id;
+        score.playerName = bancho.username;
+    } else {
+        score.playerName = convar->getConVarByName("name")->getString();
+    }
+    score.passed = isComplete && !isZero && !m_osu->getScore()->hasDied();
+    score.grade = score.passed ? m_osu->getScore()->getGrade() : OsuScore::GRADE::GRADE_F;
+    score.diff2 = m_selectedDifficulty2;
+    score.ragequit = quit;
+    score.play_time_ms = m_iCurMusicPos / m_osu->getSpeedMultiplier();
+
+    score.num300s = m_osu->getScore()->getNum300s();
+    score.num100s = m_osu->getScore()->getNum100s();
+    score.num50s = m_osu->getScore()->getNum50s();
+    score.numGekis = m_osu->getScore()->getNum300gs();
+    score.numKatus = m_osu->getScore()->getNum100ks();
+    score.numMisses = m_osu->getScore()->getNumMisses();
+    score.score = m_osu->getScore()->getScore();
+    score.comboMax = m_osu->getScore()->getComboMax();
+    score.perfect = (maxPossibleCombo > 0 && score.comboMax > 0 && score.comboMax >= maxPossibleCombo);
+    score.numSliderBreaks = m_osu->getScore()->getNumSliderBreaks();
+    score.pp = pp;
+    score.unstableRate = m_osu->getScore()->getUnstableRate();
+    score.hitErrorAvgMin = m_osu->getScore()->getHitErrorAvgMin();
+    score.hitErrorAvgMax = m_osu->getScore()->getHitErrorAvgMax();
+    score.starsTomTotal = totalStars;
+    score.starsTomAim = aim;
+    score.starsTomSpeed = speed;
+    score.speedMultiplier = m_osu->getSpeedMultiplier();
+    score.CS = CS;
+    score.AR = AR;
+    score.OD = getOD();
+    score.HP = getHP();
+    score.maxPossibleCombo = maxPossibleCombo;
+    score.numHitObjects = numHitObjects;
+    score.numCircles = numCircles;
+    score.modsLegacy = m_osu->getScore()->getModsLegacy();
+
+    // special case: manual slider accuracy has been enabled (affects pp but not score), so force scorev2 for
+    // potential future score recalculations
+    score.modsLegacy |= (m_osu_slider_scorev2_ref->getBool() ? ModFlags::ScoreV2 : 0);
+
+    std::vector<ConVar *> allExperimentalMods = m_osu->getExperimentalMods();
+    for(int i = 0; i < allExperimentalMods.size(); i++) {
+        if(allExperimentalMods[i]->getBool()) {
+            score.experimentalModsConVars.append(allExperimentalMods[i]->getName());
+            score.experimentalModsConVars.append(";");
+        }
+    }
 
-    if(osu_od_override.getFloat() >= 0.0f) OD = osu_od_override.getFloat();
+    score.md5hash = m_selectedDifficulty2->getMD5Hash();  // NOTE: necessary for "Use Mods"
 
-    if(osu_od_override_lock.getBool())
-        OD = OsuGameRules::getRawConstantOverallDifficultyForSpeedMultiplier(
-            OsuGameRules::getRawHitWindow300(OD),
-            (m_music != NULL && m_bIsPlaying ? getSpeedMultiplier() : m_osu->getSpeedMultiplier()));
+    int scoreIndex = -1;
 
-    return OD;
-}
+    if(!isCheated) {
+        OsuRichPresence::onPlayEnd(m_osu, quit);
 
-bool OsuBeatmap::isKey1Down() {
-    if(m_bIsWatchingReplay) {
-        return current_keys & (OsuReplay::M1 | OsuReplay::K1);
-    } else {
-        return m_bClick1Held;
+        if(bancho.submit_scores() && !isZero && vanilla) {
+            score.replay = replay;
+            submit_score(score);
+        }
+
+        if(score.passed) {
+            scoreIndex = m_osu->getSongBrowser()->getDatabase()->addScore(m_selectedDifficulty2->getMD5Hash(), score);
+            if(scoreIndex == -1) {
+                m_osu->getNotificationOverlay()->addNotification("Failed saving score!", 0xffff0000, false, 3.0f);
+            }
+        }
     }
-}
 
-bool OsuBeatmap::isKey2Down() {
-    if(m_bIsWatchingReplay) {
-        return current_keys & (OsuReplay::M2 | OsuReplay::K2);
-    } else {
-        return m_bClick2Held;
+    m_osu->getScore()->setIndex(scoreIndex);
+    m_osu->getScore()->setComboFull(maxPossibleCombo);  // used in OsuRankingScreen/OsuUIRankingScreenRankingPanel
+
+    // special case: incomplete scores should NEVER show pp, even if auto
+    if(!isComplete) {
+        m_osu->getScore()->setPPv2(0.0f);
     }
+
+    debugLog("OsuBeatmap::onBeforeStop() done.\n");
 }
 
-bool OsuBeatmap::isClickHeld() {
-    if(m_bIsWatchingReplay) {
-        return current_keys & (OsuReplay::M1 | OsuReplay::K1 | OsuReplay::M2 | OsuReplay::K2);
-    } else {
-        return m_bClick1Held || m_bClick2Held;
+void OsuBeatmap::onPaused(bool first) {
+    debugLog("OsuBeatmap::onPaused()\n");
+
+    if(first) {
+        m_vContinueCursorPoint = engine->getMouse()->getPos();
+
+        if(OsuGameRules::osu_mod_fps.getBool()) m_vContinueCursorPoint = OsuGameRules::getPlayfieldCenter(m_osu);
     }
 }
 
-bool OsuBeatmap::isLastKeyDownKey1() {
-    if(m_bIsWatchingReplay) {
-        return last_keys & (OsuReplay::M1 | OsuReplay::K1);
-    } else {
-        return m_bPrevKeyWasKey1;
+void OsuBeatmap::updateAutoCursorPos() {
+    m_vAutoCursorPos = m_vPlayfieldCenter;
+    m_vAutoCursorPos.y *= 2.5f;  // start moving in offscreen from bottom
+
+    if(!m_bIsPlaying && !m_bIsPaused) {
+        m_vAutoCursorPos = m_vPlayfieldCenter;
+        return;
+    }
+    if(m_hitobjects.size() < 1) {
+        m_vAutoCursorPos = m_vPlayfieldCenter;
+        return;
     }
-}
 
-UString OsuBeatmap::getTitle() const {
-    if(m_selectedDifficulty2 != NULL)
-        return m_selectedDifficulty2->getTitle();
-    else
-        return "NULL";
-}
+    const long curMusicPos = m_iCurMusicPosWithOffsets;
 
-UString OsuBeatmap::getArtist() const {
-    if(m_selectedDifficulty2 != NULL)
-        return m_selectedDifficulty2->getArtist();
-    else
-        return "NULL";
-}
+    // general
+    long prevTime = 0;
+    long nextTime = m_hitobjects[0]->getTime();
+    Vector2 prevPos = m_vAutoCursorPos;
+    Vector2 curPos = m_vAutoCursorPos;
+    Vector2 nextPos = m_vAutoCursorPos;
+    bool haveCurPos = false;
 
-unsigned long OsuBeatmap::getBreakDurationTotal() const {
-    unsigned long breakDurationTotal = 0;
-    for(int i = 0; i < m_breaks.size(); i++) {
-        breakDurationTotal += (unsigned long)(m_breaks[i].endTime - m_breaks[i].startTime);
-    }
+    // dance
+    int nextPosIndex = 0;
 
-    return breakDurationTotal;
-}
+    if(m_hitobjects[0]->getTime() < (long)m_osu_early_note_time_ref->getInt())
+        prevTime = -(long)m_osu_early_note_time_ref->getInt() * getSpeedMultiplier();
 
-OsuDatabaseBeatmap::BREAK OsuBeatmap::getBreakForTimeRange(long startMS, long positionMS, long endMS) const {
-    OsuDatabaseBeatmap::BREAK curBreak;
+    if(m_osu->getModAuto()) {
+        bool autoDanceOverride = false;
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            OsuHitObject *o = m_hitobjects[i];
+
+            // get previous object
+            if(o->getTime() <= curMusicPos) {
+                prevTime = o->getTime() + o->getDuration();
+                prevPos = o->getAutoCursorPos(curMusicPos);
+                if(o->getDuration() > 0 && curMusicPos - o->getTime() <= o->getDuration()) {
+                    if(osu_auto_cursordance.getBool()) {
+                        OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(o);
+                        if(sliderPointer != NULL) {
+                            const std::vector<OsuSlider::SLIDERCLICK> &clicks = sliderPointer->getClicks();
+
+                            // start
+                            prevTime = o->getTime();
+                            prevPos = osuCoords2Pixels(o->getRawPosAt(prevTime));
+
+                            long biggestPrevious = 0;
+                            long smallestNext = std::numeric_limits<long>::max();
+                            bool allFinished = true;
+                            long endTime = 0;
+
+                            // middle clicks
+                            for(int c = 0; c < clicks.size(); c++) {
+                                // get previous click
+                                if(clicks[c].time <= curMusicPos && clicks[c].time > biggestPrevious) {
+                                    biggestPrevious = clicks[c].time;
+                                    prevTime = clicks[c].time;
+                                    prevPos = osuCoords2Pixels(o->getRawPosAt(prevTime));
+                                }
 
-    curBreak.startTime = -1;
-    curBreak.endTime = -1;
+                                // get next click
+                                if(clicks[c].time > curMusicPos && clicks[c].time < smallestNext) {
+                                    smallestNext = clicks[c].time;
+                                    nextTime = clicks[c].time;
+                                    nextPos = osuCoords2Pixels(o->getRawPosAt(nextTime));
+                                }
 
-    for(int i = 0; i < m_breaks.size(); i++) {
-        if(m_breaks[i].startTime >= (int)startMS && m_breaks[i].endTime <= (int)endMS) {
-            if((int)positionMS >= curBreak.startTime) curBreak = m_breaks[i];
-        }
-    }
+                                // end hack
+                                if(!clicks[c].finished)
+                                    allFinished = false;
+                                else if(clicks[c].time > endTime)
+                                    endTime = clicks[c].time;
+                            }
 
-    return curBreak;
-}
+                            // end
+                            if(allFinished) {
+                                // hack for slider without middle clicks
+                                if(endTime == 0) endTime = o->getTime();
 
-OsuScore::HIT OsuBeatmap::addHitResult(OsuHitObject *hitObject, OsuScore::HIT hit, long delta, bool isEndOfCombo,
-                                       bool ignoreOnHitErrorBar, bool hitErrorBarOnly, bool ignoreCombo,
-                                       bool ignoreScore, bool ignoreHealth) {
-    // Frames are already written on every keypress/release.
-    // For some edge cases, we need to write extra frames to avoid replaybugs.
-    {
-        bool should_write_frame = false;
+                                prevTime = endTime;
+                                prevPos = osuCoords2Pixels(o->getRawPosAt(prevTime));
+                                nextTime = o->getTime() + o->getDuration();
+                                nextPos = osuCoords2Pixels(o->getRawPosAt(nextTime));
+                            }
 
-        // Slider interactions
-        // Surely buzz sliders won't be an issue... Clueless
-        should_write_frame |= (hit == OsuScore::HIT::HIT_SLIDER10);
-        should_write_frame |= (hit == OsuScore::HIT::HIT_SLIDER30);
-        should_write_frame |= (hit == OsuScore::HIT::HIT_MISS_SLIDERBREAK);
+                            haveCurPos = false;
+                            autoDanceOverride = true;
+                            break;
+                        }
+                    }
 
-        // Relax: no keypresses, instead we write on every hitresult
-        if(m_osu->getModRelax()) {
-            should_write_frame |= (hit == OsuScore::HIT::HIT_50);
-            should_write_frame |= (hit == OsuScore::HIT::HIT_100);
-            should_write_frame |= (hit == OsuScore::HIT::HIT_300);
-            should_write_frame |= (hit == OsuScore::HIT::HIT_MISS);
+                    haveCurPos = true;
+                    curPos = prevPos;
+                    break;
+                }
+            }
+
+            // get next object
+            if(o->getTime() > curMusicPos) {
+                nextPosIndex = i;
+                if(!autoDanceOverride) {
+                    nextPos = o->getAutoCursorPos(curMusicPos);
+                    nextTime = o->getTime();
+                }
+                break;
+            }
         }
+    } else if(m_osu->getModAutopilot()) {
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            OsuHitObject *o = m_hitobjects[i];
+
+            // get previous object
+            if(o->isFinished() ||
+               (curMusicPos > o->getTime() + o->getDuration() +
+                                  (long)(OsuGameRules::getHitWindow50(this) * osu_autopilot_lenience.getFloat()))) {
+                prevTime = o->getTime() + o->getDuration() + o->getAutopilotDelta();
+                prevPos = o->getAutoCursorPos(curMusicPos);
+            } else if(!o->isFinished())  // get next object
+            {
+                nextPosIndex = i;
+                nextPos = o->getAutoCursorPos(curMusicPos);
+                nextTime = o->getTime();
+
+                // wait for the user to click
+                if(curMusicPos >= nextTime + o->getDuration()) {
+                    haveCurPos = true;
+                    curPos = nextPos;
+
+                    // long delta = curMusicPos - (nextTime + o->getDuration());
+                    o->setAutopilotDelta(curMusicPos - (nextTime + o->getDuration()));
+                } else if(o->getDuration() > 0 && curMusicPos >= nextTime)  // handle objects with duration
+                {
+                    haveCurPos = true;
+                    curPos = nextPos;
+                    o->setAutopilotDelta(0);
+                }
 
-        OsuBeatmapStandard *beatmap = (OsuBeatmapStandard *)m_osu->getSelectedBeatmap();
-        if(should_write_frame && !hitErrorBarOnly && beatmap != nullptr) {
-            beatmap->write_frame();
+                break;
+            }
         }
     }
 
-    // handle perfect & sudden death
-    if(m_osu->getModSS()) {
-        if(!hitErrorBarOnly && hit != OsuScore::HIT::HIT_300 && hit != OsuScore::HIT::HIT_300G &&
-           hit != OsuScore::HIT::HIT_SLIDER10 && hit != OsuScore::HIT::HIT_SLIDER30 &&
-           hit != OsuScore::HIT::HIT_SPINNERSPIN && hit != OsuScore::HIT::HIT_SPINNERBONUS) {
-            restart();
-            return OsuScore::HIT::HIT_MISS;
+    if(haveCurPos)  // in active hitObject
+        m_vAutoCursorPos = curPos;
+    else {
+        // interpolation
+        float percent = 1.0f;
+        if((nextTime == 0 && prevTime == 0) || (nextTime - prevTime) == 0)
+            percent = 1.0f;
+        else
+            percent = (float)((long)curMusicPos - prevTime) / (float)(nextTime - prevTime);
+
+        percent = clamp<float>(percent, 0.0f, 1.0f);
+
+        // scaled distance (not osucoords)
+        float distance = (nextPos - prevPos).length();
+        if(distance > m_fHitcircleDiameter * 1.05f)  // snap only if not in a stream (heuristic)
+        {
+            int numIterations = clamp<int>(m_osu->getModAutopilot() ? osu_autopilot_snapping_strength.getInt()
+                                                                    : osu_auto_snapping_strength.getInt(),
+                                           0, 42);
+            for(int i = 0; i < numIterations; i++) {
+                percent = (-percent) * (percent - 2.0f);
+            }
+        } else  // in a stream
+        {
+            m_iAutoCursorDanceIndex = nextPosIndex;
         }
-    } else if(m_osu->getModSD()) {
-        if(hit == OsuScore::HIT::HIT_MISS) {
-            if(osu_mod_suddendeath_restart.getBool() && !bancho.is_in_a_multi_room())
-                restart();
-            else
-                fail();
 
-            return OsuScore::HIT::HIT_MISS;
+        m_vAutoCursorPos = prevPos + (nextPos - prevPos) * percent;
+
+        if(osu_auto_cursordance.getBool() && !m_osu->getModAutopilot()) {
+            Vector3 dir = Vector3(nextPos.x, nextPos.y, 0) - Vector3(prevPos.x, prevPos.y, 0);
+            Vector3 center = dir * 0.5f;
+            Matrix4 worldMatrix;
+            worldMatrix.translate(center);
+            worldMatrix.rotate((1.0f - percent) * 180.0f * (m_iAutoCursorDanceIndex % 2 == 0 ? 1 : -1), 0, 0, 1);
+            Vector3 fancyAutoCursorPos = worldMatrix * center;
+            m_vAutoCursorPos =
+                prevPos + (nextPos - prevPos) * 0.5f + Vector2(fancyAutoCursorPos.x, fancyAutoCursorPos.y);
         }
     }
+}
 
-    // miss sound
-    if(hit == OsuScore::HIT::HIT_MISS) playMissSound();
-
-    // score
-    m_osu->getScore()->addHitResult(this, hitObject, hit, delta, ignoreOnHitErrorBar, hitErrorBarOnly, ignoreCombo,
-                                    ignoreScore);
+void OsuBeatmap::updatePlayfieldMetrics() {
+    m_fScaleFactor = OsuGameRules::getPlayfieldScaleFactor(m_osu);
+    m_vPlayfieldSize = OsuGameRules::getPlayfieldSize(m_osu);
+    m_vPlayfieldOffset = OsuGameRules::getPlayfieldOffset(m_osu);
+    m_vPlayfieldCenter = OsuGameRules::getPlayfieldCenter(m_osu);
+}
 
-    // health
-    OsuScore::HIT returnedHit = OsuScore::HIT::HIT_MISS;
-    if(!ignoreHealth) {
-        addHealth(m_osu->getScore()->getHealthIncrease(this, hit), true);
+void OsuBeatmap::updateHitobjectMetrics() {
+    OsuSkin *skin = m_osu->getSkin();
+
+    m_fRawHitcircleDiameter = OsuGameRules::getRawHitCircleDiameter(getCS());
+    m_fXMultiplier = OsuGameRules::getHitCircleXMultiplier(m_osu);
+    m_fHitcircleDiameter = OsuGameRules::getHitCircleDiameter(this);
+
+    const float osuCoordScaleMultiplier = (getHitcircleDiameter() / m_fRawHitcircleDiameter);
+    m_fNumberScale = (m_fRawHitcircleDiameter / (160.0f * (skin->isDefault12x() ? 2.0f : 1.0f))) *
+                     osuCoordScaleMultiplier * osu_number_scale_multiplier.getFloat();
+    m_fHitcircleOverlapScale =
+        (m_fRawHitcircleDiameter / (160.0f)) * osuCoordScaleMultiplier * osu_number_scale_multiplier.getFloat();
+
+    const float followcircle_size_multiplier = OsuGameRules::osu_slider_followcircle_size_multiplier.getFloat();
+    const float sliderFollowCircleDiameterMultiplier =
+        (m_osu->getModNightmare() || osu_mod_jigsaw2.getBool()
+             ? (1.0f * (1.0f - osu_mod_jigsaw_followcircle_radius_factor.getFloat()) +
+                osu_mod_jigsaw_followcircle_radius_factor.getFloat() * followcircle_size_multiplier)
+             : followcircle_size_multiplier);
+    m_fRawSliderFollowCircleDiameter = m_fRawHitcircleDiameter * sliderFollowCircleDiameterMultiplier;
+    m_fSliderFollowCircleDiameter = getHitcircleDiameter() * sliderFollowCircleDiameterMultiplier;
+}
 
-        // geki/katu handling
-        if(isEndOfCombo) {
-            const int comboEndBitmask = m_osu->getScore()->getComboEndBitmask();
+void OsuBeatmap::updateSliderVertexBuffers() {
+    updatePlayfieldMetrics();
+    updateHitobjectMetrics();
 
-            if(comboEndBitmask == 0) {
-                returnedHit = OsuScore::HIT::HIT_300G;
-                addHealth(m_osu->getScore()->getHealthIncrease(this, returnedHit), true);
-                m_osu->getScore()->addHitResultComboEnd(returnedHit);
-            } else if((comboEndBitmask & 2) == 0) {
-                switch(hit) {
-                    case OsuScore::HIT::HIT_100:
-                        returnedHit = OsuScore::HIT::HIT_100K;
-                        addHealth(m_osu->getScore()->getHealthIncrease(this, returnedHit), true);
-                        m_osu->getScore()->addHitResultComboEnd(returnedHit);
-                        break;
+    m_bWasEZEnabled = m_osu->getModEZ();                // to avoid useless double updates in onModUpdate()
+    m_fPrevHitCircleDiameter = getHitcircleDiameter();  // same here
+    m_fPrevPlayfieldRotationFromConVar = osu_playfield_rotation.getFloat();  // same here
+    m_fPrevPlayfieldStretchX = osu_playfield_stretch_x.getFloat();           // same here
+    m_fPrevPlayfieldStretchY = osu_playfield_stretch_y.getFloat();           // same here
 
-                    case OsuScore::HIT::HIT_300:
-                        returnedHit = OsuScore::HIT::HIT_300K;
-                        addHealth(m_osu->getScore()->getHealthIncrease(this, returnedHit), true);
-                        m_osu->getScore()->addHitResultComboEnd(returnedHit);
-                        break;
-                }
-            } else if(hit != OsuScore::HIT::HIT_MISS)
-                addHealth(m_osu->getScore()->getHealthIncrease(this, OsuScore::HIT::HIT_MU), true);
+    debugLog("OsuBeatmap::updateSliderVertexBuffers() for %i hitobjects ...\n", m_hitobjects.size());
 
-            m_osu->getScore()->setComboEndBitmask(0);
-        }
+    for(int i = 0; i < m_hitobjects.size(); i++) {
+        OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[i]);
+        if(sliderPointer != NULL) sliderPointer->rebuildVertexBuffer();
     }
-
-    return returnedHit;
 }
 
-void OsuBeatmap::addSliderBreak() {
-    // handle perfect & sudden death
-    if(m_osu->getModSS()) {
-        restart();
-        return;
-    } else if(m_osu->getModSD()) {
-        if(osu_mod_suddendeath_restart.getBool())
-            restart();
-        else
-            fail();
+void OsuBeatmap::calculateStacks() {
+    if(!osu_stacking.getBool()) return;
 
-        return;
+    updateHitobjectMetrics();
+
+    debugLog("OsuBeatmap: Calculating stacks ...\n");
+
+    // reset
+    for(int i = 0; i < m_hitobjects.size(); i++) {
+        m_hitobjects[i]->setStack(0);
     }
 
-    // miss sound
-    playMissSound();
+    const float STACK_LENIENCE = 3.0f;
+    const float STACK_OFFSET = 0.05f;
 
-    // score
-    m_osu->getScore()->addSliderBreak();
-}
+    const float approachTime = OsuGameRules::getApproachTimeForStacking(this);
 
-void OsuBeatmap::addScorePoints(int points, bool isSpinner) { m_osu->getScore()->addPoints(points, isSpinner); }
+    const float stackLeniency =
+        (osu_stacking_leniency_override.getFloat() >= 0.0f ? osu_stacking_leniency_override.getFloat()
+                                                           : m_selectedDifficulty2->getStackLeniency());
 
-void OsuBeatmap::addHealth(double percent, bool isFromHitResult) {
-    const int drainType = osu_drain_type.getInt();
-    if(drainType < 2) return;
+    if(getSelectedDifficulty2()->getVersion() > 5) {
+        // peppy's algorithm
+        // https://gist.github.com/peppy/1167470
 
-    // never drain before first hitobject
-    if(m_hitobjects.size() > 0 && m_iCurMusicPosWithOffsets < m_hitobjects[0]->getTime()) return;
+        for(int i = m_hitobjects.size() - 1; i >= 0; i--) {
+            int n = i;
 
-    // never drain after last hitobject
-    if(m_hitobjectsSortedByEndTime.size() > 0 &&
-       m_iCurMusicPosWithOffsets > (m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getTime() +
-                                    m_hitobjectsSortedByEndTime[m_hitobjectsSortedByEndTime.size() - 1]->getDuration()))
-        return;
+            OsuHitObject *objectI = m_hitobjects[i];
 
-    if(m_bFailed) {
-        anim->deleteExistingAnimation(&m_fHealth2);
+            bool isSpinner = dynamic_cast<OsuSpinner *>(objectI) != NULL;
 
-        m_fHealth = 0.0;
-        m_fHealth2 = 0.0f;
+            if(objectI->getStack() != 0 || isSpinner) continue;
 
-        return;
-    }
+            bool isHitCircle = dynamic_cast<OsuCircle *>(objectI) != NULL;
+            bool isSlider = dynamic_cast<OsuSlider *>(objectI) != NULL;
 
-    if(isFromHitResult && percent > 0.0) {
-        m_osu->getHUD()->animateKiBulge();
+            if(isHitCircle) {
+                while(--n >= 0) {
+                    OsuHitObject *objectN = m_hitobjects[n];
 
-        if(m_fHealth > 0.9) m_osu->getHUD()->animateKiExplode();
-    }
+                    bool isSpinnerN = dynamic_cast<OsuSpinner *>(objectN);
 
-    m_fHealth = clamp<double>(m_fHealth + percent, 0.0, 1.0);
+                    if(isSpinnerN) continue;
 
-    // handle generic fail state (2)
-    const bool isDead = m_fHealth < 0.001;
-    if(isDead && !m_osu->getModNF()) {
-        if(m_osu->getModEZ() && m_osu->getScore()->getNumEZRetries() > 0)  // retries with ez
-        {
-            m_osu->getScore()->setNumEZRetries(m_osu->getScore()->getNumEZRetries() - 1);
+                    if(objectI->getTime() - (approachTime * stackLeniency) >
+                       (objectN->getTime() + objectN->getDuration()))
+                        break;
 
-            // special case: set health to 160/200 (osu!stable behavior, seems fine for all drains)
-            m_fHealth = osu_drain_stable_hpbar_recovery.getFloat() / m_osu_drain_stable_hpbar_maximum_ref->getFloat();
-            m_fHealth2 = (float)m_fHealth;
+                    Vector2 objectNEndPosition =
+                        objectN->getOriginalRawPosAt(objectN->getTime() + objectN->getDuration());
+                    if(objectN->getDuration() != 0 &&
+                       (objectNEndPosition - objectI->getOriginalRawPosAt(objectI->getTime())).length() <
+                           STACK_LENIENCE) {
+                        int offset = objectI->getStack() - objectN->getStack() + 1;
+                        for(int j = n + 1; j <= i; j++) {
+                            if((objectNEndPosition - m_hitobjects[j]->getOriginalRawPosAt(m_hitobjects[j]->getTime()))
+                                   .length() < STACK_LENIENCE)
+                                m_hitobjects[j]->setStack(m_hitobjects[j]->getStack() - offset);
+                        }
 
-            anim->deleteExistingAnimation(&m_fHealth2);
-        } else if(isFromHitResult && percent < 0.0)  // judgement fail
-        {
-            switch(drainType) {
-                case 2:  // osu!stable
-                    if(!osu_drain_stable_passive_fail.getBool()) fail();
-                    break;
+                        break;
+                    }
 
-                case 3:  // osu!lazer 2020
-                    if(!osu_drain_lazer_passive_fail.getBool()) fail();
-                    break;
+                    if((objectN->getOriginalRawPosAt(objectN->getTime()) -
+                        objectI->getOriginalRawPosAt(objectI->getTime()))
+                           .length() < STACK_LENIENCE) {
+                        objectN->setStack(objectI->getStack() + 1);
+                        objectI = objectN;
+                    }
+                }
+            } else if(isSlider) {
+                while(--n >= 0) {
+                    OsuHitObject *objectN = m_hitobjects[n];
+
+                    bool isSpinner = dynamic_cast<OsuSpinner *>(objectN) != NULL;
+
+                    if(isSpinner) continue;
+
+                    if(objectI->getTime() - (approachTime * stackLeniency) > objectN->getTime()) break;
+
+                    if(((objectN->getDuration() != 0
+                             ? objectN->getOriginalRawPosAt(objectN->getTime() + objectN->getDuration())
+                             : objectN->getOriginalRawPosAt(objectN->getTime())) -
+                        objectI->getOriginalRawPosAt(objectI->getTime()))
+                           .length() < STACK_LENIENCE) {
+                        objectN->setStack(objectI->getStack() + 1);
+                        objectI = objectN;
+                    }
+                }
+            }
+        }
+    } else  // getSelectedDifficulty()->version < 6
+    {
+        // old stacking algorithm for old beatmaps
+        // https://github.com/ppy/osu/blob/master/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs
 
-                case 4:  // osu!lazer 2018
-                    fail();
-                    break;
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            OsuHitObject *currHitObject = m_hitobjects[i];
+            OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(currHitObject);
+
+            const bool isSlider = (sliderPointer != NULL);
+
+            if(currHitObject->getStack() != 0 && !isSlider) continue;
+
+            long startTime = currHitObject->getTime() + currHitObject->getDuration();
+            int sliderStack = 0;
+
+            for(int j = i + 1; j < m_hitobjects.size(); j++) {
+                OsuHitObject *objectJ = m_hitobjects[j];
+
+                if(objectJ->getTime() - (approachTime * stackLeniency) > startTime) break;
+
+                // "The start position of the hitobject, or the position at the end of the path if the hitobject is a
+                // slider"
+                Vector2 position2 =
+                    isSlider
+                        ? sliderPointer->getOriginalRawPosAt(sliderPointer->getTime() + sliderPointer->getDuration())
+                        : currHitObject->getOriginalRawPosAt(currHitObject->getTime());
+
+                if((objectJ->getOriginalRawPosAt(objectJ->getTime()) -
+                    currHitObject->getOriginalRawPosAt(currHitObject->getTime()))
+                       .length() < 3) {
+                    currHitObject->setStack(currHitObject->getStack() + 1);
+                    startTime = objectJ->getTime() + objectJ->getDuration();
+                } else if((objectJ->getOriginalRawPosAt(objectJ->getTime()) - position2).length() < 3) {
+                    // "Case for sliders - bump notes down and right, rather than up and left."
+                    sliderStack++;
+                    objectJ->setStack(objectJ->getStack() - sliderStack);
+                    startTime = objectJ->getTime() + objectJ->getDuration();
+                }
             }
         }
     }
+
+    // update hitobject positions
+    float stackOffset = m_fRawHitcircleDiameter * STACK_OFFSET;
+    for(int i = 0; i < m_hitobjects.size(); i++) {
+        if(m_hitobjects[i]->getStack() != 0) m_hitobjects[i]->updateStackPosition(stackOffset);
+    }
 }
 
-void OsuBeatmap::updateTimingPoints(long curPos) {
-    if(curPos < 0) return;  // aspire pls >:(
+void OsuBeatmap::computeDrainRate() {
+    m_fDrainRate = 0.0;
+    m_fHpMultiplierNormal = 1.0;
+    m_fHpMultiplierComboEnd = 1.0;
 
-    /// debugLog("updateTimingPoints( %ld )\n", curPos);
+    if(m_hitobjects.size() < 1 || m_selectedDifficulty2 == NULL) return;
 
-    const OsuDatabaseBeatmap::TIMING_INFO t =
-        m_selectedDifficulty2->getTimingInfoForTime(curPos + (long)osu_timingpoints_offset.getInt());
-    m_osu->getSkin()->setSampleSet(
-        t.sampleType);  // normal/soft/drum is stored in the sample type! the sample set number is for custom sets
-    m_osu->getSkin()->setSampleVolume(clamp<float>(t.volume / 100.0f, 0.0f, 1.0f));
-}
+    debugLog("OsuBeatmap: Calculating drain ...\n");
 
-bool OsuBeatmap::isLoading() { return (!m_music->isAsyncReady()); }
+    const int drainType = m_osu_drain_type_ref->getInt();
+    if(drainType == 2)  // osu!stable
+    {
+        // see https://github.com/ppy/osu-iPhone/blob/master/Classes/OsuPlayer.m
+        // see calcHPDropRate() @ https://github.com/ppy/osu-iPhone/blob/master/Classes/OsuFiletype.m#L661
 
-long OsuBeatmap::getPVS() {
-    // this is an approximation with generous boundaries, it doesn't need to be exact (just good enough to filter 10000
-    // hitobjects down to a few hundred or so) it will be used in both positive and negative directions (previous and
-    // future hitobjects) to speed up loops which iterate over all hitobjects
-    return OsuGameRules::getApproachTime(this) + OsuGameRules::getFadeInTime() +
-           (long)OsuGameRules::getHitWindowMiss(this) + 1500;  // sanity
-}
+        // NOTE: all drain changes between 2014 and today have been fixed here (the link points to an old version of the
+        // algorithm!) these changes include: passive spinner nerf (drain * 0.25 while spinner is active), and clamping
+        // the object length drain to 0 + an extra check for that (see maxLongObjectDrop) see
+        // https://osu.ppy.sh/home/changelog/stable40/20190513.2
 
-bool OsuBeatmap::canDraw() {
-    if(!m_bIsPlaying && !m_bIsPaused && !m_bContinueScheduled && !m_bIsWaiting) return false;
-    if(m_selectedDifficulty2 == NULL || m_music == NULL)  // sanity check
-        return false;
+        struct TestPlayer {
+            TestPlayer(double hpBarMaximum) {
+                this->hpBarMaximum = hpBarMaximum;
 
-    return true;
-}
+                hpMultiplierNormal = 1.0;
+                hpMultiplierComboEnd = 1.0;
 
-bool OsuBeatmap::canUpdate() {
-    if(!m_bIsPlaying && !m_bIsPaused && !m_bContinueScheduled) return false;
+                resetHealth();
+            }
 
-    if(m_osu->getInstanceID() > 1) {
-        m_music = engine->getResourceManager()->getSound("OSU_BEATMAP_MUSIC");
-        if(m_music == NULL) return false;
-    }
+            void resetHealth() {
+                health = hpBarMaximum;
+                healthUncapped = hpBarMaximum;
+            }
 
-    return true;
-}
+            void increaseHealth(double amount) {
+                healthUncapped += amount;
+                health += amount;
 
-void OsuBeatmap::handlePreviewPlay() {
-    if(m_music != NULL && (!m_music->isPlaying() || m_music->getPosition() > 0.95f) && m_selectedDifficulty2 != NULL) {
-        // this is an assumption, but should be good enough for most songs
-        // reset playback position when the song has nearly reached the end (when the user switches back to the results
-        // screen or the songbrowser after playing)
-        if(m_music->getPosition() > 0.95f) m_iContinueMusicPos = 0;
+                if(health > hpBarMaximum) health = hpBarMaximum;
 
-        engine->getSound()->stop(m_music);
+                if(health < 0.0) health = 0.0;
 
-        if(engine->getSound()->play(m_music)) {
-            if(m_music->getFrequency() < m_fMusicFrequencyBackup)  // player has died, reset frequency
-                m_music->setFrequency(m_fMusicFrequencyBackup);
+                if(healthUncapped < 0.0) healthUncapped = 0.0;
+            }
 
-            if(m_osu->getMainMenu()->isVisible())
-                m_music->setPositionMS(0);
-            else if(m_iContinueMusicPos != 0)
-                m_music->setPositionMS(m_iContinueMusicPos);
-            else
-                m_music->setPositionMS(m_selectedDifficulty2->getPreviewTime() < 0
-                                           ? (unsigned long)(m_music->getLengthMS() * 0.40f)
-                                           : m_selectedDifficulty2->getPreviewTime());
+            void decreaseHealth(double amount) {
+                health -= amount;
 
-            m_music->setVolume(m_osu_volume_music_ref->getFloat());
-            m_music->setSpeed(m_osu->getSpeedMultiplier());
-        }
-    }
+                if(health < 0.0) health = 0.0;
 
-    // always loop during preview
-    if(m_music != NULL) m_music->setLoop(osu_beatmap_preview_music_loop.getBool());
-}
+                if(health > hpBarMaximum) health = hpBarMaximum;
 
-void OsuBeatmap::loadMusic(bool stream, bool prescan) {
-    if(m_osu->getInstanceID() > 1) {
-        m_music = engine->getResourceManager()->getSound("OSU_BEATMAP_MUSIC");
-        return;
-    }
+                healthUncapped -= amount;
 
-    stream = stream || m_bForceStreamPlayback;
-    m_iResourceLoadUpdateDelayHack = 0;
+                if(healthUncapped < 0.0) healthUncapped = 0.0;
+            }
 
-    // load the song (again)
-    if(m_selectedDifficulty2 != NULL &&
-       (m_music == NULL || m_selectedDifficulty2->getFullSoundFilePath() != m_music->getFilePath() ||
-        !m_music->isReady())) {
-        unloadMusicInt();
+            double hpBarMaximum;
 
-        // if it's not a stream then we are loading the entire song into memory for playing
-        if(!stream) engine->getResourceManager()->requestNextLoadAsync();
+            double health;
+            double healthUncapped;
 
-        m_music = engine->getResourceManager()->loadSoundAbs(
-            m_selectedDifficulty2->getFullSoundFilePath(), "OSU_BEATMAP_MUSIC", stream, false, false, false,
-            m_bForceStreamPlayback &&
-                prescan);  // m_bForceStreamPlayback = prescan necessary! otherwise big mp3s will go out of sync
-        m_music->setVolume(m_osu_volume_music_ref->getFloat());
-        m_fMusicFrequencyBackup = m_music->getFrequency();
-        m_music->setSpeed(m_osu->getSpeedMultiplier());
-    }
-}
+            double hpMultiplierNormal;
+            double hpMultiplierComboEnd;
+        };
+        double foo = (double)m_osu_drain_stable_hpbar_maximum_ref->getFloat();
+        TestPlayer testPlayer(foo);
 
-void OsuBeatmap::unloadMusicInt() {
-    if(m_osu->getInstanceID() < 2) {
-        engine->getSound()->stop(m_music);
-        engine->getResourceManager()->destroyResource(m_music);
-    }
+        const double HP = getHP();
+        const int version = m_selectedDifficulty2->getVersion();
 
-    m_music = NULL;
-}
+        double testDrop = 0.05;
 
-void OsuBeatmap::unloadObjects() {
-    for(int i = 0; i < m_hitobjects.size(); i++) {
-        delete m_hitobjects[i];
-    }
-    m_hitobjects = std::vector<OsuHitObject *>();
-    m_hitobjectsSortedByEndTime = std::vector<OsuHitObject *>();
-    m_misaimObjects = std::vector<OsuHitObject *>();
+        const double lowestHpEver = OsuGameRules::mapDifficultyRangeDouble(HP, 195.0, 160.0, 60.0);
+        const double lowestHpComboEnd = OsuGameRules::mapDifficultyRangeDouble(HP, 198.0, 170.0, 80.0);
+        const double lowestHpEnd = OsuGameRules::mapDifficultyRangeDouble(HP, 198.0, 180.0, 80.0);
+        const double HpRecoveryAvailable = OsuGameRules::mapDifficultyRangeDouble(HP, 8.0, 4.0, 0.0);
 
-    m_breaks = std::vector<OsuDatabaseBeatmap::BREAK>();
+        bool fail = false;
 
-    m_clicks = std::vector<CLICK>();
-    m_keyUps = std::vector<CLICK>();
-}
+        do {
+            testPlayer.resetHealth();
 
-void OsuBeatmap::resetHitObjects(long curPos) {
-    for(int i = 0; i < m_hitobjects.size(); i++) {
-        m_hitobjects[i]->onReset(curPos);
-        m_hitobjects[i]->update(curPos);  // fgt
-        m_hitobjects[i]->onReset(curPos);
-    }
-    m_osu->getHUD()->resetHitErrorBar();
-}
+            double lowestHp = testPlayer.health;
+            int lastTime = (int)(m_hitobjects[0]->getTime() - (long)OsuGameRules::getApproachTime(this));
+            fail = false;
 
-void OsuBeatmap::resetScore() {
-    vanilla = convar->isVanilla();
+            const int breakCount = m_breaks.size();
+            int breakNumber = 0;
 
-    replay.clear();
-    replay.push_back(OsuReplay::Frame{
-        .cur_music_pos = -1,
-        .milliseconds_since_last_frame = 0,
-        .x = 256,
-        .y = -500,
-        .key_flags = 0,
-    });
-    replay.push_back(OsuReplay::Frame{
-        .cur_music_pos = -1,
-        .milliseconds_since_last_frame = -1,
-        .x = 256,
-        .y = -500,
-        .key_flags = 0,
-    });
+            int comboTooLowCount = 0;
 
-    last_event_time = engine->getTimeReal();
-    last_event_ms = 0;
-    current_keys = 0;
-    last_keys = 0;
-    m_iCurMusicPos = 0;
-    m_iCurMusicPosWithOffsets = 0;
+            for(int i = 0; i < m_hitobjects.size(); i++) {
+                const OsuHitObject *h = m_hitobjects[i];
+                const OsuSlider *sliderPointer = dynamic_cast<const OsuSlider *>(h);
+                const OsuSpinner *spinnerPointer = dynamic_cast<const OsuSpinner *>(h);
 
-    m_fHealth = 1.0;
-    m_fHealth2 = 1.0f;
-    m_bFailed = false;
-    m_fFailAnim = 1.0f;
-    anim->deleteExistingAnimation(&m_fFailAnim);
+                const int localLastTime = lastTime;
 
-    m_osu->getScore()->reset();
-    m_osu->m_hud->resetScoreboard();
+                int breakTime = 0;
+                if(breakCount > 0 && breakNumber < breakCount) {
+                    const OsuDatabaseBeatmap::BREAK &e = m_breaks[breakNumber];
+                    if(e.startTime >= localLastTime && e.endTime <= h->getTime()) {
+                        // consider break start equal to object end time for version 8+ since drain stops during this
+                        // time
+                        breakTime = (version < 8) ? (e.endTime - e.startTime) : (e.endTime - localLastTime);
+                        breakNumber++;
+                    }
+                }
 
-    m_bIsFirstMissSound = true;
-}
+                testPlayer.decreaseHealth(testDrop * (h->getTime() - lastTime - breakTime));
+
+                lastTime = (int)(h->getTime() + h->getDuration());
+
+                if(testPlayer.health < lowestHp) lowestHp = testPlayer.health;
+
+                if(testPlayer.health > lowestHpEver) {
+                    const double longObjectDrop = testDrop * (double)h->getDuration();
+                    const double maxLongObjectDrop = std::max(0.0, longObjectDrop - testPlayer.health);
+
+                    testPlayer.decreaseHealth(longObjectDrop);
+
+                    // nested hitobjects
+                    if(sliderPointer != NULL) {
+                        // startcircle
+                        testPlayer.increaseHealth(
+                            OsuScore::getHealthIncrease(OsuScore::HIT::HIT_SLIDER30, HP, testPlayer.hpMultiplierNormal,
+                                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider30
+
+                        // ticks + repeats + repeat ticks
+                        const std::vector<OsuSlider::SLIDERCLICK> &clicks = sliderPointer->getClicks();
+                        for(int c = 0; c < clicks.size(); c++) {
+                            switch(clicks[c].type) {
+                                case 0:  // repeat
+                                    testPlayer.increaseHealth(OsuScore::getHealthIncrease(
+                                        OsuScore::HIT::HIT_SLIDER30, HP, testPlayer.hpMultiplierNormal,
+                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider30
+                                    break;
+                                case 1:  // tick
+                                    testPlayer.increaseHealth(OsuScore::getHealthIncrease(
+                                        OsuScore::HIT::HIT_SLIDER10, HP, testPlayer.hpMultiplierNormal,
+                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider10
+                                    break;
+                            }
+                        }
 
-void OsuBeatmap::playMissSound() {
-    if((m_bIsFirstMissSound && m_osu->getScore()->getCombo() > 0) ||
-       m_osu->getScore()->getCombo() > osu_combobreak_sound_combo.getInt()) {
-        m_bIsFirstMissSound = false;
-        engine->getSound()->play(getSkin()->getCombobreak());
-    }
-}
+                        // endcircle
+                        testPlayer.increaseHealth(
+                            OsuScore::getHealthIncrease(OsuScore::HIT::HIT_SLIDER30, HP, testPlayer.hpMultiplierNormal,
+                                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider30
+                    } else if(spinnerPointer != NULL) {
+                        const int rotationsNeeded = (int)((float)spinnerPointer->getDuration() / 1000.0f *
+                                                          OsuGameRules::getSpinnerSpinsPerSecond(this));
+                        for(int r = 0; r < rotationsNeeded; r++) {
+                            testPlayer.increaseHealth(OsuScore::getHealthIncrease(
+                                OsuScore::HIT::HIT_SPINNERSPIN, HP, testPlayer.hpMultiplierNormal,
+                                testPlayer.hpMultiplierComboEnd, 1.0));  // spinnerspin
+                        }
+                    }
 
-unsigned long OsuBeatmap::getMusicPositionMSInterpolated() {
-    if(!osu_interpolate_music_pos.getBool() || isLoading())
-        return m_music->getPositionMS();
-    else {
-        const double interpolationMultiplier = 1.0;
+                    if(!(maxLongObjectDrop > 0.0) || (testPlayer.health - maxLongObjectDrop) > lowestHpEver) {
+                        // regular hit (for every hitobject)
+                        testPlayer.increaseHealth(
+                            OsuScore::getHealthIncrease(OsuScore::HIT::HIT_300, HP, testPlayer.hpMultiplierNormal,
+                                                        testPlayer.hpMultiplierComboEnd, 1.0));  // 300
+
+                        // end of combo (new combo starts at next hitobject)
+                        if((i == m_hitobjects.size() - 1) || m_hitobjects[i]->isEndOfCombo()) {
+                            testPlayer.increaseHealth(
+                                OsuScore::getHealthIncrease(OsuScore::HIT::HIT_300G, HP, testPlayer.hpMultiplierNormal,
+                                                            testPlayer.hpMultiplierComboEnd, 1.0));  // geki
+
+                            if(testPlayer.health < lowestHpComboEnd) {
+                                if(++comboTooLowCount > 2) {
+                                    testPlayer.hpMultiplierComboEnd *= 1.07;
+                                    testPlayer.hpMultiplierNormal *= 1.03;
+                                    fail = true;
+                                    break;
+                                }
+                            }
+                        }
 
-        // TODO: fix snapping at beginning for maps with instant start
+                        continue;
+                    }
 
-        unsigned long returnPos = 0;
-        const double curPos = (double)m_music->getPositionMS();
-        const float speed = m_music->getSpeed();
+                    fail = true;
+                    testDrop *= 0.96;
+                    break;
+                }
 
-        // not reinventing the wheel, the interpolation magic numbers here are (c) peppy
+                fail = true;
+                testDrop *= 0.96;
+                break;
+            }
 
-        const double realTime = engine->getTimeReal();
-        const double interpolationDelta = (realTime - m_fLastRealTimeForInterpolationDelta) * 1000.0 * speed;
-        const double interpolationDeltaLimit =
-            ((realTime - m_fLastAudioTimeAccurateSet) * 1000.0 < 1500 || speed < 1.0f ? 11 : 33) *
-            interpolationMultiplier;
+            if(!fail && testPlayer.health < lowestHpEnd) {
+                fail = true;
+                testDrop *= 0.94;
+                testPlayer.hpMultiplierComboEnd *= 1.01;
+                testPlayer.hpMultiplierNormal *= 1.01;
+            }
 
-        if(m_music->isPlaying() && !m_bWasSeekFrame) {
-            double newInterpolatedPos = m_fInterpolatedMusicPos + interpolationDelta;
-            double delta = newInterpolatedPos - curPos;
+            const double recovery = (testPlayer.healthUncapped - testPlayer.hpBarMaximum) / (double)m_hitobjects.size();
+            if(!fail && recovery < HpRecoveryAvailable) {
+                fail = true;
+                testDrop *= 0.96;
+                testPlayer.hpMultiplierComboEnd *= 1.02;
+                testPlayer.hpMultiplierNormal *= 1.01;
+            }
+        } while(fail);
 
-            // debugLog("delta = %ld\n", (long)delta);
+        m_fDrainRate =
+            (testDrop / testPlayer.hpBarMaximum) * 1000.0;  // from [0, 200] to [0, 1], and from ms to seconds
+        m_fHpMultiplierComboEnd = testPlayer.hpMultiplierComboEnd;
+        m_fHpMultiplierNormal = testPlayer.hpMultiplierNormal;
+    } else if(drainType == 3)  // osu!lazer 2020
+    {
+        // build healthIncreases
+        std::vector<std::pair<double, double>> healthIncreases;  // [first = time, second = health]
+        healthIncreases.reserve(m_hitobjects.size());
+        const double healthIncreaseForHit300 = OsuScore::getHealthIncrease(OsuScore::HIT::HIT_300);
+        for(int i = 0; i < m_hitobjects.size(); i++) {
+            // nested hitobjects
+            const OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[i]);
+            if(sliderPointer != NULL) {
+                // startcircle
+                healthIncreases.push_back(
+                    std::pair<double, double>((double)m_hitobjects[i]->getTime(), healthIncreaseForHit300));
+
+                // ticks + repeats + repeat ticks
+                const std::vector<OsuSlider::SLIDERCLICK> &clicks = sliderPointer->getClicks();
+                for(int c = 0; c < clicks.size(); c++) {
+                    healthIncreases.push_back(
+                        std::pair<double, double>((double)clicks[c].time, healthIncreaseForHit300));
+                }
+            }
 
-            // approach and recalculate delta
-            newInterpolatedPos -= delta / 8.0 / interpolationMultiplier;
-            delta = newInterpolatedPos - curPos;
+            // regular hitobject
+            healthIncreases.push_back(std::pair<double, double>(
+                m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration(), healthIncreaseForHit300));
+        }
 
-            if(std::abs(delta) > interpolationDeltaLimit * 2)  // we're fucked, snap back to curPos
-            {
-                m_fInterpolatedMusicPos = (double)curPos;
-            } else if(delta < -interpolationDeltaLimit)  // undershot
-            {
-                m_fInterpolatedMusicPos += interpolationDelta * 2;
-                m_fLastAudioTimeAccurateSet = realTime;
-            } else if(delta < interpolationDeltaLimit)  // normal
-            {
-                m_fInterpolatedMusicPos = newInterpolatedPos;
-            } else  // overshot
-            {
-                m_fInterpolatedMusicPos += interpolationDelta / 2;
-                m_fLastAudioTimeAccurateSet = realTime;
-            }
+        const int numHealthIncreases = healthIncreases.size();
+        const int numBreaks = m_breaks.size();
+        const double drainStartTime = m_hitobjects[0]->getTime();
 
-            // calculate final return value
-            returnPos = (unsigned long)std::round(m_fInterpolatedMusicPos);
+        // see computeDrainRate() &
+        // https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs
 
-            bool nightcoring = m_osu->getModNC() || m_osu->getModDC();
-            if(speed < 1.0f && osu_compensate_music_speed.getBool() && !nightcoring) {
-                returnPos += (unsigned long)(((1.0f - speed) / 0.75f) * 5);
+        const double minimum_health_error = 0.01;
+
+        const double min_health_target = osu_drain_lazer_health_min.getFloat();
+        const double mid_health_target = osu_drain_lazer_health_mid.getFloat();
+        const double max_health_target = osu_drain_lazer_health_max.getFloat();
+
+        const double targetMinimumHealth =
+            OsuGameRules::mapDifficultyRange(getHP(), min_health_target, mid_health_target, max_health_target);
+
+        int adjustment = 1;
+        double result = 1.0;
+
+        // Although we expect the following loop to converge within 30 iterations (health within 1/2^31 accuracy of the
+        // target), we'll still keep a safety measure to avoid infinite loops by detecting overflows.
+        while(adjustment > 0) {
+            double currentHealth = 1.0;
+            double lowestHealth = 1.0;
+            int currentBreak = -1;
+
+            for(int i = 0; i < numHealthIncreases; i++) {
+                double currentTime = healthIncreases[i].first;
+                double lastTime = i > 0 ? healthIncreases[i - 1].first : drainStartTime;
+
+                // Subtract any break time from the duration since the last object
+                if(numBreaks > 0) {
+                    // Advance the last break occuring before the current time
+                    while(currentBreak + 1 < numBreaks && (double)m_breaks[currentBreak + 1].endTime < currentTime) {
+                        currentBreak++;
+                    }
+
+                    if(currentBreak >= 0) lastTime = std::max(lastTime, (double)m_breaks[currentBreak].endTime);
+                }
+
+                // Apply health adjustments
+                currentHealth -= (healthIncreases[i].first - lastTime) * result;
+                lowestHealth = std::min(lowestHealth, currentHealth);
+                currentHealth = std::min(1.0, currentHealth + healthIncreases[i].second);
+
+                // Common scenario for when the drain rate is definitely too harsh
+                if(lowestHealth < 0) break;
             }
-        } else  // no interpolation
-        {
-            returnPos = curPos;
-            m_fInterpolatedMusicPos = (unsigned long)returnPos;
-            m_fLastAudioTimeAccurateSet = realTime;
+
+            // Stop if the resulting health is within a reasonable offset from the target
+            if(std::abs(lowestHealth - targetMinimumHealth) <= minimum_health_error) break;
+
+            // This effectively works like a binary search - each iteration the search space moves closer to the target,
+            // but may exceed it.
+            adjustment *= 2;
+            result += 1.0 / adjustment * sign<double>(lowestHealth - targetMinimumHealth);
         }
 
-        m_fLastRealTimeForInterpolationDelta =
-            realTime;  // this is more accurate than engine->getFrameTime() for the delta calculation, since it
-                       // correctly handles all possible delays inbetween
+        m_fDrainRate = result * 1000.0;  // from ms to seconds
+    }
+}
 
-        // debugLog("returning %lu \n", returnPos);
-        // debugLog("delta = %lu\n", (long)returnPos - m_iCurMusicPos);
-        // debugLog("raw delta = %ld\n", (long)returnPos - (long)curPos);
+void OsuBeatmap::updateStarCache() {
+    if(m_osu_draw_statistics_pp_ref->getBool() || m_osu_draw_statistics_livestars_ref->getBool()) {
+        // so we don't get a useless double load inside onModUpdate()
+        m_fPrevHitCircleDiameterForStarCache = getHitcircleDiameter();
+        m_fPrevSpeedForStarCache = m_osu->getSpeedMultiplier();
+
+        // kill any running loader, so we get to a clean state
+        stopStarCacheLoader();
+        engine->getResourceManager()->destroyResource(m_starCacheLoader);
+
+        // create new loader
+        m_starCacheLoader = new OsuBackgroundStarCacheLoader(this);
+        m_starCacheLoader->revive();  // activate it
+        engine->getResourceManager()->requestNextLoadAsync();
+        engine->getResourceManager()->loadResource(m_starCacheLoader);
+    }
+}
 
-        return returnPos;
+void OsuBeatmap::stopStarCacheLoader() {
+    if(!m_starCacheLoader->isDead()) {
+        m_starCacheLoader->kill();
+        double startTime = engine->getTimeReal();
+        while(!m_starCacheLoader->isAsyncReady())  // stall main thread until it's killed (this should be very quick,
+                                                   // around max 1 ms, as the kill flag is checked in every iteration)
+        {
+            if(engine->getTimeReal() - startTime > 2) {
+                debugLog("WARNING: Ignoring stuck StarCacheLoader thread!\n");
+                break;
+            }
+        }
+
+        // NOTE: this only works safely because OsuBackgroundStarCacheLoader does no work in load(), because it might
+        // still be in the ResourceManager's sync load() queue, so future loadAsync() could crash with the old pending
+        // load()
     }
 }
+
+bool OsuBeatmap::isLoadingStarCache() {
+    return ((m_osu_draw_statistics_pp_ref->getBool() || m_osu_draw_statistics_livestars_ref->getBool()) &&
+            !m_starCacheLoader->isReady());
+}

+ 194 - 60
src/App/Osu/OsuBeatmap.h

@@ -1,13 +1,4 @@
-//================ Copyright (c) 2015, PG, All rights reserved. =================//
-//
-// Purpose:		beatmap loader
-//
-// $NoKeywords: $osubm
-//===============================================================================//
-
-#ifndef OSUBEATMAP_H
-#define OSUBEATMAP_H
-
+#pragma once
 #include "OsuDatabaseBeatmap.h"
 #include "OsuReplay.h"
 #include "OsuScore.h"
@@ -27,32 +18,81 @@ class OsuDatabaseBeatmap;
 class OsuBackgroundStarCacheLoader;
 class OsuBackgroundStarCalcHandler;
 
-class OsuBeatmap {
-   public:
+struct OsuBeatmap {
+    friend class OsuBackgroundStarCacheLoader;
+    friend class OsuBackgroundStarCalcHandler;
+
     struct CLICK {
         long musicPos;
     };
 
     OsuBeatmap(Osu *osu);
-    virtual ~OsuBeatmap();
+    ~OsuBeatmap();
 
-    virtual void draw(Graphics *g);
-    virtual void drawInt(Graphics *g);
+    void draw(Graphics *g);
     void drawDebug(Graphics *g);
     void drawBackground(Graphics *g);
-    virtual void update();
 
-    virtual void onKeyDown(KeyboardEvent &e);
-    virtual void onKeyUp(KeyboardEvent &e);
+    void update();
+    void update2();  // Used to be OsuBeatmap::update()
+
+    void onKeyDown(KeyboardEvent &e);
+
+    // Potentially Visible Set gate time size, for optimizing draw() and update() when iterating over all hitobjects
+    long getPVS();
 
-    virtual void onModUpdate() {
-        ;
-    }  // this should make all the necessary internal updates to hitobjects when legacy osu mods or static mods change
-       // live (but also on start)
-    virtual bool isLoading();  // allows subclasses to delay the playing start, e.g. to load something
+    // this should make all the necessary internal updates to hitobjects when legacy osu mods or static mods change
+    // live (but also on start)
+    void onModUpdate(bool rebuildSliderVertexBuffers = true, bool recomputeDrainRate = true);
 
-    virtual long getPVS();  // Potentially Visible Set gate time size, for optimizing draw() and update() when iterating
-                            // over all hitobjects
+    // Returns true if we're loading or waiting on other players
+    bool isLoading();
+
+    // Returns true if the local player is loading
+    bool isActuallyLoading();
+
+    Vector2 pixels2OsuCoords(Vector2 pixelCoords) const;  // only used for positional audio atm
+    Vector2 osuCoords2Pixels(
+        Vector2 coords) const;  // hitobjects should use this one (includes lots of special behaviour)
+    Vector2 osuCoords2RawPixels(
+        Vector2 coords) const;  // raw transform from osu!pixels to absolute screen pixels (without any mods whatsoever)
+    Vector3 osuCoordsTo3D(Vector2 coords, const OsuHitObject *hitObject) const;
+    Vector3 osuCoordsToRaw3D(Vector2 coords) const;  // (without any mods whatsoever)
+    Vector2 osuCoords2LegacyPixels(
+        Vector2 coords) const;  // only applies vanilla osu mods and static mods to the coordinates (used for generating
+                                // the static slider mesh) centered at (0, 0, 0)
+
+    // cursor
+    Vector2 getCursorPos() const;
+    Vector2 getFirstPersonCursorDelta() const;
+    inline Vector2 getContinueCursorPoint() const { return m_vContinueCursorPoint; }
+
+    // playfield
+    inline Vector2 getPlayfieldSize() const { return m_vPlayfieldSize; }
+    inline Vector2 getPlayfieldCenter() const { return m_vPlayfieldCenter; }
+    inline float getPlayfieldRotation() const { return m_fPlayfieldRotation; }
+
+    // hitobjects
+    float getHitcircleDiameter() const;  // in actual scaled pixels to the current resolution
+    inline float getRawHitcircleDiameter() const { return m_fRawHitcircleDiameter; }  // in osu!pixels
+    inline float getHitcircleXMultiplier() const {
+        return m_fXMultiplier;
+    }  // multiply osu!pixels with this to get screen pixels
+    inline float getNumberScale() const { return m_fNumberScale; }
+    inline float getHitcircleOverlapScale() const { return m_fHitcircleOverlapScale; }
+    inline float getSliderFollowCircleDiameter() const { return m_fSliderFollowCircleDiameter; }
+    inline float getRawSliderFollowCircleDiameter() const { return m_fRawSliderFollowCircleDiameter; }
+    inline bool isInMafhamRenderChunk() const { return m_bInMafhamRenderChunk; }
+
+    // score
+    inline int getNumHitObjects() const { return m_hitobjects.size(); }
+    inline float getAimStars() const { return m_fAimStars; }
+    inline float getAimSliderFactor() const { return m_fAimSliderFactor; }
+    inline float getSpeedStars() const { return m_fSpeedStars; }
+    inline float getSpeedNotes() const { return m_fSpeedNotes; }
+
+    // hud
+    inline bool isSpinnerActive() const { return m_bIsSpinnerActive; }
 
     // callbacks called by the Osu class (osu!standard)
     void skipEmptySection();
@@ -83,7 +123,8 @@ class OsuBeatmap {
     }
 
     // music/sound
-    void unloadMusic() { unloadMusicInt(); }
+    void loadMusic(bool stream = true, bool prescan = false);
+    void unloadMusic();
     void setVolume(float volume);
     void setSpeed(float speed);
     void seekPercent(double percent);
@@ -138,7 +179,8 @@ class OsuBeatmap {
     // set to false when using non-vanilla mods (disables score submission)
     bool vanilla = true;
 
-    // replay recording (see OsuBeatmapStandard)
+    // replay recording (see OsuBeatmap)
+    void write_frame();
     std::vector<OsuReplay::Frame> replay;
     double last_event_time = 0.0;
     long last_event_ms = 0;
@@ -206,33 +248,6 @@ class OsuBeatmap {
     inline float getBreakBackgroundFadeAnim() const { return m_fBreakBackgroundFade; }
 
    protected:
-    static ConVar *m_osu_pvs;
-    static ConVar *m_osu_draw_hitobjects_ref;
-    static ConVar *m_osu_followpoints_prevfadetime_ref;
-    static ConVar *m_osu_universal_offset_ref;
-    static ConVar *m_osu_early_note_time_ref;
-    static ConVar *m_osu_fail_time_ref;
-    static ConVar *m_osu_drain_type_ref;
-
-    static ConVar *m_osu_draw_hud_ref;
-    static ConVar *m_osu_draw_scorebarbg_ref;
-    static ConVar *m_osu_hud_scorebar_hide_during_breaks_ref;
-    static ConVar *m_osu_drain_stable_hpbar_maximum_ref;
-    static ConVar *m_osu_volume_music_ref;
-    static ConVar *m_osu_mod_fposu_ref;
-    static ConVar *m_fposu_draw_scorebarbg_on_top_ref;
-
-    // overridable child events
-    virtual void onBeforeLoad() { ; }  // called before hitobjects are loaded
-    virtual void onLoad() { ; }        // called after hitobjects have been loaded
-    virtual void onPlayStart() {
-        ;
-    }  // called when the player starts playing (everything has been loaded, including the music)
-    virtual void onBeforeStop(bool quit) {
-        ;
-    }  // called before hitobjects are unloaded (quit = don't display ranking screen)
-    virtual void onPaused(bool first) { ; }
-
     // internal
     bool canDraw();
     bool canUpdate();
@@ -240,8 +255,6 @@ class OsuBeatmap {
     void actualRestart();
 
     void handlePreviewPlay();
-    void loadMusic(bool stream = true, bool prescan = false);
-    void unloadMusicInt();
     void unloadObjects();
 
     void resetHitObjects(long curPos = 0);
@@ -340,8 +353,129 @@ class OsuBeatmap {
     int m_iPreviousFollowPointObjectIndex;  // TODO: this shouldn't be in this class
 
    private:
-    friend class OsuBackgroundStarCacheLoader;
-    friend class OsuBackgroundStarCalcHandler;
-};
+    ConVar *m_osu_pvs = nullptr;
+    ConVar *m_osu_draw_hitobjects_ref = nullptr;
+    ConVar *m_osu_followpoints_prevfadetime_ref = nullptr;
+    ConVar *m_osu_universal_offset_ref = nullptr;
+    ConVar *m_osu_early_note_time_ref = nullptr;
+    ConVar *m_osu_fail_time_ref = nullptr;
+    ConVar *m_osu_drain_type_ref = nullptr;
+    ConVar *m_osu_draw_hud_ref = nullptr;
+    ConVar *m_osu_draw_scorebarbg_ref = nullptr;
+    ConVar *m_osu_hud_scorebar_hide_during_breaks_ref = nullptr;
+    ConVar *m_osu_drain_stable_hpbar_maximum_ref = nullptr;
+    ConVar *m_osu_volume_music_ref = nullptr;
+    ConVar *m_osu_mod_fposu_ref = nullptr;
+    ConVar *m_fposu_draw_scorebarbg_on_top_ref = nullptr;
+    ConVar *m_osu_draw_statistics_pp_ref = nullptr;
+    ConVar *m_osu_draw_statistics_livestars_ref = nullptr;
+    ConVar *m_osu_mod_fullalternate_ref = nullptr;
+    ConVar *m_fposu_distance_ref = nullptr;
+    ConVar *m_fposu_curved_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_frequency_x_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_frequency_y_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_frequency_z_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_strength_x_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_strength_y_ref = nullptr;
+    ConVar *m_fposu_mod_strafing_strength_z_ref = nullptr;
+    ConVar *m_fposu_mod_3d_depthwobble_ref = nullptr;
+    ConVar *m_osu_slider_scorev2_ref = nullptr;
+
+    static inline Vector2 mapNormalizedCoordsOntoUnitCircle(const Vector2 &in) {
+        return Vector2(in.x * std::sqrt(1.0f - in.y * in.y / 2.0f), in.y * std::sqrt(1.0f - in.x * in.x / 2.0f));
+    }
+
+    static float quadLerp3f(float left, float center, float right, float percent) {
+        if(percent >= 0.5f) {
+            percent = (percent - 0.5f) / 0.5f;
+            percent *= percent;
+            return lerp<float>(center, right, percent);
+        } else {
+            percent = percent / 0.5f;
+            percent = 1.0f - (1.0f - percent) * (1.0f - percent);
+            return lerp<float>(left, center, percent);
+        }
+    }
 
-#endif
+    void onPlayStart();
+    void onBeforeStop(bool quit);
+    void onPaused(bool first);
+
+    void drawFollowPoints(Graphics *g);
+    void drawHitObjects(Graphics *g);
+
+    void updateAutoCursorPos();
+    void updatePlayfieldMetrics();
+    void updateHitobjectMetrics();
+    void updateSliderVertexBuffers();
+
+    void calculateStacks();
+    void computeDrainRate();
+
+    void updateStarCache();
+    void stopStarCacheLoader();
+    bool isLoadingStarCache();
+
+    // beatmap
+    bool m_bIsSpinnerActive;
+    Vector2 m_vContinueCursorPoint;
+
+    // playfield
+    float m_fPlayfieldRotation;
+    float m_fScaleFactor;
+    Vector2 m_vPlayfieldCenter;
+    Vector2 m_vPlayfieldOffset;
+    Vector2 m_vPlayfieldSize;
+
+    // hitobject scaling
+    float m_fXMultiplier;
+    float m_fRawHitcircleDiameter;
+    float m_fHitcircleDiameter;
+    float m_fNumberScale;
+    float m_fHitcircleOverlapScale;
+    float m_fSliderFollowCircleDiameter;
+    float m_fRawSliderFollowCircleDiameter;
+
+    // auto
+    Vector2 m_vAutoCursorPos;
+    int m_iAutoCursorDanceIndex;
+
+    // pp calculation buffer (only needs to be recalculated in onModUpdate(), instead of on every hit)
+    float m_fAimStars;
+    float m_fAimSliderFactor;
+    float m_fSpeedStars;
+    float m_fSpeedNotes;
+    OsuBackgroundStarCacheLoader *m_starCacheLoader;
+    float m_fStarCacheTime;
+
+    // dynamic slider vertex buffer and other recalculation checks (for live mod switching)
+    float m_fPrevHitCircleDiameter;
+    bool m_bWasHorizontalMirrorEnabled;
+    bool m_bWasVerticalMirrorEnabled;
+    bool m_bWasEZEnabled;
+    bool m_bWasMafhamEnabled;
+    float m_fPrevPlayfieldRotationFromConVar;
+    float m_fPrevPlayfieldStretchX;
+    float m_fPrevPlayfieldStretchY;
+    float m_fPrevHitCircleDiameterForStarCache;
+    float m_fPrevSpeedForStarCache;
+
+    // custom
+    bool m_bIsPreLoading;
+    int m_iPreLoadingIndex;
+    bool m_bWasHREnabled;  // dynamic stack recalculation
+
+    RenderTarget *m_mafhamActiveRenderTarget;
+    RenderTarget *m_mafhamFinishedRenderTarget;
+    bool m_bMafhamRenderScheduled;
+    int m_iMafhamHitObjectRenderIndex;  // scene buffering for rendering entire beatmaps at once with an acceptable
+                                        // framerate
+    int m_iMafhamPrevHitObjectIndex;
+    int m_iMafhamActiveRenderHitObjectIndex;
+    int m_iMafhamFinishedRenderHitObjectIndex;
+    bool m_bInMafhamRenderChunk;  // used by OsuSlider to not animate the reverse arrow, and by OsuCircle to not animate
+                                  // note blocking shaking, while being rendered into the scene buffer
+
+    int m_iMandalaIndex;
+};

+ 0 - 1983
src/App/Osu/OsuBeatmapStandard.cpp

@@ -1,1983 +0,0 @@
-//================ Copyright (c) 2017, PG, All rights reserved. =================//
-//
-// Purpose:		osu!standard circle clicking
-//
-// $NoKeywords: $osustd
-//===============================================================================//
-
-#include "OsuBeatmapStandard.h"
-
-#include <string.h>
-
-#include <algorithm>
-#include <cctype>
-#include <chrono>
-#include <sstream>
-
-#include "AnimationHandler.h"
-#include "Bancho.h"
-#include "BanchoNetworking.h"
-#include "BanchoProtocol.h"
-#include "BanchoSubmitter.h"
-#include "ConVar.h"
-#include "Engine.h"
-#include "Environment.h"
-#include "Mouse.h"
-#include "Osu.h"
-#include "OsuBackgroundStarCacheLoader.h"
-#include "OsuCircle.h"
-#include "OsuDatabase.h"
-#include "OsuDatabaseBeatmap.h"
-#include "OsuDifficultyCalculator.h"
-#include "OsuGameRules.h"
-#include "OsuHUD.h"
-#include "OsuHitObject.h"
-#include "OsuModFPoSu.h"
-#include "OsuNotificationOverlay.h"
-#include "OsuReplay.h"
-#include "OsuRichPresence.h"
-#include "OsuSkin.h"
-#include "OsuSkinImage.h"
-#include "OsuSlider.h"
-#include "OsuSongBrowser2.h"
-#include "OsuSpinner.h"
-#include "ResourceManager.h"
-#include "SoundEngine.h"
-
-ConVar osu_draw_followpoints("osu_draw_followpoints", true, FCVAR_NONE);
-ConVar osu_draw_reverse_order("osu_draw_reverse_order", false, FCVAR_NONE);
-ConVar osu_draw_playfield_border("osu_draw_playfield_border", true, FCVAR_NONE);
-
-ConVar osu_stacking("osu_stacking", true, FCVAR_NONE, "Whether to use stacking calculations or not");
-ConVar osu_stacking_leniency_override("osu_stacking_leniency_override", -1.0f, FCVAR_NONE);
-
-ConVar osu_auto_snapping_strength("osu_auto_snapping_strength", 1.0f, FCVAR_NONE,
-                                  "How many iterations of quadratic interpolation to use, more = snappier, 0 = linear");
-ConVar osu_auto_cursordance("osu_auto_cursordance", false, FCVAR_NONE);
-ConVar osu_autopilot_snapping_strength(
-    "osu_autopilot_snapping_strength", 2.0f, FCVAR_NONE,
-    "How many iterations of quadratic interpolation to use, more = snappier, 0 = linear");
-ConVar osu_autopilot_lenience("osu_autopilot_lenience", 0.75f, FCVAR_NONE);
-
-ConVar osu_followpoints_clamp("osu_followpoints_clamp", false, FCVAR_NONE,
-                              "clamp followpoint approach time to current circle approach time (instead of using the "
-                              "hardcoded default 800 ms raw)");
-ConVar osu_followpoints_anim("osu_followpoints_anim", false, FCVAR_NONE,
-                             "scale + move animation while fading in followpoints (osu only does this when its "
-                             "internal default skin is being used)");
-ConVar osu_followpoints_connect_combos("osu_followpoints_connect_combos", false, FCVAR_NONE,
-                                       "connect followpoints even if a new combo has started");
-ConVar osu_followpoints_connect_spinners("osu_followpoints_connect_spinners", false, FCVAR_NONE,
-                                         "connect followpoints even through spinners");
-ConVar osu_followpoints_approachtime("osu_followpoints_approachtime", 800.0f, FCVAR_NONE);
-ConVar osu_followpoints_scale_multiplier("osu_followpoints_scale_multiplier", 1.0f, FCVAR_NONE);
-ConVar osu_followpoints_separation_multiplier("osu_followpoints_separation_multiplier", 1.0f, FCVAR_NONE);
-
-ConVar osu_number_scale_multiplier("osu_number_scale_multiplier", 1.0f, FCVAR_NONE);
-
-ConVar osu_playfield_mirror_horizontal("osu_playfield_mirror_horizontal", false, FCVAR_NONE);
-ConVar osu_playfield_mirror_vertical("osu_playfield_mirror_vertical", false, FCVAR_NONE);
-
-ConVar osu_playfield_rotation("osu_playfield_rotation", 0.0f, FCVAR_CHEAT,
-                              "rotates the entire playfield by this many degrees");
-ConVar osu_playfield_stretch_x("osu_playfield_stretch_x", 0.0f, FCVAR_CHEAT,
-                               "offsets/multiplies all hitobject coordinates by it (0 = default 1x playfield size, -1 "
-                               "= on a line, -0.5 = 0.5x playfield size, 0.5 = 1.5x playfield size)");
-ConVar osu_playfield_stretch_y("osu_playfield_stretch_y", 0.0f, FCVAR_CHEAT,
-                               "offsets/multiplies all hitobject coordinates by it (0 = default 1x playfield size, -1 "
-                               "= on a line, -0.5 = 0.5x playfield size, 0.5 = 1.5x playfield size)");
-ConVar osu_playfield_circular(
-    "osu_playfield_circular", false, FCVAR_CHEAT,
-    "whether the playfield area should be transformed from a rectangle into a circle/disc/oval");
-
-ConVar osu_drain_lazer_health_min("osu_drain_lazer_health_min", 0.95f, FCVAR_NONE);
-ConVar osu_drain_lazer_health_mid("osu_drain_lazer_health_mid", 0.70f, FCVAR_NONE);
-ConVar osu_drain_lazer_health_max("osu_drain_lazer_health_max", 0.30f, FCVAR_NONE);
-
-ConVar osu_mod_wobble("osu_mod_wobble", false, FCVAR_NONVANILLA);
-ConVar osu_mod_wobble2("osu_mod_wobble2", false, FCVAR_NONVANILLA);
-ConVar osu_mod_wobble_strength("osu_mod_wobble_strength", 25.0f, FCVAR_NONE);
-ConVar osu_mod_wobble_frequency("osu_mod_wobble_frequency", 1.0f, FCVAR_NONE);
-ConVar osu_mod_wobble_rotation_speed("osu_mod_wobble_rotation_speed", 1.0f, FCVAR_NONE);
-ConVar osu_mod_jigsaw2("osu_mod_jigsaw2", false, FCVAR_NONVANILLA);
-ConVar osu_mod_jigsaw_followcircle_radius_factor("osu_mod_jigsaw_followcircle_radius_factor", 0.0f, FCVAR_NONE);
-ConVar osu_mod_shirone("osu_mod_shirone", false, FCVAR_NONVANILLA);
-ConVar osu_mod_shirone_combo("osu_mod_shirone_combo", 20.0f, FCVAR_NONE);
-ConVar osu_mod_mafham_render_chunksize("osu_mod_mafham_render_chunksize", 15, FCVAR_NONE,
-                                       "render this many hitobjects per frame chunk into the scene buffer (spreads "
-                                       "rendering across many frames to minimize lag)");
-
-ConVar osu_mandala("osu_mandala", false, FCVAR_CHEAT);
-ConVar osu_mandala_num("osu_mandala_num", 7, FCVAR_NONE);
-
-ConVar osu_debug_hiterrorbar_misaims("osu_debug_hiterrorbar_misaims", false, FCVAR_NONE);
-
-ConVar osu_pp_live_timeout(
-    "osu_pp_live_timeout", 1.0f, FCVAR_NONE,
-    "show message that we're still calculating stars after this many seconds, on the first start of the beatmap");
-
-ConVar *OsuBeatmapStandard::m_osu_draw_statistics_pp_ref = NULL;
-ConVar *OsuBeatmapStandard::m_osu_draw_statistics_livestars_ref = NULL;
-ConVar *OsuBeatmapStandard::m_osu_mod_fullalternate_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_distance_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_curved_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_frequency_x_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_frequency_y_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_frequency_z_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_strength_x_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_strength_y_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_strafing_strength_z_ref = NULL;
-ConVar *OsuBeatmapStandard::m_fposu_mod_3d_depthwobble_ref = NULL;
-ConVar *OsuBeatmapStandard::m_osu_slider_scorev2_ref = NULL;
-
-OsuBeatmapStandard::OsuBeatmapStandard(Osu *osu) : OsuBeatmap(osu) {
-    m_bIsSpinnerActive = false;
-
-    m_fPlayfieldRotation = 0.0f;
-    m_fScaleFactor = 1.0f;
-
-    m_fXMultiplier = 1.0f;
-    m_fNumberScale = 1.0f;
-    m_fHitcircleOverlapScale = 1.0f;
-    m_fRawHitcircleDiameter = 27.35f * 2.0f;
-    m_fHitcircleDiameter = 0.0f;
-    m_fSliderFollowCircleDiameter = 0.0f;
-    m_fRawSliderFollowCircleDiameter = 0.0f;
-
-    m_iAutoCursorDanceIndex = 0;
-
-    m_fAimStars = 0.0f;
-    m_fAimSliderFactor = 0.0f;
-    m_fSpeedStars = 0.0f;
-    m_fSpeedNotes = 0.0f;
-    m_starCacheLoader = new OsuBackgroundStarCacheLoader(this);
-    m_fStarCacheTime = 0.0f;
-
-    m_bWasHREnabled = false;
-    m_fPrevHitCircleDiameter = 0.0f;
-    m_bWasHorizontalMirrorEnabled = false;
-    m_bWasVerticalMirrorEnabled = false;
-    m_bWasEZEnabled = false;
-    m_bWasMafhamEnabled = false;
-    m_fPrevPlayfieldRotationFromConVar = 0.0f;
-    m_fPrevPlayfieldStretchX = 0.0f;
-    m_fPrevPlayfieldStretchY = 0.0f;
-    m_fPrevHitCircleDiameterForStarCache = 1.0f;
-    m_fPrevSpeedForStarCache = 1.0f;
-    m_bIsPreLoading = true;
-    m_iPreLoadingIndex = 0;
-
-    m_mafhamActiveRenderTarget = NULL;
-    m_mafhamFinishedRenderTarget = NULL;
-    m_bMafhamRenderScheduled = true;
-    m_iMafhamHitObjectRenderIndex = 0;
-    m_iMafhamPrevHitObjectIndex = 0;
-    m_iMafhamActiveRenderHitObjectIndex = 0;
-    m_iMafhamFinishedRenderHitObjectIndex = 0;
-    m_bInMafhamRenderChunk = false;
-
-    m_iMandalaIndex = 0;
-
-    // convar refs
-    if(m_osu_draw_statistics_pp_ref == NULL)
-        m_osu_draw_statistics_pp_ref = convar->getConVarByName("osu_draw_statistics_pp");
-    if(m_osu_draw_statistics_livestars_ref == NULL)
-        m_osu_draw_statistics_livestars_ref = convar->getConVarByName("osu_draw_statistics_livestars");
-    if(m_osu_mod_fullalternate_ref == NULL)
-        m_osu_mod_fullalternate_ref = convar->getConVarByName("osu_mod_fullalternate");
-    if(m_fposu_distance_ref == NULL) m_fposu_distance_ref = convar->getConVarByName("fposu_distance");
-    if(m_fposu_curved_ref == NULL) m_fposu_curved_ref = convar->getConVarByName("fposu_curved");
-    if(m_fposu_mod_strafing_ref == NULL) m_fposu_mod_strafing_ref = convar->getConVarByName("fposu_mod_strafing");
-    if(m_fposu_mod_strafing_frequency_x_ref == NULL)
-        m_fposu_mod_strafing_frequency_x_ref = convar->getConVarByName("fposu_mod_strafing_frequency_x");
-    if(m_fposu_mod_strafing_frequency_y_ref == NULL)
-        m_fposu_mod_strafing_frequency_y_ref = convar->getConVarByName("fposu_mod_strafing_frequency_y");
-    if(m_fposu_mod_strafing_frequency_z_ref == NULL)
-        m_fposu_mod_strafing_frequency_z_ref = convar->getConVarByName("fposu_mod_strafing_frequency_z");
-    if(m_fposu_mod_strafing_strength_x_ref == NULL)
-        m_fposu_mod_strafing_strength_x_ref = convar->getConVarByName("fposu_mod_strafing_strength_x");
-    if(m_fposu_mod_strafing_strength_y_ref == NULL)
-        m_fposu_mod_strafing_strength_y_ref = convar->getConVarByName("fposu_mod_strafing_strength_y");
-    if(m_fposu_mod_strafing_strength_z_ref == NULL)
-        m_fposu_mod_strafing_strength_z_ref = convar->getConVarByName("fposu_mod_strafing_strength_z");
-    if(m_fposu_mod_3d_depthwobble_ref == NULL)
-        m_fposu_mod_3d_depthwobble_ref = convar->getConVarByName("fposu_mod_3d_depthwobble");
-    if(m_osu_slider_scorev2_ref == NULL) m_osu_slider_scorev2_ref = convar->getConVarByName("osu_slider_scorev2");
-}
-
-OsuBeatmapStandard::~OsuBeatmapStandard() {
-    m_starCacheLoader->kill();
-
-    if(engine->getResourceManager()->isLoadingResource(m_starCacheLoader))
-        while(!m_starCacheLoader->isAsyncReady()) {
-            ;
-        }
-
-    engine->getResourceManager()->destroyResource(m_starCacheLoader);
-}
-
-void OsuBeatmapStandard::draw(Graphics *g) {
-    OsuBeatmap::draw(g);
-    if(!canDraw()) return;
-    if(isLoading()) return;  // only start drawing the rest of the playfield if everything has loaded
-
-    // draw playfield border
-    if(osu_draw_playfield_border.getBool() && !OsuGameRules::osu_mod_fps.getBool())
-        m_osu->getHUD()->drawPlayfieldBorder(g, m_vPlayfieldCenter, m_vPlayfieldSize, m_fHitcircleDiameter);
-
-    // draw hiterrorbar
-    if(!m_osu_mod_fposu_ref->getBool()) m_osu->getHUD()->drawHitErrorBar(g, this);
-
-    // draw first person crosshair
-    if(OsuGameRules::osu_mod_fps.getBool()) {
-        const int length = 15;
-        Vector2 center =
-            osuCoords2Pixels(Vector2(OsuGameRules::OSU_COORD_WIDTH / 2, OsuGameRules::OSU_COORD_HEIGHT / 2));
-        g->setColor(0xff777777);
-        g->drawLine(center.x, (int)(center.y - length), center.x, (int)(center.y + length + 1));
-        g->drawLine((int)(center.x - length), center.y, (int)(center.x + length + 1), center.y);
-    }
-
-    // draw followpoints
-    if(osu_draw_followpoints.getBool() && !OsuGameRules::osu_mod_mafham.getBool()) drawFollowPoints(g);
-
-    // draw all hitobjects in reverse
-    if(m_osu_draw_hitobjects_ref->getBool()) drawHitObjects(g);
-
-    if(osu_mandala.getBool()) {
-        for(int i = 0; i < osu_mandala_num.getInt(); i++) {
-            m_iMandalaIndex = i;
-            drawHitObjects(g);
-        }
-    }
-
-    // debug stuff
-    if(osu_debug_hiterrorbar_misaims.getBool()) {
-        for(int i = 0; i < m_misaimObjects.size(); i++) {
-            g->setColor(0xbb00ff00);
-            Vector2 pos = osuCoords2Pixels(m_misaimObjects[i]->getRawPosAt(0));
-            g->fillRect(pos.x - 50, pos.y - 50, 100, 100);
-        }
-    }
-}
-
-void OsuBeatmapStandard::drawInt(Graphics *g) {
-    OsuBeatmap::drawInt(g);
-    if(!canDraw()) return;
-
-    if(isLoadingStarCache() && engine->getTime() > m_fStarCacheTime) {
-        float progressPercent = 0.0f;
-        if(m_hitobjects.size() > 0)
-            progressPercent = (float)m_starCacheLoader->getProgress() / (float)m_hitobjects.size();
-
-        g->setColor(0x44ffffff);
-        UString loadingMessage =
-            UString::format("Calculating stars for realtime pp/stars (%i%%) ...", (int)(progressPercent * 100.0f));
-        UString loadingMessage2 = "(To get rid of this delay, disable [Draw Statistics: pp/Stars***])";
-        g->pushTransform();
-        {
-            g->translate(
-                (int)(m_osu->getScreenWidth() / 2 - m_osu->getSubTitleFont()->getStringWidth(loadingMessage) / 2),
-                m_osu->getScreenHeight() - m_osu->getSubTitleFont()->getHeight() - 25);
-            g->drawString(m_osu->getSubTitleFont(), loadingMessage);
-        }
-        g->popTransform();
-        g->pushTransform();
-        {
-            g->translate(
-                (int)(m_osu->getScreenWidth() / 2 - m_osu->getSubTitleFont()->getStringWidth(loadingMessage2) / 2),
-                m_osu->getScreenHeight() - 15);
-            g->drawString(m_osu->getSubTitleFont(), loadingMessage2);
-        }
-        g->popTransform();
-    } else if(bancho.is_playing_a_multi_map() && !bancho.room.all_players_loaded) {
-        if(!m_bIsPreLoading && !isLoadingStarCache())  // usability
-        {
-            g->setColor(0x44ffffff);
-            UString loadingMessage = "Waiting for players ...";
-            g->pushTransform();
-            {
-                g->translate(
-                    (int)(m_osu->getScreenWidth() / 2 - m_osu->getSubTitleFont()->getStringWidth(loadingMessage) / 2),
-                    m_osu->getScreenHeight() - m_osu->getSubTitleFont()->getHeight() - 15);
-                g->drawString(m_osu->getSubTitleFont(), loadingMessage);
-            }
-            g->popTransform();
-        }
-    }
-}
-
-void OsuBeatmapStandard::drawFollowPoints(Graphics *g) {
-    OsuSkin *skin = m_osu->getSkin();
-
-    const long curPos = m_iCurMusicPosWithOffsets;
-
-    // I absolutely hate this, followpoints can be abused for cheesing high AR reading since they always fade in with a
-    // fixed 800 ms custom approach time. Capping it at the current approach rate seems sensible, but unfortunately
-    // that's not what osu is doing. It was non-osu-compliant-clamped since this client existed, but let's see how many
-    // people notice a change after all this time (26.02.2020)
-
-    // 0.7x means animation lasts only 0.7 of it's time
-    const double animationMutiplier = m_osu->getSpeedMultiplier() / m_osu->getAnimationSpeedMultiplier();
-    const long followPointApproachTime =
-        animationMutiplier *
-        (osu_followpoints_clamp.getBool()
-             ? std::min((long)OsuGameRules::getApproachTime(this), (long)osu_followpoints_approachtime.getFloat())
-             : (long)osu_followpoints_approachtime.getFloat());
-    const bool followPointsConnectCombos = osu_followpoints_connect_combos.getBool();
-    const bool followPointsConnectSpinners = osu_followpoints_connect_spinners.getBool();
-    const float followPointSeparationMultiplier = std::max(osu_followpoints_separation_multiplier.getFloat(), 0.1f);
-    const float followPointPrevFadeTime = animationMutiplier * m_osu_followpoints_prevfadetime_ref->getFloat();
-    const float followPointScaleMultiplier = osu_followpoints_scale_multiplier.getFloat();
-
-    // include previous object in followpoints
-    int lastObjectIndex = -1;
-
-    for(int index = m_iPreviousFollowPointObjectIndex; index < m_hitobjects.size(); index++) {
-        lastObjectIndex = index - 1;
-
-        // ignore future spinners
-        OsuSpinner *spinnerPointer = dynamic_cast<OsuSpinner *>(m_hitobjects[index]);
-        if(spinnerPointer != NULL && !followPointsConnectSpinners)  // if this is a spinner
-        {
-            lastObjectIndex = -1;
-            continue;
-        }
-
-        // NOTE: "m_hitobjects[index]->getComboNumber() != 1" breaks (not literally) on new combos
-        // NOTE: the "getComboNumber()" call has been replaced with isEndOfCombo() because of
-        // osu_ignore_beatmap_combo_numbers and osu_number_max
-        const bool isCurrentHitObjectNewCombo =
-            (lastObjectIndex >= 0 ? m_hitobjects[lastObjectIndex]->isEndOfCombo() : false);
-        const bool isCurrentHitObjectSpinner = (lastObjectIndex >= 0 && followPointsConnectSpinners
-                                                    ? dynamic_cast<OsuSpinner *>(m_hitobjects[lastObjectIndex]) != NULL
-                                                    : false);
-        if(lastObjectIndex >= 0 && (!isCurrentHitObjectNewCombo || followPointsConnectCombos ||
-                                    (isCurrentHitObjectSpinner && followPointsConnectSpinners))) {
-            // ignore previous spinners
-            spinnerPointer = dynamic_cast<OsuSpinner *>(m_hitobjects[lastObjectIndex]);
-            if(spinnerPointer != NULL && !followPointsConnectSpinners)  // if this is a spinner
-            {
-                lastObjectIndex = -1;
-                continue;
-            }
-
-            // get time & pos of the last and current object
-            const long lastObjectEndTime =
-                m_hitobjects[lastObjectIndex]->getTime() + m_hitobjects[lastObjectIndex]->getDuration() + 1;
-            const long objectStartTime = m_hitobjects[index]->getTime();
-            const long timeDiff = objectStartTime - lastObjectEndTime;
-
-            const Vector2 startPoint = osuCoords2Pixels(m_hitobjects[lastObjectIndex]->getRawPosAt(lastObjectEndTime));
-            const Vector2 endPoint = osuCoords2Pixels(m_hitobjects[index]->getRawPosAt(objectStartTime));
-
-            const float xDiff = endPoint.x - startPoint.x;
-            const float yDiff = endPoint.y - startPoint.y;
-            const Vector2 diff = endPoint - startPoint;
-            const float dist =
-                std::round(diff.length() * 100.0f) / 100.0f;  // rounded to avoid flicker with playfield rotations
-
-            // draw all points between the two objects
-            const int followPointSeparation = Osu::getUIScale(m_osu, 32) * followPointSeparationMultiplier;
-            for(int j = (int)(followPointSeparation * 1.5f); j < (dist - followPointSeparation);
-                j += followPointSeparation) {
-                const float animRatio = ((float)j / dist);
-
-                const Vector2 animPosStart = startPoint + (animRatio - 0.1f) * diff;
-                const Vector2 finalPos = startPoint + animRatio * diff;
-
-                const long fadeInTime = (long)(lastObjectEndTime + animRatio * timeDiff) - followPointApproachTime;
-                const long fadeOutTime = (long)(lastObjectEndTime + animRatio * timeDiff);
-
-                // draw
-                float alpha = 1.0f;
-                float followAnimPercent =
-                    clamp<float>((float)(curPos - fadeInTime) / (float)followPointPrevFadeTime, 0.0f, 1.0f);
-                followAnimPercent = -followAnimPercent * (followAnimPercent - 2.0f);  // quad out
-
-                // NOTE: only internal osu default skin uses scale + move transforms here, it is impossible to achieve
-                // this effect with user skins
-                const float scale = osu_followpoints_anim.getBool() ? 1.5f - 0.5f * followAnimPercent : 1.0f;
-                const Vector2 followPos = osu_followpoints_anim.getBool()
-                                              ? animPosStart + (finalPos - animPosStart) * followAnimPercent
-                                              : finalPos;
-
-                // bullshit performance optimization: only draw followpoints if within screen bounds (plus a bit of a
-                // margin) there is only one beatmap where this matters currently: https://osu.ppy.sh/b/1145513
-                if(followPos.x < -m_osu->getScreenWidth() || followPos.x > m_osu->getScreenWidth() * 2 ||
-                   followPos.y < -m_osu->getScreenHeight() || followPos.y > m_osu->getScreenHeight() * 2)
-                    continue;
-
-                // calculate trail alpha
-                if(curPos >= fadeInTime && curPos < fadeOutTime) {
-                    // future trail
-                    const float delta = curPos - fadeInTime;
-                    alpha = (float)delta / (float)followPointApproachTime;
-                } else if(curPos >= fadeOutTime && curPos < (fadeOutTime + (long)followPointPrevFadeTime)) {
-                    // previous trail
-                    const long delta = curPos - fadeOutTime;
-                    alpha = 1.0f - (float)delta / (float)(followPointPrevFadeTime);
-                } else
-                    alpha = 0.0f;
-
-                // draw it
-                g->setColor(0xffffffff);
-                g->setAlpha(alpha);
-                g->pushTransform();
-                {
-                    g->rotate(rad2deg(std::atan2(yDiff, xDiff)));
-
-                    skin->getFollowPoint2()->setAnimationTimeOffset(skin->getAnimationSpeed(), fadeInTime);
-
-                    // NOTE: getSizeBaseRaw() depends on the current animation time being set correctly beforehand!
-                    // (otherwise you get incorrect scales, e.g. for animated elements with inconsistent @2x mixed in)
-                    // the followpoints are scaled by one eighth of the hitcirclediameter (not the raw diameter, but the
-                    // scaled diameter)
-                    const float followPointImageScale =
-                        ((m_fHitcircleDiameter / 8.0f) / skin->getFollowPoint2()->getSizeBaseRaw().x) *
-                        followPointScaleMultiplier;
-
-                    skin->getFollowPoint2()->drawRaw(g, followPos, followPointImageScale * scale);
-                }
-                g->popTransform();
-            }
-        }
-
-        // store current index as previous index
-        lastObjectIndex = index;
-
-        // iterate up until the "nextest" element
-        if(m_hitobjects[index]->getTime() >= curPos + followPointApproachTime) break;
-    }
-}
-
-void OsuBeatmapStandard::drawHitObjects(Graphics *g) {
-    const long curPos = m_iCurMusicPosWithOffsets;
-    const long pvs = getPVS();
-    const bool usePVS = m_osu_pvs->getBool();
-
-    if(!OsuGameRules::osu_mod_mafham.getBool()) {
-        if(!osu_draw_reverse_order.getBool()) {
-            for(int i = m_hitobjectsSortedByEndTime.size() - 1; i >= 0; i--) {
-                // PVS optimization (reversed)
-                if(usePVS) {
-                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
-                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
-                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
-                        break;
-                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
-                        continue;
-                }
-
-                m_hitobjectsSortedByEndTime[i]->draw(g);
-            }
-        } else {
-            for(int i = 0; i < m_hitobjectsSortedByEndTime.size(); i++) {
-                // PVS optimization
-                if(usePVS) {
-                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
-                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
-                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
-                        continue;
-                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
-                        break;
-                }
-
-                m_hitobjectsSortedByEndTime[i]->draw(g);
-            }
-        }
-        for(int i = 0; i < m_hitobjectsSortedByEndTime.size(); i++) {
-            // NOTE: to fix mayday simultaneous sliders with increasing endtime getting culled here, would have to
-            // switch from m_hitobjectsSortedByEndTime to m_hitobjects PVS optimization
-            if(usePVS) {
-                if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
-                   (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
-                                       m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
-                    continue;
-                if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
-                    break;
-            }
-
-            m_hitobjectsSortedByEndTime[i]->draw2(g);
-        }
-    } else {
-        const int mafhamRenderLiveSize = OsuGameRules::osu_mod_mafham_render_livesize.getInt();
-
-        if(m_mafhamActiveRenderTarget == NULL) m_mafhamActiveRenderTarget = m_osu->getFrameBuffer();
-
-        if(m_mafhamFinishedRenderTarget == NULL) m_mafhamFinishedRenderTarget = m_osu->getFrameBuffer2();
-
-        // if we have a chunk to render into the scene buffer
-        const bool shouldDrawBuffer =
-            (m_hitobjectsSortedByEndTime.size() - m_iCurrentHitObjectIndex) > mafhamRenderLiveSize;
-        bool shouldRenderChunk = m_iMafhamHitObjectRenderIndex < m_hitobjectsSortedByEndTime.size() && shouldDrawBuffer;
-        if(shouldRenderChunk) {
-            m_bInMafhamRenderChunk = true;
-
-            m_mafhamActiveRenderTarget->setClearColorOnDraw(m_iMafhamHitObjectRenderIndex == 0);
-            m_mafhamActiveRenderTarget->setClearDepthOnDraw(m_iMafhamHitObjectRenderIndex == 0);
-
-            m_mafhamActiveRenderTarget->enable();
-            {
-                g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_PREMUL_ALPHA);
-                {
-                    int chunkCounter = 0;
-                    for(int i = m_hitobjectsSortedByEndTime.size() - 1 - m_iMafhamHitObjectRenderIndex; i >= 0;
-                        i--, m_iMafhamHitObjectRenderIndex++) {
-                        chunkCounter++;
-                        if(chunkCounter > osu_mod_mafham_render_chunksize.getInt())
-                            break;  // continue chunk render in next frame
-
-                        if(i <= m_iCurrentHitObjectIndex + mafhamRenderLiveSize)  // skip live objects
-                        {
-                            m_iMafhamHitObjectRenderIndex = m_hitobjectsSortedByEndTime.size();  // stop chunk render
-                            break;
-                        }
-
-                        // PVS optimization (reversed)
-                        if(usePVS) {
-                            if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
-                               (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
-                                                   m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
-                            {
-                                m_iMafhamHitObjectRenderIndex =
-                                    m_hitobjectsSortedByEndTime.size();  // stop chunk render
-                                break;
-                            }
-                            if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
-                                continue;
-                        }
-
-                        m_hitobjectsSortedByEndTime[i]->draw(g);
-
-                        m_iMafhamActiveRenderHitObjectIndex = i;
-                    }
-                }
-                g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_ALPHA);
-            }
-            m_mafhamActiveRenderTarget->disable();
-
-            m_bInMafhamRenderChunk = false;
-        }
-        shouldRenderChunk = m_iMafhamHitObjectRenderIndex < m_hitobjectsSortedByEndTime.size() && shouldDrawBuffer;
-        if(!shouldRenderChunk && m_bMafhamRenderScheduled) {
-            // finished, we can now swap the active framebuffer with the one we just finished
-            m_bMafhamRenderScheduled = false;
-
-            RenderTarget *temp = m_mafhamFinishedRenderTarget;
-            m_mafhamFinishedRenderTarget = m_mafhamActiveRenderTarget;
-            m_mafhamActiveRenderTarget = temp;
-
-            m_iMafhamFinishedRenderHitObjectIndex = m_iMafhamActiveRenderHitObjectIndex;
-            m_iMafhamActiveRenderHitObjectIndex = m_hitobjectsSortedByEndTime.size();  // reset
-        }
-
-        // draw scene buffer
-        if(shouldDrawBuffer) {
-            g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_PREMUL_COLOR);
-            { m_mafhamFinishedRenderTarget->draw(g, 0, 0); }
-            g->setBlendMode(Graphics::BLEND_MODE::BLEND_MODE_ALPHA);
-        }
-
-        // draw followpoints
-        if(osu_draw_followpoints.getBool()) drawFollowPoints(g);
-
-        // draw live hitobjects (also, code duplication yay)
-        {
-            for(int i = m_hitobjectsSortedByEndTime.size() - 1; i >= 0; i--) {
-                // PVS optimization (reversed)
-                if(usePVS) {
-                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
-                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
-                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
-                        break;
-                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
-                        continue;
-                }
-
-                if(i > m_iCurrentHitObjectIndex + mafhamRenderLiveSize ||
-                   (i > m_iMafhamFinishedRenderHitObjectIndex - 1 && shouldDrawBuffer))  // skip non-live objects
-                    continue;
-
-                m_hitobjectsSortedByEndTime[i]->draw(g);
-            }
-
-            for(int i = 0; i < m_hitobjectsSortedByEndTime.size(); i++) {
-                // PVS optimization
-                if(usePVS) {
-                    if(m_hitobjectsSortedByEndTime[i]->isFinished() &&
-                       (curPos - pvs > m_hitobjectsSortedByEndTime[i]->getTime() +
-                                           m_hitobjectsSortedByEndTime[i]->getDuration()))  // past objects
-                        continue;
-                    if(m_hitobjectsSortedByEndTime[i]->getTime() > curPos + pvs)  // future objects
-                        break;
-                }
-
-                if(i >= m_iCurrentHitObjectIndex + mafhamRenderLiveSize ||
-                   (i >= m_iMafhamFinishedRenderHitObjectIndex - 1 && shouldDrawBuffer))  // skip non-live objects
-                    break;
-
-                m_hitobjectsSortedByEndTime[i]->draw2(g);
-            }
-        }
-    }
-}
-
-void OsuBeatmapStandard::update() {
-    if(!canUpdate()) {
-        OsuBeatmap::update();
-        return;
-    }
-
-    // the order of execution in here is a bit specific, since some things do need to be updated before loading has
-    // finished note that the baseclass call to update() is NOT the first call, but comes after updating the metrics and
-    // wobble
-
-    // live update hitobject and playfield metrics
-    updateHitobjectMetrics();
-    updatePlayfieldMetrics();
-
-    // wobble mod
-    if(osu_mod_wobble.getBool()) {
-        const float speedMultiplierCompensation = 1.0f / getSpeedMultiplier();
-        m_fPlayfieldRotation =
-            (m_iCurMusicPos / 1000.0f) * 30.0f * speedMultiplierCompensation * osu_mod_wobble_rotation_speed.getFloat();
-        m_fPlayfieldRotation = std::fmod(m_fPlayfieldRotation, 360.0f);
-    } else
-        m_fPlayfieldRotation = 0.0f;
-
-    // baseclass call (does the actual hitobject updates among other things)
-    OsuBeatmap::update();
-
-    // handle preloading (only for distributed slider vertexbuffer generation atm)
-    if(m_bIsPreLoading) {
-        if(Osu::debug->getBool() && m_iPreLoadingIndex == 0)
-            debugLog("OsuBeatmapStandard: Preloading slider vertexbuffers ...\n");
-
-        double startTime = engine->getTimeReal();
-        double delta = 0.0;
-
-        // hardcoded deadline of 10 ms, will temporarily bring us down to 45fps on average (better than freezing)
-        while(delta < 0.010 && m_bIsPreLoading) {
-            if(m_iPreLoadingIndex >= m_hitobjects.size()) {
-                m_bIsPreLoading = false;
-                debugLog("OsuBeatmapStandard: Preloading done.\n");
-                break;
-            } else {
-                OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[m_iPreLoadingIndex]);
-                if(sliderPointer != NULL) sliderPointer->rebuildVertexBuffer();
-            }
-
-            m_iPreLoadingIndex++;
-            delta = engine->getTimeReal() - startTime;
-        }
-    }
-
-    // notify all other players (including ourself) once we've finished loading
-    if(bancho.is_playing_a_multi_map()) {
-        if(!isLoadingInt())  // if local loading has finished
-        {
-            if(!bancho.room.player_loaded) {
-                bancho.room.player_loaded = true;
-
-                Packet packet;
-                packet.id = MATCH_LOAD_COMPLETE;
-                send_packet(packet);
-            }
-        }
-    }
-
-    if(isLoading()) return;  // only continue if we have loaded everything
-
-    // update auto (after having updated the hitobjects)
-    if(m_osu->getModAuto() || m_osu->getModAutopilot()) updateAutoCursorPos();
-
-    // spinner detection (used by osu!stable drain, and by OsuHUD for not drawing the hiterrorbar)
-    if(m_currentHitObject != NULL) {
-        OsuSpinner *spinnerPointer = dynamic_cast<OsuSpinner *>(m_currentHitObject);
-        if(spinnerPointer != NULL && m_iCurMusicPosWithOffsets > m_currentHitObject->getTime() &&
-           m_iCurMusicPosWithOffsets < m_currentHitObject->getTime() + m_currentHitObject->getDuration())
-            m_bIsSpinnerActive = true;
-        else
-            m_bIsSpinnerActive = false;
-    }
-
-    // scene buffering logic
-    if(OsuGameRules::osu_mod_mafham.getBool()) {
-        if(!m_bMafhamRenderScheduled &&
-           m_iCurrentHitObjectIndex !=
-               m_iMafhamPrevHitObjectIndex)  // if we are not already rendering and the index changed
-        {
-            m_iMafhamPrevHitObjectIndex = m_iCurrentHitObjectIndex;
-            m_iMafhamHitObjectRenderIndex = 0;
-            m_bMafhamRenderScheduled = true;
-        }
-    }
-
-    // full alternate mod lenience
-    if(m_osu_mod_fullalternate_ref->getBool()) {
-        if(m_bInBreak || m_bIsInSkippableSection || m_bIsSpinnerActive || m_iCurrentHitObjectIndex < 1)
-            m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex = m_iCurrentHitObjectIndex + 1;
-    }
-
-    if(last_keys != current_keys) {
-        write_frame();
-    } else if(last_event_time + 0.01666666666 <= engine->getTimeReal()) {
-        write_frame();
-    }
-}
-
-void OsuBeatmapStandard::write_frame() {
-    if(!m_bIsPlaying || m_bFailed || m_bIsWatchingReplay) return;
-
-    long delta = m_iCurMusicPosWithOffsets - last_event_ms;
-    if(delta < 0) return;
-    if(delta == 0 && last_keys == current_keys) return;
-
-    Vector2 pos = pixels2OsuCoords(getCursorPos());
-    if(osu_playfield_mirror_horizontal.getBool()) pos.y = OsuGameRules::OSU_COORD_HEIGHT - pos.y;
-    if(osu_playfield_mirror_vertical.getBool()) pos.x = OsuGameRules::OSU_COORD_WIDTH - pos.x;
-
-    replay.push_back(OsuReplay::Frame{
-        .cur_music_pos = m_iCurMusicPosWithOffsets,
-        .milliseconds_since_last_frame = delta,
-        .x = pos.x,
-        .y = pos.y,
-        .key_flags = current_keys,
-    });
-    last_event_time = m_fLastRealTimeForInterpolationDelta;
-    last_event_ms = m_iCurMusicPosWithOffsets;
-    last_keys = current_keys;
-}
-
-void OsuBeatmapStandard::onModUpdate(bool rebuildSliderVertexBuffers, bool recomputeDrainRate) {
-    if(Osu::debug->getBool()) debugLog("OsuBeatmapStandard::onModUpdate() @ %f\n", engine->getTime());
-
-    updatePlayfieldMetrics();
-    updateHitobjectMetrics();
-
-    if(recomputeDrainRate) computeDrainRate();
-
-    if(m_music != NULL) {
-        m_music->setSpeed(m_osu->getSpeedMultiplier());
-    }
-
-    // recalculate slider vertexbuffers
-    if(m_osu->getModHR() != m_bWasHREnabled ||
-       osu_playfield_mirror_horizontal.getBool() != m_bWasHorizontalMirrorEnabled ||
-       osu_playfield_mirror_vertical.getBool() != m_bWasVerticalMirrorEnabled) {
-        m_bWasHREnabled = m_osu->getModHR();
-        m_bWasHorizontalMirrorEnabled = osu_playfield_mirror_horizontal.getBool();
-        m_bWasVerticalMirrorEnabled = osu_playfield_mirror_vertical.getBool();
-
-        calculateStacks();
-
-        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
-    }
-    if(m_osu->getModEZ() != m_bWasEZEnabled) {
-        calculateStacks();
-
-        m_bWasEZEnabled = m_osu->getModEZ();
-        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
-    }
-    if(getHitcircleDiameter() != m_fPrevHitCircleDiameter && m_hitobjects.size() > 0) {
-        calculateStacks();
-
-        m_fPrevHitCircleDiameter = getHitcircleDiameter();
-        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
-    }
-    if(osu_playfield_rotation.getFloat() != m_fPrevPlayfieldRotationFromConVar) {
-        m_fPrevPlayfieldRotationFromConVar = osu_playfield_rotation.getFloat();
-        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
-    }
-    if(osu_playfield_stretch_x.getFloat() != m_fPrevPlayfieldStretchX) {
-        calculateStacks();
-
-        m_fPrevPlayfieldStretchX = osu_playfield_stretch_x.getFloat();
-        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
-    }
-    if(osu_playfield_stretch_y.getFloat() != m_fPrevPlayfieldStretchY) {
-        calculateStacks();
-
-        m_fPrevPlayfieldStretchY = osu_playfield_stretch_y.getFloat();
-        if(rebuildSliderVertexBuffers) updateSliderVertexBuffers();
-    }
-    if(OsuGameRules::osu_mod_mafham.getBool() != m_bWasMafhamEnabled) {
-        m_bWasMafhamEnabled = OsuGameRules::osu_mod_mafham.getBool();
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            m_hitobjects[i]->update(m_iCurMusicPosWithOffsets);
-        }
-    }
-
-    // recalculate star cache for live pp
-    if(m_osu_draw_statistics_pp_ref->getBool() ||
-       m_osu_draw_statistics_livestars_ref->getBool())  // sanity + performance/usability
-    {
-        bool didCSChange = false;
-        if(getHitcircleDiameter() != m_fPrevHitCircleDiameterForStarCache && m_hitobjects.size() > 0) {
-            m_fPrevHitCircleDiameterForStarCache = getHitcircleDiameter();
-            didCSChange = true;
-        }
-
-        bool didSpeedChange = false;
-        if(m_osu->getSpeedMultiplier() != m_fPrevSpeedForStarCache && m_hitobjects.size() > 0) {
-            m_fPrevSpeedForStarCache =
-                m_osu->getSpeedMultiplier();  // this is not using the beatmap function for speed on purpose, because
-                                              // that wouldn't work while the music is still loading
-            didSpeedChange = true;
-        }
-
-        if(didCSChange || didSpeedChange) {
-            if(m_selectedDifficulty2 != NULL) updateStarCache();
-        }
-    }
-}
-
-bool OsuBeatmapStandard::isLoading() {
-    return (isLoadingInt() || (bancho.is_playing_a_multi_map() && !bancho.room.all_players_loaded));
-}
-
-Vector2 OsuBeatmapStandard::pixels2OsuCoords(Vector2 pixelCoords) const {
-    // un-first-person
-    if(OsuGameRules::osu_mod_fps.getBool()) {
-        // HACKHACK: this is the worst hack possible (engine->isDrawing()), but it works
-        // the problem is that this same function is called while draw()ing and update()ing
-        if(!((engine->isDrawing() && (m_osu->getModAuto() || m_osu->getModAutopilot())) ||
-             !(m_osu->getModAuto() || m_osu->getModAutopilot())))
-            pixelCoords += getFirstPersonCursorDelta();
-    }
-
-    // un-offset and un-scale, reverse order
-    pixelCoords -= m_vPlayfieldOffset;
-    pixelCoords /= m_fScaleFactor;
-
-    return pixelCoords;
-}
-
-Vector2 OsuBeatmapStandard::osuCoords2Pixels(Vector2 coords) const {
-    if(m_osu->getModHR()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
-    if(osu_playfield_mirror_horizontal.getBool()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
-    if(osu_playfield_mirror_vertical.getBool()) coords.x = OsuGameRules::OSU_COORD_WIDTH - coords.x;
-
-    // wobble
-    if(osu_mod_wobble.getBool()) {
-        const float speedMultiplierCompensation = 1.0f / getSpeedMultiplier();
-        coords.x += std::sin((m_iCurMusicPos / 1000.0f) * 5 * speedMultiplierCompensation *
-                             osu_mod_wobble_frequency.getFloat()) *
-                    osu_mod_wobble_strength.getFloat();
-        coords.y += std::sin((m_iCurMusicPos / 1000.0f) * 4 * speedMultiplierCompensation *
-                             osu_mod_wobble_frequency.getFloat()) *
-                    osu_mod_wobble_strength.getFloat();
-    }
-
-    // wobble2
-    if(osu_mod_wobble2.getBool()) {
-        const float speedMultiplierCompensation = 1.0f / getSpeedMultiplier();
-        Vector2 centerDelta = coords - Vector2(OsuGameRules::OSU_COORD_WIDTH, OsuGameRules::OSU_COORD_HEIGHT) / 2;
-        coords.x += centerDelta.x * 0.25f *
-                    std::sin((m_iCurMusicPos / 1000.0f) * 5 * speedMultiplierCompensation *
-                             osu_mod_wobble_frequency.getFloat()) *
-                    osu_mod_wobble_strength.getFloat();
-        coords.y += centerDelta.y * 0.25f *
-                    std::sin((m_iCurMusicPos / 1000.0f) * 3 * speedMultiplierCompensation *
-                             osu_mod_wobble_frequency.getFloat()) *
-                    osu_mod_wobble_strength.getFloat();
-    }
-
-    // rotation
-    if(m_fPlayfieldRotation + osu_playfield_rotation.getFloat() != 0.0f) {
-        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
-        Matrix4 rot;
-        rot.rotateZ(m_fPlayfieldRotation + osu_playfield_rotation.getFloat());  // (m_iCurMusicPos/1000.0f)*30
-
-        coords3 = coords3 * rot;
-        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        coords.x = coords3.x;
-        coords.y = coords3.y;
-    }
-
-    if(osu_mandala.getBool()) {
-        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
-        Matrix4 rot;
-        rot.rotateZ((360.0f / osu_mandala_num.getInt()) * (m_iMandalaIndex + 1));  // (m_iCurMusicPos/1000.0f)*30
-
-        coords3 = coords3 * rot;
-        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        coords.x = coords3.x;
-        coords.y = coords3.y;
-    }
-
-    // if wobble, clamp coordinates
-    if(osu_mod_wobble.getBool() || osu_mod_wobble2.getBool()) {
-        coords.x = clamp<float>(coords.x, 0.0f, OsuGameRules::OSU_COORD_WIDTH);
-        coords.y = clamp<float>(coords.y, 0.0f, OsuGameRules::OSU_COORD_HEIGHT);
-    }
-
-    if(m_bFailed) {
-        float failTimePercentInv = 1.0f - m_fFailAnim;  // goes from 0 to 1 over the duration of osu_fail_time
-        failTimePercentInv *= failTimePercentInv;
-
-        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
-        Matrix4 rot;
-        rot.rotateZ(failTimePercentInv * 60.0f);
-
-        coords3 = coords3 * rot;
-        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        coords.x = coords3.x + failTimePercentInv * OsuGameRules::OSU_COORD_WIDTH * 0.25f;
-        coords.y = coords3.y + failTimePercentInv * OsuGameRules::OSU_COORD_HEIGHT * 1.25f;
-    }
-
-    // playfield stretching/transforming
-    coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;  // center
-    coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
-    {
-        if(osu_playfield_circular.getBool()) {
-            // normalize to -1 +1
-            coords.x /= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
-            coords.y /= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
-
-            // clamp (for sqrt) and transform
-            coords.x = clamp<float>(coords.x, -1.0f, 1.0f);
-            coords.y = clamp<float>(coords.y, -1.0f, 1.0f);
-            coords = mapNormalizedCoordsOntoUnitCircle(coords);
-
-            // and scale back up
-            coords.x *= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
-            coords.y *= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
-        }
-
-        // stretch
-        coords.x *= 1.0f + osu_playfield_stretch_x.getFloat();
-        coords.y *= 1.0f + osu_playfield_stretch_y.getFloat();
-    }
-    coords.x += OsuGameRules::OSU_COORD_WIDTH / 2;  // undo center
-    coords.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-    // scale and offset
-    coords *= m_fScaleFactor;
-    coords += m_vPlayfieldOffset;  // the offset is already scaled, just add it
-
-    // first person mod, centered cursor
-    if(OsuGameRules::osu_mod_fps.getBool()) {
-        // this is the worst hack possible (engine->isDrawing()), but it works
-        // the problem is that this same function is called while draw()ing and update()ing
-        if((engine->isDrawing() && (m_osu->getModAuto() || m_osu->getModAutopilot())) ||
-           !(m_osu->getModAuto() || m_osu->getModAutopilot()))
-            coords += getFirstPersonCursorDelta();
-    }
-
-    return coords;
-}
-
-Vector2 OsuBeatmapStandard::osuCoords2RawPixels(Vector2 coords) const {
-    // scale and offset
-    coords *= m_fScaleFactor;
-    coords += m_vPlayfieldOffset;  // the offset is already scaled, just add it
-
-    return coords;
-}
-
-Vector2 OsuBeatmapStandard::osuCoords2LegacyPixels(Vector2 coords) const {
-    if(m_osu->getModHR()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
-    if(osu_playfield_mirror_horizontal.getBool()) coords.y = OsuGameRules::OSU_COORD_HEIGHT - coords.y;
-    if(osu_playfield_mirror_vertical.getBool()) coords.x = OsuGameRules::OSU_COORD_WIDTH - coords.x;
-
-    // rotation
-    if(m_fPlayfieldRotation + osu_playfield_rotation.getFloat() != 0.0f) {
-        coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        Vector3 coords3 = Vector3(coords.x, coords.y, 0);
-        Matrix4 rot;
-        rot.rotateZ(m_fPlayfieldRotation + osu_playfield_rotation.getFloat());
-
-        coords3 = coords3 * rot;
-        coords3.x += OsuGameRules::OSU_COORD_WIDTH / 2;
-        coords3.y += OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-        coords.x = coords3.x;
-        coords.y = coords3.y;
-    }
-
-    // VR center
-    coords.x -= OsuGameRules::OSU_COORD_WIDTH / 2;
-    coords.y -= OsuGameRules::OSU_COORD_HEIGHT / 2;
-
-    if(osu_playfield_circular.getBool()) {
-        // normalize to -1 +1
-        coords.x /= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
-        coords.y /= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
-
-        // clamp (for sqrt) and transform
-        coords.x = clamp<float>(coords.x, -1.0f, 1.0f);
-        coords.y = clamp<float>(coords.y, -1.0f, 1.0f);
-        coords = mapNormalizedCoordsOntoUnitCircle(coords);
-
-        // and scale back up
-        coords.x *= (float)OsuGameRules::OSU_COORD_WIDTH / 2.0f;
-        coords.y *= (float)OsuGameRules::OSU_COORD_HEIGHT / 2.0f;
-    }
-
-    // VR scale
-    coords.x *= 1.0f + osu_playfield_stretch_x.getFloat();
-    coords.y *= 1.0f + osu_playfield_stretch_y.getFloat();
-
-    return coords;
-}
-
-Vector2 OsuBeatmapStandard::getCursorPos() const {
-    if(OsuGameRules::osu_mod_fps.getBool() && !m_bIsPaused) {
-        if(m_osu->getModAuto() || m_osu->getModAutopilot())
-            return m_vAutoCursorPos;
-        else
-            return m_vPlayfieldCenter;
-    } else if(m_osu->getModAuto() || m_osu->getModAutopilot())
-        return m_vAutoCursorPos;
-    else {
-        Vector2 pos = engine->getMouse()->getPos();
-        if(osu_mod_shirone.getBool() && m_osu->getScore()->getCombo() > 0)  // <3
-            return pos + Vector2(std::sin((m_iCurMusicPos / 20.0f) * 1.15f) *
-                                     ((float)m_osu->getScore()->getCombo() / osu_mod_shirone_combo.getFloat()),
-                                 std::cos((m_iCurMusicPos / 20.0f) * 1.3f) *
-                                     ((float)m_osu->getScore()->getCombo() / osu_mod_shirone_combo.getFloat()));
-        else
-            return pos;
-    }
-}
-
-Vector2 OsuBeatmapStandard::getFirstPersonCursorDelta() const {
-    return m_vPlayfieldCenter -
-           (m_osu->getModAuto() || m_osu->getModAutopilot() ? m_vAutoCursorPos : engine->getMouse()->getPos());
-}
-
-float OsuBeatmapStandard::getHitcircleDiameter() const { return m_fHitcircleDiameter; }
-
-void OsuBeatmapStandard::onBeforeLoad() {
-    debugLog("OsuBeatmapStandard::onBeforeLoad()\n");
-
-    // some hitobjects already need this information to be up-to-date before their constructor is called
-    updatePlayfieldMetrics();
-    updateHitobjectMetrics();
-
-    m_bIsPreLoading = false;
-}
-
-void OsuBeatmapStandard::onLoad() {
-    debugLog("OsuBeatmapStandard::onLoad()\n");
-
-    // after the hitobjects have been loaded we can calculate the stacks
-    calculateStacks();
-    computeDrainRate();
-
-    // start preloading (delays the play start until it's set to false, see isLoading())
-    m_bIsPreLoading = true;
-    m_iPreLoadingIndex = 0;
-
-    // build stars
-    m_fStarCacheTime =
-        engine->getTime() +
-        osu_pp_live_timeout
-            .getFloat();  // first time delay only. subsequent updates should immediately show the loading spinner
-    updateStarCache();
-}
-
-void OsuBeatmapStandard::onPlayStart() {
-    debugLog("OsuBeatmapStandard::onPlayStart()\n");
-
-    onModUpdate(
-        false,
-        false);  // if there are calculations in there that need the hitobjects to be loaded, also applies speed/pitch
-}
-
-void OsuBeatmapStandard::onBeforeStop(bool quit) {
-    debugLog("OsuBeatmapStandard::onBeforeStop()\n");
-
-    // kill any running star cache loader
-    stopStarCacheLoader();
-
-    // calculate stars
-    double aim = 0.0;
-    double aimSliderFactor = 0.0;
-    double speed = 0.0;
-    double speedNotes = 0.0;
-    const std::string &osuFilePath = m_selectedDifficulty2->getFilePath();
-    const Osu::GAMEMODE gameMode = Osu::GAMEMODE::STD;
-    const float AR = getAR();
-    const float CS = getCS();
-    const float OD = getOD();
-    const float speedMultiplier = m_osu->getSpeedMultiplier();  // NOTE: not this->getSpeedMultiplier()!
-    const bool relax = m_osu->getModRelax();
-    const bool touchDevice = m_osu->getModTD();
-
-    OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres =
-        OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, gameMode, AR, CS, speedMultiplier);
-    const double totalStars = OsuDifficultyCalculator::calculateStarDiffForHitObjects(
-        diffres.diffobjects, CS, OD, speedMultiplier, relax, touchDevice, &aim, &aimSliderFactor, &speed, &speedNotes);
-
-    m_fAimStars = (float)aim;
-    m_fSpeedStars = (float)speed;
-
-    // calculate final pp
-    const int numHitObjects = m_hitobjects.size();
-    const int numCircles = m_selectedDifficulty2->getNumCircles();
-    const int numSliders = m_selectedDifficulty2->getNumSliders();
-    const int numSpinners = m_selectedDifficulty2->getNumSpinners();
-    const int maxPossibleCombo = m_iMaxPossibleCombo;
-    const int highestCombo = m_osu->getScore()->getComboMax();
-    const int numMisses = m_osu->getScore()->getNumMisses();
-    const int num300s = m_osu->getScore()->getNum300s();
-    const int num100s = m_osu->getScore()->getNum100s();
-    const int num50s = m_osu->getScore()->getNum50s();
-    const float pp = OsuDifficultyCalculator::calculatePPv2(
-        m_osu, this, aim, aimSliderFactor, speed, speedNotes, numHitObjects, numCircles, numSliders, numSpinners,
-        maxPossibleCombo, highestCombo, numMisses, num300s, num100s, num50s);
-    m_osu->getScore()->setStarsTomTotal(totalStars);
-    m_osu->getScore()->setStarsTomAim(m_fAimStars);
-    m_osu->getScore()->setStarsTomSpeed(m_fSpeedStars);
-    m_osu->getScore()->setPPv2(pp);
-
-    // save local score, but only under certain conditions
-    const bool isComplete = (num300s + num100s + num50s + numMisses >= numHitObjects);
-    const bool isZero = (m_osu->getScore()->getScore() < 1);
-    const bool isCheated =
-        (m_osu->getModAuto() || (m_osu->getModAutopilot() && m_osu->getModRelax())) || m_osu->getScore()->isUnranked();
-
-    OsuDatabase::Score score;
-    score.isLegacyScore = false;
-    score.isImportedLegacyScore = false;
-    score.version = OsuScore::VERSION;
-    score.unixTimestamp =
-        std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
-
-    if(bancho.is_online()) {
-        score.player_id = bancho.user_id;
-        score.playerName = bancho.username;
-    } else {
-        score.playerName = convar->getConVarByName("name")->getString();
-    }
-    score.passed = isComplete && !isZero && !m_osu->getScore()->hasDied();
-    score.grade = score.passed ? m_osu->getScore()->getGrade() : OsuScore::GRADE::GRADE_F;
-    score.diff2 = m_selectedDifficulty2;
-    score.ragequit = quit;
-    score.play_time_ms = m_iCurMusicPos / m_osu->getSpeedMultiplier();
-
-    score.num300s = m_osu->getScore()->getNum300s();
-    score.num100s = m_osu->getScore()->getNum100s();
-    score.num50s = m_osu->getScore()->getNum50s();
-    score.numGekis = m_osu->getScore()->getNum300gs();
-    score.numKatus = m_osu->getScore()->getNum100ks();
-    score.numMisses = m_osu->getScore()->getNumMisses();
-    score.score = m_osu->getScore()->getScore();
-    score.comboMax = m_osu->getScore()->getComboMax();
-    score.perfect = (maxPossibleCombo > 0 && score.comboMax > 0 && score.comboMax >= maxPossibleCombo);
-    score.numSliderBreaks = m_osu->getScore()->getNumSliderBreaks();
-    score.pp = pp;
-    score.unstableRate = m_osu->getScore()->getUnstableRate();
-    score.hitErrorAvgMin = m_osu->getScore()->getHitErrorAvgMin();
-    score.hitErrorAvgMax = m_osu->getScore()->getHitErrorAvgMax();
-    score.starsTomTotal = totalStars;
-    score.starsTomAim = aim;
-    score.starsTomSpeed = speed;
-    score.speedMultiplier = m_osu->getSpeedMultiplier();
-    score.CS = CS;
-    score.AR = AR;
-    score.OD = getOD();
-    score.HP = getHP();
-    score.maxPossibleCombo = maxPossibleCombo;
-    score.numHitObjects = numHitObjects;
-    score.numCircles = numCircles;
-    score.modsLegacy = m_osu->getScore()->getModsLegacy();
-
-    // special case: manual slider accuracy has been enabled (affects pp but not score), so force scorev2 for
-    // potential future score recalculations
-    score.modsLegacy |= (m_osu_slider_scorev2_ref->getBool() ? ModFlags::ScoreV2 : 0);
-
-    std::vector<ConVar *> allExperimentalMods = m_osu->getExperimentalMods();
-    for(int i = 0; i < allExperimentalMods.size(); i++) {
-        if(allExperimentalMods[i]->getBool()) {
-            score.experimentalModsConVars.append(allExperimentalMods[i]->getName());
-            score.experimentalModsConVars.append(";");
-        }
-    }
-
-    score.md5hash = m_selectedDifficulty2->getMD5Hash();  // NOTE: necessary for "Use Mods"
-
-    int scoreIndex = -1;
-
-    if(!isCheated) {
-        OsuRichPresence::onPlayEnd(m_osu, quit);
-
-        if(bancho.submit_scores() && !isZero && vanilla) {
-            score.replay = replay;
-            submit_score(score);
-        }
-
-        if(score.passed) {
-            scoreIndex = m_osu->getSongBrowser()->getDatabase()->addScore(m_selectedDifficulty2->getMD5Hash(), score);
-            if(scoreIndex == -1) {
-                m_osu->getNotificationOverlay()->addNotification("Failed saving score!", 0xffff0000, false, 3.0f);
-            }
-        }
-    }
-
-    m_osu->getScore()->setIndex(scoreIndex);
-    m_osu->getScore()->setComboFull(maxPossibleCombo);  // used in OsuRankingScreen/OsuUIRankingScreenRankingPanel
-
-    // special case: incomplete scores should NEVER show pp, even if auto
-    if(!isComplete) {
-        m_osu->getScore()->setPPv2(0.0f);
-    }
-
-    debugLog("OsuBeatmapStandard::onBeforeStop() done.\n");
-}
-
-void OsuBeatmapStandard::onPaused(bool first) {
-    debugLog("OsuBeatmapStandard::onPaused()\n");
-
-    if(first) {
-        m_vContinueCursorPoint = engine->getMouse()->getPos();
-
-        if(OsuGameRules::osu_mod_fps.getBool()) m_vContinueCursorPoint = OsuGameRules::getPlayfieldCenter(m_osu);
-    }
-}
-
-void OsuBeatmapStandard::updateAutoCursorPos() {
-    m_vAutoCursorPos = m_vPlayfieldCenter;
-    m_vAutoCursorPos.y *= 2.5f;  // start moving in offscreen from bottom
-
-    if(!m_bIsPlaying && !m_bIsPaused) {
-        m_vAutoCursorPos = m_vPlayfieldCenter;
-        return;
-    }
-    if(m_hitobjects.size() < 1) {
-        m_vAutoCursorPos = m_vPlayfieldCenter;
-        return;
-    }
-
-    const long curMusicPos = m_iCurMusicPosWithOffsets;
-
-    // general
-    long prevTime = 0;
-    long nextTime = m_hitobjects[0]->getTime();
-    Vector2 prevPos = m_vAutoCursorPos;
-    Vector2 curPos = m_vAutoCursorPos;
-    Vector2 nextPos = m_vAutoCursorPos;
-    bool haveCurPos = false;
-
-    // dance
-    int nextPosIndex = 0;
-
-    if(m_hitobjects[0]->getTime() < (long)m_osu_early_note_time_ref->getInt())
-        prevTime = -(long)m_osu_early_note_time_ref->getInt() * getSpeedMultiplier();
-
-    if(m_osu->getModAuto()) {
-        bool autoDanceOverride = false;
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            OsuHitObject *o = m_hitobjects[i];
-
-            // get previous object
-            if(o->getTime() <= curMusicPos) {
-                prevTime = o->getTime() + o->getDuration();
-                prevPos = o->getAutoCursorPos(curMusicPos);
-                if(o->getDuration() > 0 && curMusicPos - o->getTime() <= o->getDuration()) {
-                    if(osu_auto_cursordance.getBool()) {
-                        OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(o);
-                        if(sliderPointer != NULL) {
-                            const std::vector<OsuSlider::SLIDERCLICK> &clicks = sliderPointer->getClicks();
-
-                            // start
-                            prevTime = o->getTime();
-                            prevPos = osuCoords2Pixels(o->getRawPosAt(prevTime));
-
-                            long biggestPrevious = 0;
-                            long smallestNext = std::numeric_limits<long>::max();
-                            bool allFinished = true;
-                            long endTime = 0;
-
-                            // middle clicks
-                            for(int c = 0; c < clicks.size(); c++) {
-                                // get previous click
-                                if(clicks[c].time <= curMusicPos && clicks[c].time > biggestPrevious) {
-                                    biggestPrevious = clicks[c].time;
-                                    prevTime = clicks[c].time;
-                                    prevPos = osuCoords2Pixels(o->getRawPosAt(prevTime));
-                                }
-
-                                // get next click
-                                if(clicks[c].time > curMusicPos && clicks[c].time < smallestNext) {
-                                    smallestNext = clicks[c].time;
-                                    nextTime = clicks[c].time;
-                                    nextPos = osuCoords2Pixels(o->getRawPosAt(nextTime));
-                                }
-
-                                // end hack
-                                if(!clicks[c].finished)
-                                    allFinished = false;
-                                else if(clicks[c].time > endTime)
-                                    endTime = clicks[c].time;
-                            }
-
-                            // end
-                            if(allFinished) {
-                                // hack for slider without middle clicks
-                                if(endTime == 0) endTime = o->getTime();
-
-                                prevTime = endTime;
-                                prevPos = osuCoords2Pixels(o->getRawPosAt(prevTime));
-                                nextTime = o->getTime() + o->getDuration();
-                                nextPos = osuCoords2Pixels(o->getRawPosAt(nextTime));
-                            }
-
-                            haveCurPos = false;
-                            autoDanceOverride = true;
-                            break;
-                        }
-                    }
-
-                    haveCurPos = true;
-                    curPos = prevPos;
-                    break;
-                }
-            }
-
-            // get next object
-            if(o->getTime() > curMusicPos) {
-                nextPosIndex = i;
-                if(!autoDanceOverride) {
-                    nextPos = o->getAutoCursorPos(curMusicPos);
-                    nextTime = o->getTime();
-                }
-                break;
-            }
-        }
-    } else if(m_osu->getModAutopilot()) {
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            OsuHitObject *o = m_hitobjects[i];
-
-            // get previous object
-            if(o->isFinished() ||
-               (curMusicPos > o->getTime() + o->getDuration() +
-                                  (long)(OsuGameRules::getHitWindow50(this) * osu_autopilot_lenience.getFloat()))) {
-                prevTime = o->getTime() + o->getDuration() + o->getAutopilotDelta();
-                prevPos = o->getAutoCursorPos(curMusicPos);
-            } else if(!o->isFinished())  // get next object
-            {
-                nextPosIndex = i;
-                nextPos = o->getAutoCursorPos(curMusicPos);
-                nextTime = o->getTime();
-
-                // wait for the user to click
-                if(curMusicPos >= nextTime + o->getDuration()) {
-                    haveCurPos = true;
-                    curPos = nextPos;
-
-                    // long delta = curMusicPos - (nextTime + o->getDuration());
-                    o->setAutopilotDelta(curMusicPos - (nextTime + o->getDuration()));
-                } else if(o->getDuration() > 0 && curMusicPos >= nextTime)  // handle objects with duration
-                {
-                    haveCurPos = true;
-                    curPos = nextPos;
-                    o->setAutopilotDelta(0);
-                }
-
-                break;
-            }
-        }
-    }
-
-    if(haveCurPos)  // in active hitObject
-        m_vAutoCursorPos = curPos;
-    else {
-        // interpolation
-        float percent = 1.0f;
-        if((nextTime == 0 && prevTime == 0) || (nextTime - prevTime) == 0)
-            percent = 1.0f;
-        else
-            percent = (float)((long)curMusicPos - prevTime) / (float)(nextTime - prevTime);
-
-        percent = clamp<float>(percent, 0.0f, 1.0f);
-
-        // scaled distance (not osucoords)
-        float distance = (nextPos - prevPos).length();
-        if(distance > m_fHitcircleDiameter * 1.05f)  // snap only if not in a stream (heuristic)
-        {
-            int numIterations = clamp<int>(m_osu->getModAutopilot() ? osu_autopilot_snapping_strength.getInt()
-                                                                    : osu_auto_snapping_strength.getInt(),
-                                           0, 42);
-            for(int i = 0; i < numIterations; i++) {
-                percent = (-percent) * (percent - 2.0f);
-            }
-        } else  // in a stream
-        {
-            m_iAutoCursorDanceIndex = nextPosIndex;
-        }
-
-        m_vAutoCursorPos = prevPos + (nextPos - prevPos) * percent;
-
-        if(osu_auto_cursordance.getBool() && !m_osu->getModAutopilot()) {
-            Vector3 dir = Vector3(nextPos.x, nextPos.y, 0) - Vector3(prevPos.x, prevPos.y, 0);
-            Vector3 center = dir * 0.5f;
-            Matrix4 worldMatrix;
-            worldMatrix.translate(center);
-            worldMatrix.rotate((1.0f - percent) * 180.0f * (m_iAutoCursorDanceIndex % 2 == 0 ? 1 : -1), 0, 0, 1);
-            Vector3 fancyAutoCursorPos = worldMatrix * center;
-            m_vAutoCursorPos =
-                prevPos + (nextPos - prevPos) * 0.5f + Vector2(fancyAutoCursorPos.x, fancyAutoCursorPos.y);
-        }
-    }
-}
-
-void OsuBeatmapStandard::updatePlayfieldMetrics() {
-    m_fScaleFactor = OsuGameRules::getPlayfieldScaleFactor(m_osu);
-    m_vPlayfieldSize = OsuGameRules::getPlayfieldSize(m_osu);
-    m_vPlayfieldOffset = OsuGameRules::getPlayfieldOffset(m_osu);
-    m_vPlayfieldCenter = OsuGameRules::getPlayfieldCenter(m_osu);
-}
-
-void OsuBeatmapStandard::updateHitobjectMetrics() {
-    OsuSkin *skin = m_osu->getSkin();
-
-    m_fRawHitcircleDiameter = OsuGameRules::getRawHitCircleDiameter(getCS());
-    m_fXMultiplier = OsuGameRules::getHitCircleXMultiplier(m_osu);
-    m_fHitcircleDiameter = OsuGameRules::getHitCircleDiameter(this);
-
-    const float osuCoordScaleMultiplier = (getHitcircleDiameter() / m_fRawHitcircleDiameter);
-    m_fNumberScale = (m_fRawHitcircleDiameter / (160.0f * (skin->isDefault12x() ? 2.0f : 1.0f))) *
-                     osuCoordScaleMultiplier * osu_number_scale_multiplier.getFloat();
-    m_fHitcircleOverlapScale =
-        (m_fRawHitcircleDiameter / (160.0f)) * osuCoordScaleMultiplier * osu_number_scale_multiplier.getFloat();
-
-    const float followcircle_size_multiplier = OsuGameRules::osu_slider_followcircle_size_multiplier.getFloat();
-    const float sliderFollowCircleDiameterMultiplier =
-        (m_osu->getModNightmare() || osu_mod_jigsaw2.getBool()
-             ? (1.0f * (1.0f - osu_mod_jigsaw_followcircle_radius_factor.getFloat()) +
-                osu_mod_jigsaw_followcircle_radius_factor.getFloat() * followcircle_size_multiplier)
-             : followcircle_size_multiplier);
-    m_fRawSliderFollowCircleDiameter = m_fRawHitcircleDiameter * sliderFollowCircleDiameterMultiplier;
-    m_fSliderFollowCircleDiameter = getHitcircleDiameter() * sliderFollowCircleDiameterMultiplier;
-}
-
-void OsuBeatmapStandard::updateSliderVertexBuffers() {
-    updatePlayfieldMetrics();
-    updateHitobjectMetrics();
-
-    m_bWasEZEnabled = m_osu->getModEZ();                // to avoid useless double updates in onModUpdate()
-    m_fPrevHitCircleDiameter = getHitcircleDiameter();  // same here
-    m_fPrevPlayfieldRotationFromConVar = osu_playfield_rotation.getFloat();  // same here
-    m_fPrevPlayfieldStretchX = osu_playfield_stretch_x.getFloat();           // same here
-    m_fPrevPlayfieldStretchY = osu_playfield_stretch_y.getFloat();           // same here
-
-    debugLog("OsuBeatmapStandard::updateSliderVertexBuffers() for %i hitobjects ...\n", m_hitobjects.size());
-
-    for(int i = 0; i < m_hitobjects.size(); i++) {
-        OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[i]);
-        if(sliderPointer != NULL) sliderPointer->rebuildVertexBuffer();
-    }
-}
-
-void OsuBeatmapStandard::calculateStacks() {
-    if(!osu_stacking.getBool()) return;
-
-    updateHitobjectMetrics();
-
-    debugLog("OsuBeatmapStandard: Calculating stacks ...\n");
-
-    // reset
-    for(int i = 0; i < m_hitobjects.size(); i++) {
-        m_hitobjects[i]->setStack(0);
-    }
-
-    const float STACK_LENIENCE = 3.0f;
-    const float STACK_OFFSET = 0.05f;
-
-    const float approachTime = OsuGameRules::getApproachTimeForStacking(this);
-
-    const float stackLeniency =
-        (osu_stacking_leniency_override.getFloat() >= 0.0f ? osu_stacking_leniency_override.getFloat()
-                                                           : m_selectedDifficulty2->getStackLeniency());
-
-    if(getSelectedDifficulty2()->getVersion() > 5) {
-        // peppy's algorithm
-        // https://gist.github.com/peppy/1167470
-
-        for(int i = m_hitobjects.size() - 1; i >= 0; i--) {
-            int n = i;
-
-            OsuHitObject *objectI = m_hitobjects[i];
-
-            bool isSpinner = dynamic_cast<OsuSpinner *>(objectI) != NULL;
-
-            if(objectI->getStack() != 0 || isSpinner) continue;
-
-            bool isHitCircle = dynamic_cast<OsuCircle *>(objectI) != NULL;
-            bool isSlider = dynamic_cast<OsuSlider *>(objectI) != NULL;
-
-            if(isHitCircle) {
-                while(--n >= 0) {
-                    OsuHitObject *objectN = m_hitobjects[n];
-
-                    bool isSpinnerN = dynamic_cast<OsuSpinner *>(objectN);
-
-                    if(isSpinnerN) continue;
-
-                    if(objectI->getTime() - (approachTime * stackLeniency) >
-                       (objectN->getTime() + objectN->getDuration()))
-                        break;
-
-                    Vector2 objectNEndPosition =
-                        objectN->getOriginalRawPosAt(objectN->getTime() + objectN->getDuration());
-                    if(objectN->getDuration() != 0 &&
-                       (objectNEndPosition - objectI->getOriginalRawPosAt(objectI->getTime())).length() <
-                           STACK_LENIENCE) {
-                        int offset = objectI->getStack() - objectN->getStack() + 1;
-                        for(int j = n + 1; j <= i; j++) {
-                            if((objectNEndPosition - m_hitobjects[j]->getOriginalRawPosAt(m_hitobjects[j]->getTime()))
-                                   .length() < STACK_LENIENCE)
-                                m_hitobjects[j]->setStack(m_hitobjects[j]->getStack() - offset);
-                        }
-
-                        break;
-                    }
-
-                    if((objectN->getOriginalRawPosAt(objectN->getTime()) -
-                        objectI->getOriginalRawPosAt(objectI->getTime()))
-                           .length() < STACK_LENIENCE) {
-                        objectN->setStack(objectI->getStack() + 1);
-                        objectI = objectN;
-                    }
-                }
-            } else if(isSlider) {
-                while(--n >= 0) {
-                    OsuHitObject *objectN = m_hitobjects[n];
-
-                    bool isSpinner = dynamic_cast<OsuSpinner *>(objectN) != NULL;
-
-                    if(isSpinner) continue;
-
-                    if(objectI->getTime() - (approachTime * stackLeniency) > objectN->getTime()) break;
-
-                    if(((objectN->getDuration() != 0
-                             ? objectN->getOriginalRawPosAt(objectN->getTime() + objectN->getDuration())
-                             : objectN->getOriginalRawPosAt(objectN->getTime())) -
-                        objectI->getOriginalRawPosAt(objectI->getTime()))
-                           .length() < STACK_LENIENCE) {
-                        objectN->setStack(objectI->getStack() + 1);
-                        objectI = objectN;
-                    }
-                }
-            }
-        }
-    } else  // getSelectedDifficulty()->version < 6
-    {
-        // old stacking algorithm for old beatmaps
-        // https://github.com/ppy/osu/blob/master/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs
-
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            OsuHitObject *currHitObject = m_hitobjects[i];
-            OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(currHitObject);
-
-            const bool isSlider = (sliderPointer != NULL);
-
-            if(currHitObject->getStack() != 0 && !isSlider) continue;
-
-            long startTime = currHitObject->getTime() + currHitObject->getDuration();
-            int sliderStack = 0;
-
-            for(int j = i + 1; j < m_hitobjects.size(); j++) {
-                OsuHitObject *objectJ = m_hitobjects[j];
-
-                if(objectJ->getTime() - (approachTime * stackLeniency) > startTime) break;
-
-                // "The start position of the hitobject, or the position at the end of the path if the hitobject is a
-                // slider"
-                Vector2 position2 =
-                    isSlider
-                        ? sliderPointer->getOriginalRawPosAt(sliderPointer->getTime() + sliderPointer->getDuration())
-                        : currHitObject->getOriginalRawPosAt(currHitObject->getTime());
-
-                if((objectJ->getOriginalRawPosAt(objectJ->getTime()) -
-                    currHitObject->getOriginalRawPosAt(currHitObject->getTime()))
-                       .length() < 3) {
-                    currHitObject->setStack(currHitObject->getStack() + 1);
-                    startTime = objectJ->getTime() + objectJ->getDuration();
-                } else if((objectJ->getOriginalRawPosAt(objectJ->getTime()) - position2).length() < 3) {
-                    // "Case for sliders - bump notes down and right, rather than up and left."
-                    sliderStack++;
-                    objectJ->setStack(objectJ->getStack() - sliderStack);
-                    startTime = objectJ->getTime() + objectJ->getDuration();
-                }
-            }
-        }
-    }
-
-    // update hitobject positions
-    float stackOffset = m_fRawHitcircleDiameter * STACK_OFFSET;
-    for(int i = 0; i < m_hitobjects.size(); i++) {
-        if(m_hitobjects[i]->getStack() != 0) m_hitobjects[i]->updateStackPosition(stackOffset);
-    }
-}
-
-void OsuBeatmapStandard::computeDrainRate() {
-    m_fDrainRate = 0.0;
-    m_fHpMultiplierNormal = 1.0;
-    m_fHpMultiplierComboEnd = 1.0;
-
-    if(m_hitobjects.size() < 1 || m_selectedDifficulty2 == NULL) return;
-
-    debugLog("OsuBeatmapStandard: Calculating drain ...\n");
-
-    const int drainType = m_osu_drain_type_ref->getInt();
-    if(drainType == 2)  // osu!stable
-    {
-        // see https://github.com/ppy/osu-iPhone/blob/master/Classes/OsuPlayer.m
-        // see calcHPDropRate() @ https://github.com/ppy/osu-iPhone/blob/master/Classes/OsuFiletype.m#L661
-
-        // NOTE: all drain changes between 2014 and today have been fixed here (the link points to an old version of the
-        // algorithm!) these changes include: passive spinner nerf (drain * 0.25 while spinner is active), and clamping
-        // the object length drain to 0 + an extra check for that (see maxLongObjectDrop) see
-        // https://osu.ppy.sh/home/changelog/stable40/20190513.2
-
-        struct TestPlayer {
-            TestPlayer(double hpBarMaximum) {
-                this->hpBarMaximum = hpBarMaximum;
-
-                hpMultiplierNormal = 1.0;
-                hpMultiplierComboEnd = 1.0;
-
-                resetHealth();
-            }
-
-            void resetHealth() {
-                health = hpBarMaximum;
-                healthUncapped = hpBarMaximum;
-            }
-
-            void increaseHealth(double amount) {
-                healthUncapped += amount;
-                health += amount;
-
-                if(health > hpBarMaximum) health = hpBarMaximum;
-
-                if(health < 0.0) health = 0.0;
-
-                if(healthUncapped < 0.0) healthUncapped = 0.0;
-            }
-
-            void decreaseHealth(double amount) {
-                health -= amount;
-
-                if(health < 0.0) health = 0.0;
-
-                if(health > hpBarMaximum) health = hpBarMaximum;
-
-                healthUncapped -= amount;
-
-                if(healthUncapped < 0.0) healthUncapped = 0.0;
-            }
-
-            double hpBarMaximum;
-
-            double health;
-            double healthUncapped;
-
-            double hpMultiplierNormal;
-            double hpMultiplierComboEnd;
-        };
-        double foo = (double)m_osu_drain_stable_hpbar_maximum_ref->getFloat();
-        TestPlayer testPlayer(foo);
-
-        const double HP = getHP();
-        const int version = m_selectedDifficulty2->getVersion();
-
-        double testDrop = 0.05;
-
-        const double lowestHpEver = OsuGameRules::mapDifficultyRangeDouble(HP, 195.0, 160.0, 60.0);
-        const double lowestHpComboEnd = OsuGameRules::mapDifficultyRangeDouble(HP, 198.0, 170.0, 80.0);
-        const double lowestHpEnd = OsuGameRules::mapDifficultyRangeDouble(HP, 198.0, 180.0, 80.0);
-        const double HpRecoveryAvailable = OsuGameRules::mapDifficultyRangeDouble(HP, 8.0, 4.0, 0.0);
-
-        bool fail = false;
-
-        do {
-            testPlayer.resetHealth();
-
-            double lowestHp = testPlayer.health;
-            int lastTime = (int)(m_hitobjects[0]->getTime() - (long)OsuGameRules::getApproachTime(this));
-            fail = false;
-
-            const int breakCount = m_breaks.size();
-            int breakNumber = 0;
-
-            int comboTooLowCount = 0;
-
-            for(int i = 0; i < m_hitobjects.size(); i++) {
-                const OsuHitObject *h = m_hitobjects[i];
-                const OsuSlider *sliderPointer = dynamic_cast<const OsuSlider *>(h);
-                const OsuSpinner *spinnerPointer = dynamic_cast<const OsuSpinner *>(h);
-
-                const int localLastTime = lastTime;
-
-                int breakTime = 0;
-                if(breakCount > 0 && breakNumber < breakCount) {
-                    const OsuDatabaseBeatmap::BREAK &e = m_breaks[breakNumber];
-                    if(e.startTime >= localLastTime && e.endTime <= h->getTime()) {
-                        // consider break start equal to object end time for version 8+ since drain stops during this
-                        // time
-                        breakTime = (version < 8) ? (e.endTime - e.startTime) : (e.endTime - localLastTime);
-                        breakNumber++;
-                    }
-                }
-
-                testPlayer.decreaseHealth(testDrop * (h->getTime() - lastTime - breakTime));
-
-                lastTime = (int)(h->getTime() + h->getDuration());
-
-                if(testPlayer.health < lowestHp) lowestHp = testPlayer.health;
-
-                if(testPlayer.health > lowestHpEver) {
-                    const double longObjectDrop = testDrop * (double)h->getDuration();
-                    const double maxLongObjectDrop = std::max(0.0, longObjectDrop - testPlayer.health);
-
-                    testPlayer.decreaseHealth(longObjectDrop);
-
-                    // nested hitobjects
-                    if(sliderPointer != NULL) {
-                        // startcircle
-                        testPlayer.increaseHealth(
-                            OsuScore::getHealthIncrease(OsuScore::HIT::HIT_SLIDER30, HP, testPlayer.hpMultiplierNormal,
-                                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider30
-
-                        // ticks + repeats + repeat ticks
-                        const std::vector<OsuSlider::SLIDERCLICK> &clicks = sliderPointer->getClicks();
-                        for(int c = 0; c < clicks.size(); c++) {
-                            switch(clicks[c].type) {
-                                case 0:  // repeat
-                                    testPlayer.increaseHealth(OsuScore::getHealthIncrease(
-                                        OsuScore::HIT::HIT_SLIDER30, HP, testPlayer.hpMultiplierNormal,
-                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider30
-                                    break;
-                                case 1:  // tick
-                                    testPlayer.increaseHealth(OsuScore::getHealthIncrease(
-                                        OsuScore::HIT::HIT_SLIDER10, HP, testPlayer.hpMultiplierNormal,
-                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider10
-                                    break;
-                            }
-                        }
-
-                        // endcircle
-                        testPlayer.increaseHealth(
-                            OsuScore::getHealthIncrease(OsuScore::HIT::HIT_SLIDER30, HP, testPlayer.hpMultiplierNormal,
-                                                        testPlayer.hpMultiplierComboEnd, 1.0));  // slider30
-                    } else if(spinnerPointer != NULL) {
-                        const int rotationsNeeded = (int)((float)spinnerPointer->getDuration() / 1000.0f *
-                                                          OsuGameRules::getSpinnerSpinsPerSecond(this));
-                        for(int r = 0; r < rotationsNeeded; r++) {
-                            testPlayer.increaseHealth(OsuScore::getHealthIncrease(
-                                OsuScore::HIT::HIT_SPINNERSPIN, HP, testPlayer.hpMultiplierNormal,
-                                testPlayer.hpMultiplierComboEnd, 1.0));  // spinnerspin
-                        }
-                    }
-
-                    if(!(maxLongObjectDrop > 0.0) || (testPlayer.health - maxLongObjectDrop) > lowestHpEver) {
-                        // regular hit (for every hitobject)
-                        testPlayer.increaseHealth(
-                            OsuScore::getHealthIncrease(OsuScore::HIT::HIT_300, HP, testPlayer.hpMultiplierNormal,
-                                                        testPlayer.hpMultiplierComboEnd, 1.0));  // 300
-
-                        // end of combo (new combo starts at next hitobject)
-                        if((i == m_hitobjects.size() - 1) || m_hitobjects[i]->isEndOfCombo()) {
-                            testPlayer.increaseHealth(
-                                OsuScore::getHealthIncrease(OsuScore::HIT::HIT_300G, HP, testPlayer.hpMultiplierNormal,
-                                                            testPlayer.hpMultiplierComboEnd, 1.0));  // geki
-
-                            if(testPlayer.health < lowestHpComboEnd) {
-                                if(++comboTooLowCount > 2) {
-                                    testPlayer.hpMultiplierComboEnd *= 1.07;
-                                    testPlayer.hpMultiplierNormal *= 1.03;
-                                    fail = true;
-                                    break;
-                                }
-                            }
-                        }
-
-                        continue;
-                    }
-
-                    fail = true;
-                    testDrop *= 0.96;
-                    break;
-                }
-
-                fail = true;
-                testDrop *= 0.96;
-                break;
-            }
-
-            if(!fail && testPlayer.health < lowestHpEnd) {
-                fail = true;
-                testDrop *= 0.94;
-                testPlayer.hpMultiplierComboEnd *= 1.01;
-                testPlayer.hpMultiplierNormal *= 1.01;
-            }
-
-            const double recovery = (testPlayer.healthUncapped - testPlayer.hpBarMaximum) / (double)m_hitobjects.size();
-            if(!fail && recovery < HpRecoveryAvailable) {
-                fail = true;
-                testDrop *= 0.96;
-                testPlayer.hpMultiplierComboEnd *= 1.02;
-                testPlayer.hpMultiplierNormal *= 1.01;
-            }
-        } while(fail);
-
-        m_fDrainRate =
-            (testDrop / testPlayer.hpBarMaximum) * 1000.0;  // from [0, 200] to [0, 1], and from ms to seconds
-        m_fHpMultiplierComboEnd = testPlayer.hpMultiplierComboEnd;
-        m_fHpMultiplierNormal = testPlayer.hpMultiplierNormal;
-    } else if(drainType == 3)  // osu!lazer 2020
-    {
-        // build healthIncreases
-        std::vector<std::pair<double, double>> healthIncreases;  // [first = time, second = health]
-        healthIncreases.reserve(m_hitobjects.size());
-        const double healthIncreaseForHit300 = OsuScore::getHealthIncrease(OsuScore::HIT::HIT_300);
-        for(int i = 0; i < m_hitobjects.size(); i++) {
-            // nested hitobjects
-            const OsuSlider *sliderPointer = dynamic_cast<OsuSlider *>(m_hitobjects[i]);
-            if(sliderPointer != NULL) {
-                // startcircle
-                healthIncreases.push_back(
-                    std::pair<double, double>((double)m_hitobjects[i]->getTime(), healthIncreaseForHit300));
-
-                // ticks + repeats + repeat ticks
-                const std::vector<OsuSlider::SLIDERCLICK> &clicks = sliderPointer->getClicks();
-                for(int c = 0; c < clicks.size(); c++) {
-                    healthIncreases.push_back(
-                        std::pair<double, double>((double)clicks[c].time, healthIncreaseForHit300));
-                }
-            }
-
-            // regular hitobject
-            healthIncreases.push_back(std::pair<double, double>(
-                m_hitobjects[i]->getTime() + m_hitobjects[i]->getDuration(), healthIncreaseForHit300));
-        }
-
-        const int numHealthIncreases = healthIncreases.size();
-        const int numBreaks = m_breaks.size();
-        const double drainStartTime = m_hitobjects[0]->getTime();
-
-        // see computeDrainRate() &
-        // https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs
-
-        const double minimum_health_error = 0.01;
-
-        const double min_health_target = osu_drain_lazer_health_min.getFloat();
-        const double mid_health_target = osu_drain_lazer_health_mid.getFloat();
-        const double max_health_target = osu_drain_lazer_health_max.getFloat();
-
-        const double targetMinimumHealth =
-            OsuGameRules::mapDifficultyRange(getHP(), min_health_target, mid_health_target, max_health_target);
-
-        int adjustment = 1;
-        double result = 1.0;
-
-        // Although we expect the following loop to converge within 30 iterations (health within 1/2^31 accuracy of the
-        // target), we'll still keep a safety measure to avoid infinite loops by detecting overflows.
-        while(adjustment > 0) {
-            double currentHealth = 1.0;
-            double lowestHealth = 1.0;
-            int currentBreak = -1;
-
-            for(int i = 0; i < numHealthIncreases; i++) {
-                double currentTime = healthIncreases[i].first;
-                double lastTime = i > 0 ? healthIncreases[i - 1].first : drainStartTime;
-
-                // Subtract any break time from the duration since the last object
-                if(numBreaks > 0) {
-                    // Advance the last break occuring before the current time
-                    while(currentBreak + 1 < numBreaks && (double)m_breaks[currentBreak + 1].endTime < currentTime) {
-                        currentBreak++;
-                    }
-
-                    if(currentBreak >= 0) lastTime = std::max(lastTime, (double)m_breaks[currentBreak].endTime);
-                }
-
-                // Apply health adjustments
-                currentHealth -= (healthIncreases[i].first - lastTime) * result;
-                lowestHealth = std::min(lowestHealth, currentHealth);
-                currentHealth = std::min(1.0, currentHealth + healthIncreases[i].second);
-
-                // Common scenario for when the drain rate is definitely too harsh
-                if(lowestHealth < 0) break;
-            }
-
-            // Stop if the resulting health is within a reasonable offset from the target
-            if(std::abs(lowestHealth - targetMinimumHealth) <= minimum_health_error) break;
-
-            // This effectively works like a binary search - each iteration the search space moves closer to the target,
-            // but may exceed it.
-            adjustment *= 2;
-            result += 1.0 / adjustment * sign<double>(lowestHealth - targetMinimumHealth);
-        }
-
-        m_fDrainRate = result * 1000.0;  // from ms to seconds
-    }
-}
-
-void OsuBeatmapStandard::updateStarCache() {
-    if(m_osu_draw_statistics_pp_ref->getBool() || m_osu_draw_statistics_livestars_ref->getBool()) {
-        // so we don't get a useless double load inside onModUpdate()
-        m_fPrevHitCircleDiameterForStarCache = getHitcircleDiameter();
-        m_fPrevSpeedForStarCache = m_osu->getSpeedMultiplier();
-
-        // kill any running loader, so we get to a clean state
-        stopStarCacheLoader();
-        engine->getResourceManager()->destroyResource(m_starCacheLoader);
-
-        // create new loader
-        m_starCacheLoader = new OsuBackgroundStarCacheLoader(this);
-        m_starCacheLoader->revive();  // activate it
-        engine->getResourceManager()->requestNextLoadAsync();
-        engine->getResourceManager()->loadResource(m_starCacheLoader);
-    }
-}
-
-void OsuBeatmapStandard::stopStarCacheLoader() {
-    if(!m_starCacheLoader->isDead()) {
-        m_starCacheLoader->kill();
-        double startTime = engine->getTimeReal();
-        while(!m_starCacheLoader->isAsyncReady())  // stall main thread until it's killed (this should be very quick,
-                                                   // around max 1 ms, as the kill flag is checked in every iteration)
-        {
-            if(engine->getTimeReal() - startTime > 2) {
-                debugLog("WARNING: Ignoring stuck StarCacheLoader thread!\n");
-                break;
-            }
-        }
-
-        // NOTE: this only works safely because OsuBackgroundStarCacheLoader does no work in load(), because it might
-        // still be in the ResourceManager's sync load() queue, so future loadAsync() could crash with the old pending
-        // load()
-    }
-}
-
-bool OsuBeatmapStandard::isLoadingStarCache() {
-    return ((m_osu_draw_statistics_pp_ref->getBool() || m_osu_draw_statistics_livestars_ref->getBool()) &&
-            !m_starCacheLoader->isReady());
-}
-
-bool OsuBeatmapStandard::isLoadingInt() { return (OsuBeatmap::isLoading() || m_bIsPreLoading || isLoadingStarCache()); }

+ 0 - 192
src/App/Osu/OsuBeatmapStandard.h

@@ -1,192 +0,0 @@
-//================ Copyright (c) 2017, PG, All rights reserved. =================//
-//
-// Purpose:		osu!standard circle clicking
-//
-// $NoKeywords: $osustd
-//===============================================================================//
-
-#ifndef OSUBEATMAPSTANDARD_H
-#define OSUBEATMAPSTANDARD_H
-
-#include "OsuBeatmap.h"
-
-class OsuBackgroundStarCacheLoader;
-
-class OsuBeatmapStandard : public OsuBeatmap {
-   public:
-    OsuBeatmapStandard(Osu *osu);
-    virtual ~OsuBeatmapStandard();
-
-    virtual void draw(Graphics *g);
-    virtual void drawInt(Graphics *g);
-    virtual void update();
-
-    virtual void onModUpdate() { onModUpdate(true, true); }
-    void onModUpdate(bool rebuildSliderVertexBuffers = true,
-                     bool recomputeDrainRate = true);  // this seems very dangerous compiler-wise, but it works
-    virtual bool isLoading();
-
-    Vector2 pixels2OsuCoords(Vector2 pixelCoords) const;  // only used for positional audio atm
-    Vector2 osuCoords2Pixels(
-        Vector2 coords) const;  // hitobjects should use this one (includes lots of special behaviour)
-    Vector2 osuCoords2RawPixels(
-        Vector2 coords) const;  // raw transform from osu!pixels to absolute screen pixels (without any mods whatsoever)
-    Vector3 osuCoordsTo3D(Vector2 coords, const OsuHitObject *hitObject) const;
-    Vector3 osuCoordsToRaw3D(Vector2 coords) const;  // (without any mods whatsoever)
-    Vector2 osuCoords2LegacyPixels(
-        Vector2 coords) const;  // only applies vanilla osu mods and static mods to the coordinates (used for generating
-                                // the static slider mesh) centered at (0, 0, 0)
-
-    // cursor
-    Vector2 getCursorPos() const;
-    Vector2 getFirstPersonCursorDelta() const;
-    inline Vector2 getContinueCursorPoint() const { return m_vContinueCursorPoint; }
-
-    // playfield
-    inline Vector2 getPlayfieldSize() const { return m_vPlayfieldSize; }
-    inline Vector2 getPlayfieldCenter() const { return m_vPlayfieldCenter; }
-    inline float getPlayfieldRotation() const { return m_fPlayfieldRotation; }
-
-    // hitobjects
-    float getHitcircleDiameter() const;  // in actual scaled pixels to the current resolution
-    inline float getRawHitcircleDiameter() const { return m_fRawHitcircleDiameter; }  // in osu!pixels
-    inline float getHitcircleXMultiplier() const {
-        return m_fXMultiplier;
-    }  // multiply osu!pixels with this to get screen pixels
-    inline float getNumberScale() const { return m_fNumberScale; }
-    inline float getHitcircleOverlapScale() const { return m_fHitcircleOverlapScale; }
-    inline float getSliderFollowCircleDiameter() const { return m_fSliderFollowCircleDiameter; }
-    inline float getRawSliderFollowCircleDiameter() const { return m_fRawSliderFollowCircleDiameter; }
-    inline bool isInMafhamRenderChunk() const { return m_bInMafhamRenderChunk; }
-
-    // score
-    inline int getNumHitObjects() const { return m_hitobjects.size(); }
-    inline float getAimStars() const { return m_fAimStars; }
-    inline float getAimSliderFactor() const { return m_fAimSliderFactor; }
-    inline float getSpeedStars() const { return m_fSpeedStars; }
-    inline float getSpeedNotes() const { return m_fSpeedNotes; }
-
-    // hud
-    inline bool isSpinnerActive() const { return m_bIsSpinnerActive; }
-
-    // replay recording
-    void write_frame();
-
-   private:
-    static ConVar *m_osu_draw_statistics_pp_ref;
-    static ConVar *m_osu_draw_statistics_livestars_ref;
-    static ConVar *m_osu_mod_fullalternate_ref;
-    static ConVar *m_fposu_distance_ref;
-    static ConVar *m_fposu_curved_ref;
-    static ConVar *m_fposu_mod_strafing_ref;
-    static ConVar *m_fposu_mod_strafing_frequency_x_ref;
-    static ConVar *m_fposu_mod_strafing_frequency_y_ref;
-    static ConVar *m_fposu_mod_strafing_frequency_z_ref;
-    static ConVar *m_fposu_mod_strafing_strength_x_ref;
-    static ConVar *m_fposu_mod_strafing_strength_y_ref;
-    static ConVar *m_fposu_mod_strafing_strength_z_ref;
-    static ConVar *m_fposu_mod_3d_depthwobble_ref;
-    static ConVar *m_osu_slider_scorev2_ref;
-
-    static inline Vector2 mapNormalizedCoordsOntoUnitCircle(const Vector2 &in) {
-        return Vector2(in.x * std::sqrt(1.0f - in.y * in.y / 2.0f), in.y * std::sqrt(1.0f - in.x * in.x / 2.0f));
-    }
-
-    static float quadLerp3f(float left, float center, float right, float percent) {
-        if(percent >= 0.5f) {
-            percent = (percent - 0.5f) / 0.5f;
-            percent *= percent;
-            return lerp<float>(center, right, percent);
-        } else {
-            percent = percent / 0.5f;
-            percent = 1.0f - (1.0f - percent) * (1.0f - percent);
-            return lerp<float>(left, center, percent);
-        }
-    }
-
-    virtual void onBeforeLoad();
-    virtual void onLoad();
-    virtual void onPlayStart();
-    virtual void onBeforeStop(bool quit);
-    virtual void onPaused(bool first);
-
-    void drawFollowPoints(Graphics *g);
-    void drawHitObjects(Graphics *g);
-
-    void updateAutoCursorPos();
-    void updatePlayfieldMetrics();
-    void updateHitobjectMetrics();
-    void updateSliderVertexBuffers();
-
-    void calculateStacks();
-    void computeDrainRate();
-
-    void updateStarCache();
-    void stopStarCacheLoader();
-    bool isLoadingStarCache();
-    bool isLoadingInt();
-
-    // beatmap
-    bool m_bIsSpinnerActive;
-    Vector2 m_vContinueCursorPoint;
-
-    // playfield
-    float m_fPlayfieldRotation;
-    float m_fScaleFactor;
-    Vector2 m_vPlayfieldCenter;
-    Vector2 m_vPlayfieldOffset;
-    Vector2 m_vPlayfieldSize;
-
-    // hitobject scaling
-    float m_fXMultiplier;
-    float m_fRawHitcircleDiameter;
-    float m_fHitcircleDiameter;
-    float m_fNumberScale;
-    float m_fHitcircleOverlapScale;
-    float m_fSliderFollowCircleDiameter;
-    float m_fRawSliderFollowCircleDiameter;
-
-    // auto
-    Vector2 m_vAutoCursorPos;
-    int m_iAutoCursorDanceIndex;
-
-    // pp calculation buffer (only needs to be recalculated in onModUpdate(), instead of on every hit)
-    float m_fAimStars;
-    float m_fAimSliderFactor;
-    float m_fSpeedStars;
-    float m_fSpeedNotes;
-    OsuBackgroundStarCacheLoader *m_starCacheLoader;
-    float m_fStarCacheTime;
-
-    // dynamic slider vertex buffer and other recalculation checks (for live mod switching)
-    float m_fPrevHitCircleDiameter;
-    bool m_bWasHorizontalMirrorEnabled;
-    bool m_bWasVerticalMirrorEnabled;
-    bool m_bWasEZEnabled;
-    bool m_bWasMafhamEnabled;
-    float m_fPrevPlayfieldRotationFromConVar;
-    float m_fPrevPlayfieldStretchX;
-    float m_fPrevPlayfieldStretchY;
-    float m_fPrevHitCircleDiameterForStarCache;
-    float m_fPrevSpeedForStarCache;
-
-    // custom
-    bool m_bIsPreLoading;
-    int m_iPreLoadingIndex;
-    bool m_bWasHREnabled;  // dynamic stack recalculation
-
-    RenderTarget *m_mafhamActiveRenderTarget;
-    RenderTarget *m_mafhamFinishedRenderTarget;
-    bool m_bMafhamRenderScheduled;
-    int m_iMafhamHitObjectRenderIndex;  // scene buffering for rendering entire beatmaps at once with an acceptable
-                                        // framerate
-    int m_iMafhamPrevHitObjectIndex;
-    int m_iMafhamActiveRenderHitObjectIndex;
-    int m_iMafhamFinishedRenderHitObjectIndex;
-    bool m_bInMafhamRenderChunk;  // used by OsuSlider to not animate the reverse arrow, and by OsuCircle to not animate
-                                  // note blocking shaking, while being rendered into the scene buffer
-
-    int m_iMandalaIndex;
-};
-
-#endif

+ 12 - 12
src/App/Osu/OsuCircle.cpp

@@ -16,7 +16,7 @@
 #include "OpenGLHeaders.h"
 #include "OpenGLLegacyInterface.h"
 #include "Osu.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuGameRules.h"
 #include "OsuModFPoSu.h"
 #include "OsuSkin.h"
@@ -42,9 +42,9 @@ ConVar osu_slider_draw_endcircle("osu_slider_draw_endcircle", true, FCVAR_NONE);
 int OsuCircle::rainbowNumber = 0;
 int OsuCircle::rainbowColorCounter = 0;
 
-void OsuCircle::drawApproachCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number,
-                                   int colorCounter, int colorOffset, float colorRGBMultiplier, float approachScale,
-                                   float alpha, bool overrideHDApproachCircle) {
+void OsuCircle::drawApproachCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
+                                   int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
+                                   bool overrideHDApproachCircle) {
     rainbowNumber = number;
     rainbowColorCounter = colorCounter;
 
@@ -59,7 +59,7 @@ void OsuCircle::drawApproachCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vec
                        overrideHDApproachCircle);
 }
 
-void OsuCircle::drawCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number, int colorCounter,
+void OsuCircle::drawCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
                            int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
                            float numberAlpha, bool drawNumber, bool overrideHDApproachCircle) {
     drawCircle(g, beatmap->getSkin(), beatmap->osuCoords2Pixels(rawPos), beatmap->getHitcircleDiameter(),
@@ -116,9 +116,9 @@ void OsuCircle::drawCircle(Graphics *g, OsuSkin *skin, Vector2 pos, float hitcir
     drawHitCircleOverlay(g, skin->getHitCircleOverlay2(), pos, circleOverlayImageScale, alpha, 1.0f);
 }
 
-void OsuCircle::drawSliderStartCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number,
-                                      int colorCounter, int colorOffset, float colorRGBMultiplier, float approachScale,
-                                      float alpha, float numberAlpha, bool drawNumber, bool overrideHDApproachCircle) {
+void OsuCircle::drawSliderStartCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
+                                      int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
+                                      float numberAlpha, bool drawNumber, bool overrideHDApproachCircle) {
     drawSliderStartCircle(g, beatmap->getSkin(), beatmap->osuCoords2Pixels(rawPos), beatmap->getHitcircleDiameter(),
                           beatmap->getNumberScale(), beatmap->getHitcircleOverlapScale(), number, colorCounter,
                           colorOffset, colorRGBMultiplier, approachScale, alpha, numberAlpha, drawNumber,
@@ -176,9 +176,9 @@ void OsuCircle::drawSliderStartCircle(Graphics *g, OsuSkin *skin, Vector2 pos, f
     }
 }
 
-void OsuCircle::drawSliderEndCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number,
-                                    int colorCounter, int colorOffset, float colorRGBMultiplier, float approachScale,
-                                    float alpha, float numberAlpha, bool drawNumber, bool overrideHDApproachCircle) {
+void OsuCircle::drawSliderEndCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
+                                    int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
+                                    float numberAlpha, bool drawNumber, bool overrideHDApproachCircle) {
     drawSliderEndCircle(g, beatmap->getSkin(), beatmap->osuCoords2Pixels(rawPos), beatmap->getHitcircleDiameter(),
                         beatmap->getNumberScale(), beatmap->getHitcircleOverlapScale(), number, colorCounter,
                         colorOffset, colorRGBMultiplier, approachScale, alpha, numberAlpha, drawNumber,
@@ -417,7 +417,7 @@ void OsuCircle::drawHitCircleNumber(Graphics *g, OsuSkin *skin, float numberScal
 }
 
 OsuCircle::OsuCircle(int x, int y, long time, int sampleType, int comboNumber, bool isEndOfCombo, int colorCounter,
-                     int colorOffset, OsuBeatmapStandard *beatmap)
+                     int colorOffset, OsuBeatmap *beatmap)
     : OsuHitObject(time, sampleType, comboNumber, isEndOfCombo, colorCounter, colorOffset, beatmap) {
     m_vOriginalRawPos = Vector2(x, y);
     m_vRawPos = m_vOriginalRawPos;

+ 12 - 14
src/App/Osu/OsuCircle.h

@@ -16,10 +16,10 @@ class OsuSkinImage;
 class OsuCircle : public OsuHitObject {
    public:
     // main
-    static void drawApproachCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number,
-                                   int colorCounter, int colorOffset, float colorRGBMultiplier, float approachScale,
-                                   float alpha, bool overrideHDApproachCircle = false);
-    static void drawCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number, int colorCounter,
+    static void drawApproachCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
+                                   int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
+                                   bool overrideHDApproachCircle = false);
+    static void drawCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
                            int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
                            float numberAlpha, bool drawNumber = true, bool overrideHDApproachCircle = false);
     static void drawCircle(Graphics *g, OsuSkin *skin, Vector2 pos, float hitcircleDiameter, float numberScale,
@@ -28,19 +28,17 @@ class OsuCircle : public OsuHitObject {
                            bool overrideHDApproachCircle = false);
     static void drawCircle(Graphics *g, OsuSkin *skin, Vector2 pos, float hitcircleDiameter, Color color,
                            float alpha = 1.0f);
-    static void drawSliderStartCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number,
-                                      int colorCounter, int colorOffset, float colorRGBMultiplier, float approachScale,
-                                      float alpha, float numberAlpha, bool drawNumber = true,
-                                      bool overrideHDApproachCircle = false);
+    static void drawSliderStartCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
+                                      int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
+                                      float numberAlpha, bool drawNumber = true, bool overrideHDApproachCircle = false);
     static void drawSliderStartCircle(Graphics *g, OsuSkin *skin, Vector2 pos, float hitcircleDiameter,
                                       float numberScale, float hitcircleOverlapScale, int number, int colorCounter = 0,
                                       int colorOffset = 0, float colorRGBMultiplier = 1.0f, float approachScale = 1.0f,
                                       float alpha = 1.0f, float numberAlpha = 1.0f, bool drawNumber = true,
                                       bool overrideHDApproachCircle = false);
-    static void drawSliderEndCircle(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, int number,
-                                    int colorCounter, int colorOffset, float colorRGBMultiplier, float approachScale,
-                                    float alpha, float numberAlpha, bool drawNumber = true,
-                                    bool overrideHDApproachCircle = false);
+    static void drawSliderEndCircle(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, int number, int colorCounter,
+                                    int colorOffset, float colorRGBMultiplier, float approachScale, float alpha,
+                                    float numberAlpha, bool drawNumber = true, bool overrideHDApproachCircle = false);
     static void drawSliderEndCircle(Graphics *g, OsuSkin *skin, Vector2 pos, float hitcircleDiameter, float numberScale,
                                     float overlapScale, int number = 0, int colorCounter = 0, int colorOffset = 0,
                                     float colorRGBMultiplier = 1.0f, float approachScale = 1.0f, float alpha = 1.0f,
@@ -59,7 +57,7 @@ class OsuCircle : public OsuHitObject {
 
    public:
     OsuCircle(int x, int y, long time, int sampleType, int comboNumber, bool isEndOfCombo, int colorCounter,
-              int colorOffset, OsuBeatmapStandard *beatmap);
+              int colorOffset, OsuBeatmap *beatmap);
     virtual ~OsuCircle();
 
     virtual void draw(Graphics *g);
@@ -85,7 +83,7 @@ class OsuCircle : public OsuHitObject {
 
     void onHit(OsuScore::HIT result, long delta, float targetDelta = 0.0f, float targetAngle = 0.0f);
 
-    OsuBeatmapStandard *m_beatmap;
+    OsuBeatmap *m_beatmap;
 
     Vector2 m_vRawPos;
     Vector2 m_vOriginalRawPos;  // for live mod changing

+ 1 - 3
src/App/Osu/OsuDatabase.cpp

@@ -1375,9 +1375,7 @@ void OsuDatabase::loadDB(Packet *db, bool &fallbackToRawLoad) {
             continue;
 
         // fill diff with data
-        if((mode == 0 && m_osu->getGamemode() == Osu::GAMEMODE::STD) ||
-           (mode == 0x03 && m_osu->getGamemode() == Osu::GAMEMODE::MANIA))  // gamemode filter
-        {
+        if(mode == 0) {
             OsuDatabaseBeatmap *diff2 = new OsuDatabaseBeatmap(m_osu, fullFilePath, beatmapPath);
             {
                 diff2->m_sTitle = songTitle;

+ 116 - 208
src/App/Osu/OsuDatabaseBeatmap.cpp

@@ -16,7 +16,6 @@
 #include "File.h"
 #include "Osu.h"
 #include "OsuBeatmap.h"
-#include "OsuBeatmapStandard.h"
 #include "OsuCircle.h"
 #include "OsuGameRules.h"
 #include "OsuHitObject.h"
@@ -155,15 +154,13 @@ OsuDatabaseBeatmap::~OsuDatabaseBeatmap() {
 }
 
 OsuDatabaseBeatmap::PRIMITIVE_CONTAINER OsuDatabaseBeatmap::loadPrimitiveObjects(const std::string &osuFilePath,
-                                                                                 Osu::GAMEMODE gameMode,
                                                                                  bool filePathIsInMemoryBeatmap) {
     std::atomic<bool> dead;
     dead = false;
-    return loadPrimitiveObjects(osuFilePath, gameMode, filePathIsInMemoryBeatmap, dead);
+    return loadPrimitiveObjects(osuFilePath, filePathIsInMemoryBeatmap, dead);
 }
 
 OsuDatabaseBeatmap::PRIMITIVE_CONTAINER OsuDatabaseBeatmap::loadPrimitiveObjects(const std::string &osuFilePath,
-                                                                                 Osu::GAMEMODE gameMode,
                                                                                  bool filePathIsInMemoryBeatmap,
                                                                                  const std::atomic<bool> &dead) {
     PRIMITIVE_CONTAINER c;
@@ -382,7 +379,6 @@ OsuDatabaseBeatmap::PRIMITIVE_CONTAINER OsuDatabaseBeatmap::loadPrimitiveObjects
                                     h.colorCounter = colorCounter;
                                     h.colorOffset = colorOffset;
                                     h.clicked = false;
-                                    h.maniaEndTime = 0;
                                 }
                                 c.hitcircles.push_back(h);
                             } else if(type & 0x2)  // slider
@@ -496,38 +492,6 @@ OsuDatabaseBeatmap::PRIMITIVE_CONTAINER OsuDatabaseBeatmap::loadPrimitiveObjects
                                     s.endTime = tokens[5].toFloat();
                                 }
                                 c.spinners.push_back(s);
-                            } else if(gameMode == Osu::GAMEMODE::MANIA &&
-                                      (type & 0x80))  // osu!mania hold note, gamemode check for sanity
-                            {
-                                UString curLineString = UString(curLineChar);
-                                std::vector<UString> tokens = curLineString.split(",");
-
-                                if(tokens.size() < 6) {
-                                    debugLog("Invalid hold note in beatmap: %s\n\ncurLine = %s\n", osuFilePath.c_str(),
-                                             curLineChar);
-                                    continue;
-                                }
-
-                                std::vector<UString> holdNoteTokens = tokens[5].split(":");
-                                if(holdNoteTokens.size() < 1) {
-                                    debugLog("Invalid hold note in beatmap: %s\n\ncurLine = %s\n", osuFilePath.c_str(),
-                                             curLineChar);
-                                    continue;
-                                }
-
-                                HITCIRCLE h;
-                                {
-                                    h.x = x;
-                                    h.y = y;
-                                    h.time = time;
-                                    h.sampleType = hitSound;
-                                    h.number = comboNumber++;
-                                    h.colorCounter = colorCounter;
-                                    h.colorOffset = colorOffset;
-                                    h.clicked = false;
-                                    h.maniaEndTime = holdNoteTokens[0].toLong();
-                                }
-                                c.hitcircles.push_back(h);
                             }
                         }
                         break;
@@ -710,17 +674,17 @@ OsuDatabaseBeatmap::CALCULATE_SLIDER_TIMES_CLICKS_TICKS_RESULT OsuDatabaseBeatma
 }
 
 OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT OsuDatabaseBeatmap::loadDifficultyHitObjects(const std::string &osuFilePath,
-                                                                                     Osu::GAMEMODE gameMode, float AR,
-                                                                                     float CS, float speedMultiplier,
+                                                                                     float AR, float CS,
+                                                                                     float speedMultiplier,
                                                                                      bool calculateStarsInaccurately) {
     std::atomic<bool> dead;
     dead = false;
-    return loadDifficultyHitObjects(osuFilePath, gameMode, AR, CS, speedMultiplier, calculateStarsInaccurately, dead);
+    return loadDifficultyHitObjects(osuFilePath, AR, CS, speedMultiplier, calculateStarsInaccurately, dead);
 }
 
 OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT OsuDatabaseBeatmap::loadDifficultyHitObjects(const std::string &osuFilePath,
-                                                                                     Osu::GAMEMODE gameMode, float AR,
-                                                                                     float CS, float speedMultiplier,
+                                                                                     float AR, float CS,
+                                                                                     float speedMultiplier,
                                                                                      bool calculateStarsInaccurately,
                                                                                      const std::atomic<bool> &dead) {
     LOAD_DIFFOBJ_RESULT result = LOAD_DIFFOBJ_RESULT();
@@ -730,7 +694,7 @@ OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT OsuDatabaseBeatmap::loadDifficultyHitObj
     // type for simplicity
 
     // load primitive arrays
-    PRIMITIVE_CONTAINER c = loadPrimitiveObjects(osuFilePath, gameMode, false, dead);
+    PRIMITIVE_CONTAINER c = loadPrimitiveObjects(osuFilePath, false, dead);
     if(c.errorCode != 0) {
         result.errorCode = c.errorCode;
         return result;
@@ -813,7 +777,7 @@ OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT OsuDatabaseBeatmap::loadDifficultyHitObj
     std::sort(result.diffobjects.begin(), result.diffobjects.end(), DiffHitObjectSortComparator());
 
     // calculate stacks
-    // see OsuBeatmapStandard.cpp
+    // see OsuBeatmap.cpp
     // NOTE: this must be done before the speed multiplier is applied!
     // HACKHACK: code duplication ffs
     if(m_osu_stars_stacking_ref->getBool() &&
@@ -1200,9 +1164,7 @@ bool OsuDatabaseBeatmap::loadMetadata(OsuDatabaseBeatmap *databaseBeatmap) {
     }
 
     // gamemode filter
-    if((databaseBeatmap->m_iGameMode != 0 && databaseBeatmap->m_osu->getGamemode() == Osu::GAMEMODE::STD) ||
-       (databaseBeatmap->m_iGameMode != 0x03 && databaseBeatmap->m_osu->getGamemode() == Osu::GAMEMODE::MANIA))
-        return false;  // nothing more to do here
+    if(databaseBeatmap->m_iGameMode != 0) return false;  // nothing more to do here
 
     // general sanity checks
     if((databaseBeatmap->m_timingpoints.size() < 1)) {
@@ -1359,8 +1321,8 @@ OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT OsuDatabaseBeatmap::loadGameplay(OsuDat
     }
 
     // load primitives, put in temporary container
-    PRIMITIVE_CONTAINER c = loadPrimitiveObjects(databaseBeatmap->m_sFilePath, databaseBeatmap->m_osu->getGamemode(),
-                                                 databaseBeatmap->m_bFilePathIsInMemoryBeatmap);
+    PRIMITIVE_CONTAINER c =
+        loadPrimitiveObjects(databaseBeatmap->m_sFilePath, databaseBeatmap->m_bFilePathIsInMemoryBeatmap);
     if(c.errorCode != 0) {
         result.errorCode = c.errorCode;
         return result;
@@ -1404,7 +1366,6 @@ OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT OsuDatabaseBeatmap::loadGameplay(OsuDat
     }
 
     // build hitobjects from the primitive data we loaded from the osu file
-    OsuBeatmapStandard *beatmapStandard = dynamic_cast<OsuBeatmapStandard *>(beatmap);
     {
         struct Helper {
             static inline uint32_t pcgHash(uint32_t input) {
@@ -1416,167 +1377,115 @@ OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT OsuDatabaseBeatmap::loadGameplay(OsuDat
 
         result.randomSeed = (osu_mod_random_seed.getInt() == 0 ? rand() : osu_mod_random_seed.getInt());
 
-        if(beatmapStandard != NULL) {
-            // also calculate max possible combo
-            int maxPossibleCombo = 0;
-
-            for(size_t i = 0; i < c.hitcircles.size(); i++) {
-                HITCIRCLE &h = c.hitcircles[i];
-
-                if(osu_mod_random.getBool()) {
-                    h.x = clamp<int>(
-                        h.x -
-                            (int)(((Helper::pcgHash(result.randomSeed + h.x) % OsuGameRules::OSU_COORD_WIDTH) / 8.0f) *
-                                  osu_mod_random_circle_offset_x_percent.getFloat()),
-                        0, OsuGameRules::OSU_COORD_WIDTH);
-                    h.y = clamp<int>(
-                        h.y -
-                            (int)(((Helper::pcgHash(result.randomSeed + h.y) % OsuGameRules::OSU_COORD_HEIGHT) / 8.0f) *
-                                  osu_mod_random_circle_offset_y_percent.getFloat()),
-                        0, OsuGameRules::OSU_COORD_HEIGHT);
-                }
-
-                result.hitobjects.push_back(new OsuCircle(h.x, h.y, h.time, h.sampleType, h.number, false,
-                                                          h.colorCounter, h.colorOffset, beatmapStandard));
-
-                // potential convert-all-circles-to-sliders mod, have to play around more with this
-                /*
-                if (i+1 < c.hitcircles.size())
-                {
-                        std::vector<Vector2> points;
-                        Vector2 p1 = Vector2(c.hitcircles[i].x, c.hitcircles[i].y);
-                        Vector2 p2 = Vector2(c.hitcircles[i+1].x, c.hitcircles[i+1].y);
-                        points.push_back(p1);
-                        points.push_back(p2 - (p2 - p1).normalize()*35);
-                        const float pixelLength = (p2 - p1).length();
-                        const unsigned long time = c.hitcircles[i].time;
-                        const unsigned long timeEnd = c.hitcircles[i+1].time;
-                        const unsigned long sliderTime = timeEnd - time;
-
-                        bool blocked = false;
-                        for (int s=0; s<c.sliders.size(); s++)
-                        {
-                                if (c.sliders[s].time > time && c.sliders[s].time < timeEnd)
-                                {
-                                        blocked = true;
-                                        break;
-                                }
-                        }
-                        for (int s=0; s<c.spinners.size(); s++)
-                        {
-                                if (c.spinners[s].time > time && c.spinners[s].time < timeEnd)
-                                {
-                                        blocked = true;
-                                        break;
-                                }
-                        }
-
-                        blocked |= pixelLength < 45;
+        // also calculate max possible combo
+        int maxPossibleCombo = 0;
+
+        for(size_t i = 0; i < c.hitcircles.size(); i++) {
+            HITCIRCLE &h = c.hitcircles[i];
+
+            if(osu_mod_random.getBool()) {
+                h.x = clamp<int>(
+                    h.x - (int)(((Helper::pcgHash(result.randomSeed + h.x) % OsuGameRules::OSU_COORD_WIDTH) / 8.0f) *
+                                osu_mod_random_circle_offset_x_percent.getFloat()),
+                    0, OsuGameRules::OSU_COORD_WIDTH);
+                h.y = clamp<int>(
+                    h.y - (int)(((Helper::pcgHash(result.randomSeed + h.y) % OsuGameRules::OSU_COORD_HEIGHT) / 8.0f) *
+                                osu_mod_random_circle_offset_y_percent.getFloat()),
+                    0, OsuGameRules::OSU_COORD_HEIGHT);
+            }
 
-                        if (!blocked)
-                                m_loadHitObjects.push_back(new
-                OsuSlider(OsuSliderCurve::OSUSLIDERCURVETYPE::OSUSLIDERCURVETYPE_LINEAR, 1, pixelLength, points,
-                std::vector<int>(), std::vector<float>(), sliderTime, sliderTime, time, h.sampleType, h.number, false,
-                h.colorCounter, h.colorOffset, beatmapStandard)); else m_loadHitObjects.push_back(new OsuCircle(h.x,
-                h.y, h.time, h.sampleType, h.number, false, h.colorCounter, h.colorOffset, beatmapStandard));
+            result.hitobjects.push_back(
+                new OsuCircle(h.x, h.y, h.time, h.sampleType, h.number, false, h.colorCounter, h.colorOffset, beatmap));
+        }
+        maxPossibleCombo += c.hitcircles.size();
+
+        for(size_t i = 0; i < c.sliders.size(); i++) {
+            SLIDER &s = c.sliders[i];
+
+            if(osu_mod_strict_tracking.getBool() && osu_mod_strict_tracking_remove_slider_ticks.getBool())
+                s.ticks.clear();
+
+            if(osu_mod_random.getBool()) {
+                for(int p = 0; p < s.points.size(); p++) {
+                    s.points[p].x =
+                        clamp<int>(s.points[p].x - (int)(((Helper::pcgHash(result.randomSeed + s.points[p].x) %
+                                                           OsuGameRules::OSU_COORD_WIDTH) /
+                                                          3.0f) *
+                                                         osu_mod_random_slider_offset_x_percent.getFloat()),
+                                   0, OsuGameRules::OSU_COORD_WIDTH);
+                    s.points[p].y =
+                        clamp<int>(s.points[p].y - (int)(((Helper::pcgHash(result.randomSeed + s.points[p].y) %
+                                                           OsuGameRules::OSU_COORD_HEIGHT) /
+                                                          3.0f) *
+                                                         osu_mod_random_slider_offset_y_percent.getFloat()),
+                                   0, OsuGameRules::OSU_COORD_HEIGHT);
                 }
-                */
             }
-            maxPossibleCombo += c.hitcircles.size();
-
-            for(size_t i = 0; i < c.sliders.size(); i++) {
-                SLIDER &s = c.sliders[i];
-
-                if(osu_mod_strict_tracking.getBool() && osu_mod_strict_tracking_remove_slider_ticks.getBool())
-                    s.ticks.clear();
-
-                if(osu_mod_random.getBool()) {
-                    for(int p = 0; p < s.points.size(); p++) {
-                        s.points[p].x =
-                            clamp<int>(s.points[p].x - (int)(((Helper::pcgHash(result.randomSeed + s.points[p].x) %
-                                                               OsuGameRules::OSU_COORD_WIDTH) /
-                                                              3.0f) *
-                                                             osu_mod_random_slider_offset_x_percent.getFloat()),
-                                       0, OsuGameRules::OSU_COORD_WIDTH);
-                        s.points[p].y =
-                            clamp<int>(s.points[p].y - (int)(((Helper::pcgHash(result.randomSeed + s.points[p].y) %
-                                                               OsuGameRules::OSU_COORD_HEIGHT) /
-                                                              3.0f) *
-                                                             osu_mod_random_slider_offset_y_percent.getFloat()),
-                                       0, OsuGameRules::OSU_COORD_HEIGHT);
-                    }
-                }
-
-                if(osu_mod_reverse_sliders.getBool()) std::reverse(s.points.begin(), s.points.end());
 
-                result.hitobjects.push_back(new OsuSlider(s.type, s.repeat, s.pixelLength, s.points, s.hitSounds,
-                                                          s.ticks, s.sliderTime, s.sliderTimeWithoutRepeats, s.time,
-                                                          s.sampleType, s.number, false, s.colorCounter, s.colorOffset,
-                                                          beatmapStandard));
+            if(osu_mod_reverse_sliders.getBool()) std::reverse(s.points.begin(), s.points.end());
 
-                const int repeats = std::max((s.repeat - 1), 0);
-                maxPossibleCombo += 2 + repeats + (repeats + 1) * s.ticks.size();  // start/end + repeat arrow + ticks
-            }
+            result.hitobjects.push_back(new OsuSlider(s.type, s.repeat, s.pixelLength, s.points, s.hitSounds, s.ticks,
+                                                      s.sliderTime, s.sliderTimeWithoutRepeats, s.time, s.sampleType,
+                                                      s.number, false, s.colorCounter, s.colorOffset, beatmap));
 
-            for(size_t i = 0; i < c.spinners.size(); i++) {
-                SPINNER &s = c.spinners[i];
-
-                if(osu_mod_random.getBool()) {
-                    s.x = clamp<int>(
-                        s.x -
-                            (int)(((Helper::pcgHash(result.randomSeed + s.x) % OsuGameRules::OSU_COORD_WIDTH) / 1.25f) *
-                                  (Helper::pcgHash(result.randomSeed + s.x) % 2 == 0 ? 1.0f : -1.0f) *
-                                  osu_mod_random_spinner_offset_x_percent.getFloat()),
-                        0, OsuGameRules::OSU_COORD_WIDTH);
-                    s.y = clamp<int>(
-                        s.y - (int)(((Helper::pcgHash(result.randomSeed + s.y) % OsuGameRules::OSU_COORD_HEIGHT) /
-                                     1.25f) *
-                                    (Helper::pcgHash(result.randomSeed + s.y) % 2 == 0 ? 1.0f : -1.0f) *
-                                    osu_mod_random_spinner_offset_y_percent.getFloat()),
-                        0, OsuGameRules::OSU_COORD_HEIGHT);
-                }
+            const int repeats = std::max((s.repeat - 1), 0);
+            maxPossibleCombo += 2 + repeats + (repeats + 1) * s.ticks.size();  // start/end + repeat arrow + ticks
+        }
 
-                result.hitobjects.push_back(
-                    new OsuSpinner(s.x, s.y, s.time, s.sampleType, false, s.endTime, beatmapStandard));
-            }
-            maxPossibleCombo += c.spinners.size();
-
-            beatmapStandard->setMaxPossibleCombo(maxPossibleCombo);
-
-            // debug
-            if(m_osu_debug_pp_ref->getBool()) {
-                const std::string &osuFilePath = databaseBeatmap->m_sFilePath;
-                const Osu::GAMEMODE gameMode = Osu::GAMEMODE::STD;
-                const float AR = beatmap->getAR();
-                const float CS = beatmap->getCS();
-                const float OD = beatmap->getOD();
-                const float speedMultiplier =
-                    databaseBeatmap->m_osu->getSpeedMultiplier();  // NOTE: not this->getSpeedMultiplier()!
-                const bool relax = databaseBeatmap->m_osu->getModRelax();
-                const bool touchDevice = databaseBeatmap->m_osu->getModTD();
-
-                LOAD_DIFFOBJ_RESULT diffres =
-                    OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, gameMode, AR, CS, speedMultiplier);
-
-                double aim = 0.0;
-                double aimSliderFactor = 0.0;
-                double speed = 0.0;
-                double speedNotes = 0.0;
-                double stars = OsuDifficultyCalculator::calculateStarDiffForHitObjects(
-                    diffres.diffobjects, CS, OD, speedMultiplier, relax, touchDevice, &aim, &aimSliderFactor, &speed,
-                    &speedNotes);
-                double pp = OsuDifficultyCalculator::calculatePPv2(
-                    beatmap->getOsu(), beatmap, aim, aimSliderFactor, speed, speedNotes, databaseBeatmap->m_iNumObjects,
-                    databaseBeatmap->m_iNumCircles, databaseBeatmap->m_iNumSliders, databaseBeatmap->m_iNumSpinners,
-                    maxPossibleCombo);
-
-                engine->showMessageInfo(
-                    "PP", UString::format("pp = %f, stars = %f, aimstars = %f, speedstars = %f, %i circles, %i "
-                                          "sliders, %i spinners, %i hitobjects, maxcombo = %i",
-                                          pp, stars, aim, speed, databaseBeatmap->m_iNumCircles,
-                                          databaseBeatmap->m_iNumSliders, databaseBeatmap->m_iNumSpinners,
-                                          databaseBeatmap->m_iNumObjects, maxPossibleCombo));
+        for(size_t i = 0; i < c.spinners.size(); i++) {
+            SPINNER &s = c.spinners[i];
+
+            if(osu_mod_random.getBool()) {
+                s.x = clamp<int>(
+                    s.x - (int)(((Helper::pcgHash(result.randomSeed + s.x) % OsuGameRules::OSU_COORD_WIDTH) / 1.25f) *
+                                (Helper::pcgHash(result.randomSeed + s.x) % 2 == 0 ? 1.0f : -1.0f) *
+                                osu_mod_random_spinner_offset_x_percent.getFloat()),
+                    0, OsuGameRules::OSU_COORD_WIDTH);
+                s.y = clamp<int>(
+                    s.y - (int)(((Helper::pcgHash(result.randomSeed + s.y) % OsuGameRules::OSU_COORD_HEIGHT) / 1.25f) *
+                                (Helper::pcgHash(result.randomSeed + s.y) % 2 == 0 ? 1.0f : -1.0f) *
+                                osu_mod_random_spinner_offset_y_percent.getFloat()),
+                    0, OsuGameRules::OSU_COORD_HEIGHT);
             }
+
+            result.hitobjects.push_back(new OsuSpinner(s.x, s.y, s.time, s.sampleType, false, s.endTime, beatmap));
+        }
+        maxPossibleCombo += c.spinners.size();
+
+        beatmap->setMaxPossibleCombo(maxPossibleCombo);
+
+        // debug
+        if(m_osu_debug_pp_ref->getBool()) {
+            const std::string &osuFilePath = databaseBeatmap->m_sFilePath;
+            const float AR = beatmap->getAR();
+            const float CS = beatmap->getCS();
+            const float OD = beatmap->getOD();
+            const float speedMultiplier =
+                databaseBeatmap->m_osu->getSpeedMultiplier();  // NOTE: not this->getSpeedMultiplier()!
+            const bool relax = databaseBeatmap->m_osu->getModRelax();
+            const bool touchDevice = databaseBeatmap->m_osu->getModTD();
+
+            LOAD_DIFFOBJ_RESULT diffres =
+                OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, AR, CS, speedMultiplier);
+
+            double aim = 0.0;
+            double aimSliderFactor = 0.0;
+            double speed = 0.0;
+            double speedNotes = 0.0;
+            double stars = OsuDifficultyCalculator::calculateStarDiffForHitObjects(
+                diffres.diffobjects, CS, OD, speedMultiplier, relax, touchDevice, &aim, &aimSliderFactor, &speed,
+                &speedNotes);
+            double pp = OsuDifficultyCalculator::calculatePPv2(
+                beatmap->getOsu(), beatmap, aim, aimSliderFactor, speed, speedNotes, databaseBeatmap->m_iNumObjects,
+                databaseBeatmap->m_iNumCircles, databaseBeatmap->m_iNumSliders, databaseBeatmap->m_iNumSpinners,
+                maxPossibleCombo);
+
+            engine->showMessageInfo(
+                "PP",
+                UString::format("pp = %f, stars = %f, aimstars = %f, speedstars = %f, %i circles, %i "
+                                "sliders, %i spinners, %i hitobjects, maxcombo = %i",
+                                pp, stars, aim, speed, databaseBeatmap->m_iNumCircles, databaseBeatmap->m_iNumSliders,
+                                databaseBeatmap->m_iNumSpinners, databaseBeatmap->m_iNumObjects, maxPossibleCombo));
         }
     }
 
@@ -1598,7 +1507,7 @@ OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT OsuDatabaseBeatmap::loadGameplay(OsuDat
                                        result.hitobjects[result.hitobjects.size() - 1]->getDuration();
 
     // set isEndOfCombo + precalculate Score v2 combo portion maximum
-    if(beatmapStandard != NULL) {
+    if(beatmap != NULL) {
         unsigned long long scoreV2ComboPortionMaximum = 1;
 
         if(result.hitobjects.size() > 0) scoreV2ComboPortionMaximum = 0;
@@ -1627,7 +1536,7 @@ OsuDatabaseBeatmap::LOAD_GAMEPLAY_RESULT OsuDatabaseBeatmap::loadGameplay(OsuDat
             if(nextHitObject == NULL || nextHitObject->getComboNumber() == 1) currentHitObject->setIsEndOfCombo(true);
         }
 
-        beatmapStandard->setScoreV2ComboPortionMaximum(scoreV2ComboPortionMaximum);
+        beatmap->setScoreV2ComboPortionMaximum(scoreV2ComboPortionMaximum);
     }
 
     // special rule for first hitobject (for 1 approach circle with HD)
@@ -1924,9 +1833,8 @@ void OsuDatabaseBeatmapStarCalculator::initAsync() {
 
     m_iLengthMS = 0;
 
-    const Osu::GAMEMODE gameMode = Osu::GAMEMODE::STD;
-    OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres = OsuDatabaseBeatmap::loadDifficultyHitObjects(
-        m_sFilePath, gameMode, m_fAR, m_fCS, m_fSpeedMultiplier, false, m_bDead);
+    OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres =
+        OsuDatabaseBeatmap::loadDifficultyHitObjects(m_sFilePath, m_fAR, m_fCS, m_fSpeedMultiplier, false, m_bDead);
     m_iErrorCode = diffres.errorCode;
 
     if(m_iErrorCode == 0) {

+ 8 - 10
src/App/Osu/OsuDatabaseBeatmap.h

@@ -99,12 +99,11 @@ class OsuDatabaseBeatmap {
     OsuDatabaseBeatmap(Osu *osu, std::vector<OsuDatabaseBeatmap *> &difficulties);
     ~OsuDatabaseBeatmap();
 
-    static LOAD_DIFFOBJ_RESULT loadDifficultyHitObjects(const std::string &osuFilePath, Osu::GAMEMODE gameMode,
-                                                        float AR, float CS, float speedMultiplier,
-                                                        bool calculateStarsInaccurately = false);
-    static LOAD_DIFFOBJ_RESULT loadDifficultyHitObjects(const std::string &osuFilePath, Osu::GAMEMODE gameMode,
-                                                        float AR, float CS, float speedMultiplier,
-                                                        bool calculateStarsInaccurately, const std::atomic<bool> &dead);
+    static LOAD_DIFFOBJ_RESULT loadDifficultyHitObjects(const std::string &osuFilePath, float AR, float CS,
+                                                        float speedMultiplier, bool calculateStarsInaccurately = false);
+    static LOAD_DIFFOBJ_RESULT loadDifficultyHitObjects(const std::string &osuFilePath, float AR, float CS,
+                                                        float speedMultiplier, bool calculateStarsInaccurately,
+                                                        const std::atomic<bool> &dead);
     static bool loadMetadata(OsuDatabaseBeatmap *databaseBeatmap);
     static LOAD_GAMEPLAY_RESULT loadGameplay(OsuDatabaseBeatmap *databaseBeatmap, OsuBeatmap *beatmap);
 
@@ -259,7 +258,6 @@ class OsuDatabaseBeatmap {
         int colorCounter;
         int colorOffset;
         bool clicked;
-        long maniaEndTime;
     };
 
     struct SLIDER {
@@ -326,10 +324,10 @@ class OsuDatabaseBeatmap {
     static ConVar *m_osu_debug_pp_ref;
     static ConVar *m_osu_slider_end_inside_check_offset_ref;
 
-    static PRIMITIVE_CONTAINER loadPrimitiveObjects(const std::string &osuFilePath, Osu::GAMEMODE gameMode,
+    static PRIMITIVE_CONTAINER loadPrimitiveObjects(const std::string &osuFilePath,
                                                     bool filePathIsInMemoryBeatmap = false);
-    static PRIMITIVE_CONTAINER loadPrimitiveObjects(const std::string &osuFilePath, Osu::GAMEMODE gameMode,
-                                                    bool filePathIsInMemoryBeatmap, const std::atomic<bool> &dead);
+    static PRIMITIVE_CONTAINER loadPrimitiveObjects(const std::string &osuFilePath, bool filePathIsInMemoryBeatmap,
+                                                    const std::atomic<bool> &dead);
     static CALCULATE_SLIDER_TIMES_CLICKS_TICKS_RESULT calculateSliderTimesClicksTicks(
         int beatmapVersion, std::vector<SLIDER> &sliders, std::vector<TIMINGPOINT> &timingpoints,
         float sliderMultiplier, float sliderTickRate);

+ 25 - 32
src/App/Osu/OsuHUD.cpp

@@ -18,7 +18,6 @@
 #include "OpenGLES2Interface.h"
 #include "Osu.h"
 #include "OsuBeatmap.h"
-#include "OsuBeatmapStandard.h"
 #include "OsuCircle.h"
 #include "OsuDatabase.h"
 #include "OsuDatabaseBeatmap.h"
@@ -308,10 +307,8 @@ void OsuHUD::draw(Graphics *g) {
     OsuBeatmap *beatmap = m_osu->getSelectedBeatmap();
     if(beatmap == NULL) return;  // sanity check
 
-    OsuBeatmapStandard *beatmapStd = dynamic_cast<OsuBeatmapStandard *>(beatmap);
-
     if(osu_draw_hud.getBool()) {
-        if(osu_draw_inputoverlay.getBool() && beatmapStd != NULL) {
+        if(osu_draw_inputoverlay.getBool()) {
             const bool isAutoClicking = (m_osu->getModAuto() || m_osu->getModRelax());
             if(!isAutoClicking)
                 drawInputOverlay(g, m_osu->getScore()->getKeyCount(1), m_osu->getScore()->getKeyCount(2),
@@ -327,8 +324,8 @@ void OsuHUD::draw(Graphics *g) {
 
         g->pushTransform();
         {
-            if(m_osu->getModTarget() && osu_draw_target_heatmap.getBool() && beatmapStd != NULL)
-                g->translate(0, beatmapStd->getHitcircleDiameter() *
+            if(m_osu->getModTarget() && osu_draw_target_heatmap.getBool())
+                g->translate(0, beatmap->getHitcircleDiameter() *
                                     (1.0f / (osu_hud_scale.getFloat() * osu_hud_statistics_scale.getFloat())));
 
             const int hitObjectIndexForCurrentTime =
@@ -366,16 +363,15 @@ void OsuHUD::draw(Graphics *g) {
                 osu_hud_scorebar_hide_during_breaks.getBool() ? (1.0f - beatmap->getBreakBackgroundFadeAnim()) : 1.0f,
                 m_fScoreBarBreakAnim);
 
-        // NOTE: moved to draw behind hitobjects in OsuBeatmapStandard::draw()
+        // NOTE: moved to draw behind hitobjects in OsuBeatmap::draw()
         if(m_osu_mod_fposu_ref->getBool()) {
             if(osu_draw_hiterrorbar.getBool() &&
-               (beatmapStd == NULL ||
-                (!beatmapStd->isSpinnerActive() || !osu_hud_hiterrorbar_hide_during_spinner.getBool())) &&
+               (beatmap == NULL ||
+                (!beatmap->isSpinnerActive() || !osu_hud_hiterrorbar_hide_during_spinner.getBool())) &&
                !beatmap->isLoading()) {
-                if(beatmapStd != NULL)
-                    drawHitErrorBar(g, OsuGameRules::getHitWindow300(beatmap), OsuGameRules::getHitWindow100(beatmap),
-                                    OsuGameRules::getHitWindow50(beatmap), OsuGameRules::getHitWindowMiss(beatmap),
-                                    m_osu->getScore()->getUnstableRate());
+                drawHitErrorBar(g, OsuGameRules::getHitWindow300(beatmap), OsuGameRules::getHitWindow100(beatmap),
+                                OsuGameRules::getHitWindow50(beatmap), OsuGameRules::getHitWindowMiss(beatmap),
+                                m_osu->getScore()->getUnstableRate());
             }
         }
 
@@ -388,26 +384,25 @@ void OsuHUD::draw(Graphics *g) {
 
         if(osu_draw_accuracy.getBool()) drawAccuracy(g, m_osu->getScore()->getAccuracy() * 100.0f);
 
-        if(m_osu->getModTarget() && osu_draw_target_heatmap.getBool() && beatmapStd != NULL)
-            drawTargetHeatmap(g, beatmapStd->getHitcircleDiameter());
+        if(m_osu->getModTarget() && osu_draw_target_heatmap.getBool())
+            drawTargetHeatmap(g, beatmap->getHitcircleDiameter());
     } else if(!osu_hud_shift_tab_toggles_everything.getBool()) {
-        if(osu_draw_inputoverlay.getBool() && beatmapStd != NULL) {
+        if(osu_draw_inputoverlay.getBool()) {
             const bool isAutoClicking = (m_osu->getModAuto() || m_osu->getModRelax());
             if(!isAutoClicking)
                 drawInputOverlay(g, m_osu->getScore()->getKeyCount(1), m_osu->getScore()->getKeyCount(2),
                                  m_osu->getScore()->getKeyCount(3), m_osu->getScore()->getKeyCount(4));
         }
 
-        // NOTE: moved to draw behind hitobjects in OsuBeatmapStandard::draw()
+        // NOTE: moved to draw behind hitobjects in OsuBeatmap::draw()
         if(m_osu_mod_fposu_ref->getBool()) {
             if(osu_draw_hiterrorbar.getBool() &&
-               (beatmapStd == NULL ||
-                (!beatmapStd->isSpinnerActive() || !osu_hud_hiterrorbar_hide_during_spinner.getBool())) &&
+               (beatmap == NULL ||
+                (!beatmap->isSpinnerActive() || !osu_hud_hiterrorbar_hide_during_spinner.getBool())) &&
                !beatmap->isLoading()) {
-                if(beatmapStd != NULL)
-                    drawHitErrorBar(g, OsuGameRules::getHitWindow300(beatmap), OsuGameRules::getHitWindow100(beatmap),
-                                    OsuGameRules::getHitWindow50(beatmap), OsuGameRules::getHitWindowMiss(beatmap),
-                                    m_osu->getScore()->getUnstableRate());
+                drawHitErrorBar(g, OsuGameRules::getHitWindow300(beatmap), OsuGameRules::getHitWindow100(beatmap),
+                                OsuGameRules::getHitWindow50(beatmap), OsuGameRules::getHitWindowMiss(beatmap),
+                                m_osu->getScore()->getUnstableRate());
             }
         }
     }
@@ -415,11 +410,10 @@ void OsuHUD::draw(Graphics *g) {
     if(beatmap->shouldFlashSectionPass()) drawSectionPass(g, beatmap->shouldFlashSectionPass());
     if(beatmap->shouldFlashSectionFail()) drawSectionFail(g, beatmap->shouldFlashSectionFail());
 
-    if(beatmap->shouldFlashWarningArrows())
-        drawWarningArrows(g, beatmapStd != NULL ? beatmapStd->getHitcircleDiameter() : 0);
+    if(beatmap->shouldFlashWarningArrows()) drawWarningArrows(g, beatmap->getHitcircleDiameter());
 
-    if(beatmap->isContinueScheduled() && beatmapStd != NULL && osu_draw_continue.getBool())
-        drawContinue(g, beatmapStd->getContinueCursorPoint(), beatmapStd->getHitcircleDiameter());
+    if(beatmap->isContinueScheduled() && osu_draw_continue.getBool())
+        drawContinue(g, beatmap->getContinueCursorPoint(), beatmap->getHitcircleDiameter());
 
     if(osu_draw_scrubbing_timeline.getBool() && m_osu->isSeeking()) {
         static std::vector<BREAK> breaks;
@@ -1684,13 +1678,12 @@ void OsuHUD::drawContinue(Graphics *g, Vector2 cursor, float hitcircleDiameter)
     g->popTransform();
 }
 
-void OsuHUD::drawHitErrorBar(Graphics *g, OsuBeatmapStandard *beatmapStd) {
+void OsuHUD::drawHitErrorBar(Graphics *g, OsuBeatmap *beatmap) {
     if(osu_draw_hud.getBool() || !osu_hud_shift_tab_toggles_everything.getBool()) {
         if(osu_draw_hiterrorbar.getBool() &&
-           (!beatmapStd->isSpinnerActive() || !osu_hud_hiterrorbar_hide_during_spinner.getBool()) &&
-           !beatmapStd->isLoading())
-            drawHitErrorBar(g, OsuGameRules::getHitWindow300(beatmapStd), OsuGameRules::getHitWindow100(beatmapStd),
-                            OsuGameRules::getHitWindow50(beatmapStd), OsuGameRules::getHitWindowMiss(beatmapStd),
+           (!beatmap->isSpinnerActive() || !osu_hud_hiterrorbar_hide_during_spinner.getBool()) && !beatmap->isLoading())
+            drawHitErrorBar(g, OsuGameRules::getHitWindow300(beatmap), OsuGameRules::getHitWindow100(beatmap),
+                            OsuGameRules::getHitWindow50(beatmap), OsuGameRules::getHitWindowMiss(beatmap),
                             m_osu->getScore()->getUnstableRate());
     }
 }

+ 2 - 2
src/App/Osu/OsuHUD.h

@@ -14,7 +14,7 @@ class Osu;
 class OsuUIAvatar;
 class OsuScore;
 class OsuScoreboardSlot;
-class OsuBeatmapStandard;
+class OsuBeatmap;
 
 class McFont;
 class ConVar;
@@ -53,7 +53,7 @@ class OsuHUD : public OsuScreen {
         bool secondTrail = false);  // NOTE: only use if drawCursor() with updateAndDrawTrail = false (FPoSu)
     void drawCursorRipples(Graphics *g);
     void drawFps(Graphics *g) { drawFps(g, m_tempFont, m_fCurFps); }
-    void drawHitErrorBar(Graphics *g, OsuBeatmapStandard *beatmapStd);
+    void drawHitErrorBar(Graphics *g, OsuBeatmap *beatmap);
     void drawPlayfieldBorder(Graphics *g, Vector2 playfieldCenter, Vector2 playfieldSize, float hitcircleDiameter);
     void drawPlayfieldBorder(Graphics *g, Vector2 playfieldCenter, Vector2 playfieldSize, float hitcircleDiameter,
                              float borderSize);

+ 5 - 9
src/App/Osu/OsuHitObject.cpp

@@ -11,7 +11,7 @@
 #include "ConVar.h"
 #include "Engine.h"
 #include "Osu.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuGameRules.h"
 #include "OsuHUD.h"
 #include "OsuSkin.h"
@@ -98,7 +98,7 @@ ConVar *OsuHitObject::m_osu_mod_mafham_ref = NULL;
 
 unsigned long long OsuHitObject::sortHackCounter = 0;
 
-void OsuHitObject::drawHitResult(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, OsuScore::HIT result,
+void OsuHitObject::drawHitResult(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, OsuScore::HIT result,
                                  float animPercentInv, float hitDeltaRangePercent) {
     drawHitResult(g, beatmap->getSkin(), beatmap->getHitcircleDiameter(), beatmap->getRawHitcircleDiameter(), rawPos,
                   result, animPercentInv, hitDeltaRangePercent);
@@ -352,8 +352,6 @@ void OsuHitObject::drawHitResultAnim(Graphics *g, const HITRESULTANIM &hitresult
                               // scheduled with it, e.g. for slider end)
        && (hitresultanim.time + osu_hitresult_duration_max.getFloat() *
                                     (1.0f / m_beatmap->getOsu()->getAnimationSpeedMultiplier())) > engine->getTime()) {
-        OsuBeatmapStandard *beatmapStd = dynamic_cast<OsuBeatmapStandard *>(m_beatmap);
-
         OsuSkin *skin = m_beatmap->getSkin();
         {
             const long skinAnimationTimeStartOffset =
@@ -380,11 +378,9 @@ void OsuHitObject::drawHitResultAnim(Graphics *g, const HITRESULTANIM &hitresult
                 (((engine->getTime() - hitresultanim.time) * m_beatmap->getOsu()->getAnimationSpeedMultiplier()) /
                  osu_hitresult_duration.getFloat());
 
-            if(beatmapStd != NULL)
-                drawHitResult(
-                    g, beatmapStd, beatmapStd->osuCoords2Pixels(hitresultanim.rawPos), hitresultanim.result,
-                    animPercentInv,
-                    clamp<float>((float)hitresultanim.delta / OsuGameRules::getHitWindow50(beatmapStd), -1.0f, 1.0f));
+            drawHitResult(
+                g, m_beatmap, m_beatmap->osuCoords2Pixels(hitresultanim.rawPos), hitresultanim.result, animPercentInv,
+                clamp<float>((float)hitresultanim.delta / OsuGameRules::getHitWindow50(m_beatmap), -1.0f, 1.0f));
         }
     }
 }

+ 2 - 2
src/App/Osu/OsuHitObject.h

@@ -13,11 +13,11 @@
 class ConVar;
 
 class OsuModFPoSu;
-class OsuBeatmapStandard;
+class OsuBeatmap;
 
 class OsuHitObject {
    public:
-    static void drawHitResult(Graphics *g, OsuBeatmapStandard *beatmap, Vector2 rawPos, OsuScore::HIT result,
+    static void drawHitResult(Graphics *g, OsuBeatmap *beatmap, Vector2 rawPos, OsuScore::HIT result,
                               float animPercentInv, float hitDeltaRangePercent);
     static void drawHitResult(Graphics *g, OsuSkin *skin, float hitcircleDiameter, float rawHitcircleDiameter,
                               Vector2 rawPos, OsuScore::HIT result, float animPercentInv, float hitDeltaRangePercent);

+ 2 - 2
src/App/Osu/OsuMainMenu.cpp

@@ -19,7 +19,7 @@
 #include "Mouse.h"
 #include "Osu.h"
 #include "OsuBackgroundImageHandler.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuDatabase.h"
 #include "OsuDatabaseBeatmap.h"
 #include "OsuGameRules.h"
@@ -321,7 +321,7 @@ OsuMainMenu::OsuMainMenu(Osu *osu) : OsuScreen(osu) {
     m_fMainMenuSliderTextRawHitCircleDiameter = 1.0f;
     if(osu_main_menu_use_slider_text.getBool()) {
         m_mainMenuSliderTextDatabaseBeatmap = new OsuDatabaseBeatmap(m_osu, s_sliderTextBeatmap, "", true);
-        m_mainMenuSliderTextBeatmapStandard = new OsuBeatmapStandard(m_osu);
+        m_mainMenuSliderTextBeatmapStandard = new OsuBeatmap(m_osu);
 
         // HACKHACK: temporary workaround to avoid this breaking the main menu logo text sliders (1/2)
         const bool wasModRandomEnabled = m_osu_mod_random_ref->getBool();

+ 2 - 2
src/App/Osu/OsuMainMenu.h

@@ -14,7 +14,7 @@
 
 class Osu;
 
-class OsuBeatmapStandard;
+class OsuBeatmap;
 class OsuDatabaseBeatmap;
 
 class OsuHitObject;
@@ -165,7 +165,7 @@ class OsuMainMenu : public OsuScreen, public MouseListener {
     float m_fStartupAnim2;
 
     OsuDatabaseBeatmap *m_mainMenuSliderTextDatabaseBeatmap;
-    OsuBeatmapStandard *m_mainMenuSliderTextBeatmapStandard;
+    OsuBeatmap *m_mainMenuSliderTextBeatmapStandard;
     std::vector<OsuHitObject *> m_mainMenuSliderTextBeatmapHitObjects;
     float m_fMainMenuSliderTextRawHitCircleDiameter;
 

+ 3 - 3
src/App/Osu/OsuModFPoSu.cpp

@@ -23,7 +23,7 @@
 #include "OpenGLLegacyInterface.h"
 #include "Osu.h"
 #include "OsuBackgroundImageHandler.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuKeyBindings.h"
 #include "OsuModSelector.h"
 #include "OsuOptionsMenu.h"
@@ -346,8 +346,8 @@ void OsuModFPoSu::update() {
         // auto support, because it looks pretty cool
         Vector2 mousePos = engine->getMouse()->getPos();
         if(isAutoCursor && m_osu->isInPlayMode() && m_osu->getSelectedBeatmap() != NULL) {
-            OsuBeatmapStandard *beatmapStd = dynamic_cast<OsuBeatmapStandard *>(m_osu->getSelectedBeatmap());
-            if(beatmapStd != NULL && !beatmapStd->isPaused()) mousePos = beatmapStd->getCursorPos();
+            OsuBeatmap *beatmap = m_osu->getSelectedBeatmap();
+            if(beatmap != NULL && !beatmap->isPaused()) mousePos = beatmap->getCursorPos();
         }
 
         m_camera->lookAt(calculateUnProjectedVector(mousePos));

+ 7 - 9
src/App/Osu/OsuScore.cpp

@@ -13,7 +13,6 @@
 #include "Engine.h"
 #include "Osu.h"
 #include "OsuBeatmap.h"
-#include "OsuBeatmapStandard.h"
 #include "OsuDatabaseBeatmap.h"
 #include "OsuDifficultyCalculator.h"
 #include "OsuGameRules.h"
@@ -300,14 +299,13 @@ void OsuScore::addHitResult(OsuBeatmap *beatmap, OsuHitObject *hitObject, HIT hi
     // recalculate pp
     if(m_osu_draw_statistics_pp_ref->getBool())  // sanity + performance
     {
-        OsuBeatmapStandard *standardPointer = dynamic_cast<OsuBeatmapStandard *>(beatmap);
-        if(standardPointer != NULL && beatmap->getSelectedDifficulty2() != NULL) {
-            double aimStars = standardPointer->getAimStars();
-            double aimSliderFactor = standardPointer->getAimSliderFactor();
-            double speedStars = standardPointer->getSpeedStars();
-            double speedNotes = standardPointer->getSpeedNotes();
-
-            // int numHitObjects = standardPointer->getNumHitObjects();
+        if(beatmap != NULL && beatmap->getSelectedDifficulty2() != NULL) {
+            double aimStars = beatmap->getAimStars();
+            double aimSliderFactor = beatmap->getAimSliderFactor();
+            double speedStars = beatmap->getSpeedStars();
+            double speedNotes = beatmap->getSpeedNotes();
+
+            // int numHitObjects = beatmap->getNumHitObjects();
             int maxPossibleCombo = beatmap->getMaxPossibleCombo();
             int numCircles = beatmap->getSelectedDifficulty2()->getNumCircles();
             int numSliders = beatmap->getSelectedDifficulty2()->getNumSliders();

+ 2 - 2
src/App/Osu/OsuSlider.cpp

@@ -13,7 +13,7 @@
 #include "ConVar.h"
 #include "Engine.h"
 #include "Osu.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuCircle.h"
 #include "OsuGameRules.h"
 #include "OsuModFPoSu.h"
@@ -76,7 +76,7 @@ ConVar *OsuSlider::m_osu_drain_type_ref = NULL;
 OsuSlider::OsuSlider(char type, int repeat, float pixelLength, std::vector<Vector2> points, std::vector<int> hitSounds,
                      std::vector<float> ticks, float sliderTime, float sliderTimeWithoutRepeats, long time,
                      int sampleType, int comboNumber, bool isEndOfCombo, int colorCounter, int colorOffset,
-                     OsuBeatmapStandard *beatmap)
+                     OsuBeatmap *beatmap)
     : OsuHitObject(time, sampleType, comboNumber, isEndOfCombo, colorCounter, colorOffset, beatmap) {
     if(m_osu_playfield_mirror_horizontal_ref == NULL)
         m_osu_playfield_mirror_horizontal_ref = convar->getConVarByName("osu_playfield_mirror_horizontal");

+ 2 - 2
src/App/Osu/OsuSlider.h

@@ -28,7 +28,7 @@ class OsuSlider : public OsuHitObject {
    public:
     OsuSlider(char type, int repeat, float pixelLength, std::vector<Vector2> points, std::vector<int> hitSounds,
               std::vector<float> ticks, float sliderTime, float sliderTimeWithoutRepeats, long time, int sampleType,
-              int comboNumber, bool isEndOfCombo, int colorCounter, int colorOffset, OsuBeatmapStandard *beatmap);
+              int comboNumber, bool isEndOfCombo, int colorCounter, int colorOffset, OsuBeatmap *beatmap);
     virtual ~OsuSlider();
 
     virtual void draw(Graphics *g);
@@ -90,7 +90,7 @@ class OsuSlider : public OsuHitObject {
 
     bool isClickHeldSlider();  // special logic to disallow hold tapping
 
-    OsuBeatmapStandard *m_beatmap;
+    OsuBeatmap *m_beatmap;
 
     OsuSliderCurve *m_curve;
 

+ 6 - 21
src/App/Osu/OsuSongBrowser2.cpp

@@ -21,7 +21,7 @@
 #include "Mouse.h"
 #include "Osu.h"
 #include "OsuBackgroundImageHandler.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuChat.h"
 #include "OsuDatabase.h"
 #include "OsuDatabaseBeatmap.h"
@@ -1682,10 +1682,9 @@ void OsuSongBrowser2::onDifficultySelected(OsuDatabaseBeatmap *diff2, bool play)
     const bool wasSelectedBeatmapNULL = (m_selectedBeatmap == NULL);
     if(m_selectedBeatmap != NULL) m_selectedBeatmap->deselect();
 
-    // create/recreate/cache runtime beatmap object depending on gamemode
-    if(m_osu->getGamemode() == Osu::GAMEMODE::STD && dynamic_cast<OsuBeatmapStandard *>(m_selectedBeatmap) == NULL) {
-        SAFE_DELETE(m_selectedBeatmap);
-        m_selectedBeatmap = new OsuBeatmapStandard(m_osu);
+    // create/recreate/cache runtime beatmap object
+    if(m_selectedBeatmap == NULL) {
+        m_selectedBeatmap = new OsuBeatmap(m_osu);
     }
 
     // remember it
@@ -3766,7 +3765,7 @@ void OsuSongBrowser2::onSelectionMode() {
         {
             if(m_osu_mod_fposu_ref->getBool())
                 activeButton = fposuButton;
-            else if(m_osu->getGamemode() == Osu::GAMEMODE::STD)
+            else
                 activeButton = standardButton;
         }
         if(activeButton != NULL) activeButton->setTextBrightColor(0xff00ff00);
@@ -3818,21 +3817,7 @@ void OsuSongBrowser2::onSelectionOptions() {
 void OsuSongBrowser2::onModeChange(UString text) { onModeChange2(text); }
 
 void OsuSongBrowser2::onModeChange2(UString text, int id) {
-    if(id != 2 && text != UString("fposu")) m_osu_mod_fposu_ref->setValue(0.0f);
-
-    if(id == 0 || text == UString("std")) {
-        if(m_osu->getGamemode() != Osu::GAMEMODE::STD) {
-            m_osu->setGamemode(Osu::GAMEMODE::STD);
-            refreshBeatmaps();
-        }
-    } else if(id == 2 || text == UString("fposu")) {
-        m_osu_mod_fposu_ref->setValue(1.0f);
-
-        if(m_osu->getGamemode() != Osu::GAMEMODE::STD) {
-            m_osu->setGamemode(Osu::GAMEMODE::STD);
-            refreshBeatmaps();
-        }
-    }
+    m_osu_mod_fposu_ref->setValue(id == 2 || text == UString("fposu"));
 }
 
 void OsuSongBrowser2::onUserButtonClicked() {

+ 2 - 3
src/App/Osu/OsuSpinner.cpp

@@ -12,7 +12,7 @@
 #include "Engine.h"
 #include "Mouse.h"
 #include "Osu.h"
-#include "OsuBeatmapStandard.h"
+#include "OsuBeatmap.h"
 #include "OsuGameRules.h"
 #include "OsuSkin.h"
 #include "ResourceManager.h"
@@ -22,8 +22,7 @@ ConVar osu_spinner_use_ar_fadein(
     "osu_spinner_use_ar_fadein", false, FCVAR_NONE,
     "whether spinners should fade in with AR (same as circles), or with hardcoded 400 ms fadein time (osu!default)");
 
-OsuSpinner::OsuSpinner(int x, int y, long time, int sampleType, bool isEndOfCombo, long endTime,
-                       OsuBeatmapStandard *beatmap)
+OsuSpinner::OsuSpinner(int x, int y, long time, int sampleType, bool isEndOfCombo, long endTime, OsuBeatmap *beatmap)
     : OsuHitObject(time, sampleType, -1, isEndOfCombo, -1, -1, beatmap) {
     m_vOriginalRawPos = Vector2(x, y);
     m_vRawPos = m_vOriginalRawPos;

+ 2 - 2
src/App/Osu/OsuSpinner.h

@@ -12,7 +12,7 @@
 
 class OsuSpinner : public OsuHitObject {
    public:
-    OsuSpinner(int x, int y, long time, int sampleType, bool isEndOfCombo, long endTime, OsuBeatmapStandard *beatmap);
+    OsuSpinner(int x, int y, long time, int sampleType, bool isEndOfCombo, long endTime, OsuBeatmap *beatmap);
     virtual ~OsuSpinner();
 
     virtual void draw(Graphics *g);
@@ -34,7 +34,7 @@ class OsuSpinner : public OsuHitObject {
     void onHit();
     void rotate(float rad);
 
-    OsuBeatmapStandard *m_beatmap;
+    OsuBeatmap *m_beatmap;
 
     Vector2 m_vRawPos;
     Vector2 m_vOriginalRawPos;

+ 1 - 2
src/App/Osu/OsuUserStatsScreen.cpp

@@ -136,7 +136,6 @@ class OsuUserStatsScreenBackgroundPPRecalculator : public Resource {
                     const OsuReplay::BEATMAP_VALUES legacyValues = OsuReplay::getBeatmapValuesForModsLegacy(
                         score.modsLegacy, diff2->getAR(), diff2->getCS(), diff2->getOD(), diff2->getHP());
                     const std::string &osuFilePath = diff2->getFilePath();
-                    const Osu::GAMEMODE gameMode = Osu::GAMEMODE::STD;
                     const float AR = (score.isLegacyScore ? legacyValues.AR : score.AR);
                     const float CS = (score.isLegacyScore ? legacyValues.CS : score.CS);
                     const float OD = (score.isLegacyScore ? legacyValues.OD : score.OD);
@@ -148,7 +147,7 @@ class OsuUserStatsScreenBackgroundPPRecalculator : public Resource {
 
                     // 2) load hitobjects for diffcalc
                     OsuDatabaseBeatmap::LOAD_DIFFOBJ_RESULT diffres =
-                        OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, gameMode, AR, CS, speedMultiplier);
+                        OsuDatabaseBeatmap::loadDifficultyHitObjects(osuFilePath, AR, CS, speedMultiplier);
                     if(diffres.diffobjects.size() < 1) {
                         if(Osu::debug->getBool()) debugLog("PPRecalc couldn't load %s\n", osuFilePath.c_str());