Ver código fonte

Implement replay viewer

Clément Wolf 1 mês atrás
pai
commit
93329431b9

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

@@ -24,7 +24,7 @@ OsuDatabase::Score parse_score(char *score_line) {
     char *saveptr = NULL;
     char *str = strtok_r(score_line, "|", &saveptr);
     if(!str) return score;
-    // Do nothing with score ID
+    score.online_score_id = strtoul(str, NULL, 10);
 
     str = strtok_r(NULL, "|", &saveptr);
     if(!str) return score;
@@ -76,7 +76,7 @@ OsuDatabase::Score parse_score(char *score_line) {
 
     str = strtok_r(NULL, "|", &saveptr);
     if(!str) return score;
-    // Do nothing with rank
+    score.has_replay = strtoul(str, NULL, 10);
 
     str = strtok_r(NULL, "|", &saveptr);
     if(!str) return score;
@@ -198,6 +198,6 @@ void process_leaderboard_response(Packet response) {
     // XXX: We should also separately display either the "personal best" the server sent us,
     //      or the local best, depending on which score is better.
     debugLog("Received online leaderbord for Beatmap ID %d\n", info.beatmap_id);
-    bancho.osu->getSongBrowser()->getDatabase()->m_online_scores[beatmap_hash] = scores;
+    bancho.osu->getSongBrowser()->getDatabase()->m_online_scores[beatmap_hash] = std::move(scores);
     bancho.osu->getSongBrowser()->rebuildScoreButtons();
 }

+ 124 - 34
src/App/Osu/OsuBeatmap.cpp

@@ -439,7 +439,7 @@ void OsuBeatmap::drawDebug(Graphics *g) {
         McFont *debugFont = engine->getResourceManager()->getFont("FONT_DEFAULT");
         g->setColor(0xffffffff);
         g->pushTransform();
-        g->translate(5, debugFont->getHeight() + 5 - engine->getMouse()->getPos().y);
+        g->translate(5, debugFont->getHeight() + 5 - getMousePos().y);
         {
             for(const OsuDatabaseBeatmap::TIMINGPOINT &t : m_selectedDifficulty2->getTimingpoints()) {
                 g->drawString(debugFont, UString::format("%li,%f,%i,%i,%i", t.offset, t.msPerBeat, t.sampleType,
@@ -581,6 +581,8 @@ void OsuBeatmap::skipEmptySection() {
 }
 
 void OsuBeatmap::keyPressed1(bool mouse) {
+    if(m_bIsWatchingReplay) return;
+
     if(m_bContinueScheduled) m_bClickedContinue = !m_osu->getModSelector()->isMouseInside();
 
     if(osu_mod_fullalternate.getBool() && m_bPrevKeyWasKey1) {
@@ -600,11 +602,8 @@ void OsuBeatmap::keyPressed1(bool mouse) {
     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);
+        m_clicks.push_back(m_iCurMusicPosWithOffsets);
 
     if(mouse) {
         current_keys = current_keys | OsuReplay::M1;
@@ -614,6 +613,8 @@ void OsuBeatmap::keyPressed1(bool mouse) {
 }
 
 void OsuBeatmap::keyPressed2(bool mouse) {
+    if(m_bIsWatchingReplay) return;
+
     if(m_bContinueScheduled) m_bClickedContinue = !m_osu->getModSelector()->isMouseInside();
 
     if(osu_mod_fullalternate.getBool() && !m_bPrevKeyWasKey1) {
@@ -633,11 +634,8 @@ void OsuBeatmap::keyPressed2(bool mouse) {
     m_bPrevKeyWasKey1 = false;
     m_bClick2Held = 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);
+        m_clicks.push_back(m_iCurMusicPosWithOffsets);
 
     if(mouse) {
         current_keys = current_keys | OsuReplay::M2;
@@ -647,6 +645,8 @@ void OsuBeatmap::keyPressed2(bool mouse) {
 }
 
 void OsuBeatmap::keyReleased1(bool mouse) {
+    if(m_bIsWatchingReplay) return;
+
     // key overlay
     m_osu->getHUD()->animateInputoverlay(1, false);
     m_osu->getHUD()->animateInputoverlay(3, false);
@@ -657,6 +657,8 @@ void OsuBeatmap::keyReleased1(bool mouse) {
 }
 
 void OsuBeatmap::keyReleased2(bool mouse) {
+    if(m_bIsWatchingReplay) return;
+
     // key overlay
     m_osu->getHUD()->animateInputoverlay(2, false);
     m_osu->getHUD()->animateInputoverlay(4, false);
@@ -694,6 +696,26 @@ void OsuBeatmap::deselect() {
     unloadObjects();
 }
 
+bool OsuBeatmap::watch(std::vector<OsuReplay::Frame> replay) {
+    // Replay is invalid
+    if(replay.size() < 3) {
+        return false;
+    }
+
+    // Map failed to load
+    if(!play()) {
+        return false;
+    }
+
+    spectated_replay = std::move(replay);
+    m_bIsWatchingReplay = true;
+    last_event_ms = -1;
+    last_keys = 0;
+    current_keys = 0;
+
+    return true;
+}
+
 bool OsuBeatmap::play() {
     if(m_selectedDifficulty2 == NULL) return false;
 
@@ -823,9 +845,11 @@ bool OsuBeatmap::play() {
     m_bIsWaiting = true;
     m_fWaitTime = engine->getTimeReal();
 
-    // NOTE: loading failures are handled dynamically in update(), so temporarily assume everything has worked in here
     m_bIsPlaying = true;
-    return m_bIsPlaying;
+    m_bIsWatchingReplay = false;
+
+    // NOTE: loading failures are handled dynamically in update(), so temporarily assume everything has worked in here
+    return true;
 }
 
 void OsuBeatmap::restart(bool quick) {
@@ -1597,11 +1621,8 @@ void OsuBeatmap::unloadObjects() {
     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>();
+    m_clicks = std::vector<long>();
 }
 
 void OsuBeatmap::resetHitObjects(long curPos) {
@@ -1616,15 +1637,15 @@ void OsuBeatmap::resetHitObjects(long curPos) {
 void OsuBeatmap::resetScore() {
     vanilla = convar->isVanilla();
 
-    replay.clear();
-    replay.push_back(OsuReplay::Frame{
+    live_replay.clear();
+    live_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{
+    live_replay.push_back(OsuReplay::Frame{
         .cur_music_pos = -1,
         .milliseconds_since_last_frame = -1,
         .x = 256,
@@ -2259,10 +2280,7 @@ void OsuBeatmap::update2() {
                                   // 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();
-            }
+            m_clicks.clear();
         }
     }
 
@@ -2410,6 +2428,74 @@ void OsuBeatmap::update2() {
                                 (m_selectedDifficulty2->getVersion() < 5 ? osu_old_beatmap_offset.getInt() : 0);
     updateTimingPoints(m_iCurMusicPosWithOffsets);
 
+    if(m_bIsWatchingReplay) {
+        OsuReplay::Frame current_frame = spectated_replay[0];
+        OsuReplay::Frame next_frame = spectated_replay[0];
+
+        for(auto frame : spectated_replay) {
+            next_frame = frame;
+
+            // XXX: The way this is done, inputs will be skipped if the user is lagging
+            if(next_frame.cur_music_pos > m_iCurMusicPosWithOffsets) {
+                if(current_frame.cur_music_pos > last_event_ms) {
+                    last_event_ms = current_frame.cur_music_pos;
+                    last_keys = current_keys;
+                    current_keys = current_frame.key_flags;
+
+                    // Released key 1
+                    if(last_keys & (OsuReplay::K1) && current_keys & ~(OsuReplay::K1)) {
+                        m_osu->getHUD()->animateInputoverlay(1, false);
+                    } else if(last_keys & (OsuReplay::M1) && current_keys & ~(OsuReplay::M1)) {
+                        m_osu->getHUD()->animateInputoverlay(3, false);
+                    }
+
+                    // Released key 2
+                    if(last_keys & (OsuReplay::K2) && current_keys & ~(OsuReplay::K2)) {
+                        m_osu->getHUD()->animateInputoverlay(2, false);
+                    } else if(last_keys & (OsuReplay::M2) && current_keys & ~(OsuReplay::M2)) {
+                        m_osu->getHUD()->animateInputoverlay(4, false);
+                    }
+
+                    // Pressed key 1
+                    if(last_keys & (OsuReplay::K1) && current_keys & ~(OsuReplay::K1)) {
+                        m_osu->getHUD()->animateInputoverlay(1, true);
+                        m_clicks.push_back(last_event_ms);
+                        if(!m_bInBreak && !m_bIsInSkippableSection) m_osu->getScore()->addKeyCount(1);
+                    } else if(last_keys & (OsuReplay::M1) && current_keys & ~(OsuReplay::M1)) {
+                        m_osu->getHUD()->animateInputoverlay(3, true);
+                        m_clicks.push_back(last_event_ms);
+                        if(!m_bInBreak && !m_bIsInSkippableSection) m_osu->getScore()->addKeyCount(3);
+                    }
+
+                    // Pressed key 2
+                    if(last_keys & ~(OsuReplay::K2) && current_keys & (OsuReplay::K2)) {
+                        m_osu->getHUD()->animateInputoverlay(2, true);
+                        m_clicks.push_back(last_event_ms);
+                        if(!m_bInBreak && !m_bIsInSkippableSection) m_osu->getScore()->addKeyCount(2);
+                    } else if(last_keys & ~(OsuReplay::M2) && current_keys & (OsuReplay::M2)) {
+                        m_osu->getHUD()->animateInputoverlay(4, true);
+                        m_clicks.push_back(last_event_ms);
+                        if(!m_bInBreak && !m_bIsInSkippableSection) m_osu->getScore()->addKeyCount(4);
+                    }
+                }
+
+                break;
+            }
+
+            current_frame = frame;
+        }
+
+        float percent = 0.f;
+        if(next_frame.milliseconds_since_last_frame > 0) {
+            long ms_since_last_frame = m_iCurMusicPosWithOffsets - current_frame.cur_music_pos;
+            percent = (float)ms_since_last_frame / (float)next_frame.milliseconds_since_last_frame;
+        }
+        m_interpolatedMousePos = Vector2{
+            .x = lerp(current_frame.x, next_frame.x, percent),
+            .y = lerp(current_frame.y, next_frame.y, percent),
+        };
+    }
+
     // for performance reasons, a lot of operations are crammed into 1 loop over all hitobjects:
     // update all hitobjects,
     // handle click events,
@@ -2567,8 +2653,6 @@ void OsuBeatmap::update2() {
             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 =
@@ -2707,7 +2791,7 @@ void OsuBeatmap::update2() {
                         continue;
 
                     m_misaimObjects[i]->misAimed();
-                    const long delta = (long)m_clicks[c].musicPos - (long)m_misaimObjects[i]->getTime();
+                    const long delta = m_clicks[c] - (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)
@@ -2729,7 +2813,6 @@ void OsuBeatmap::update2() {
 
             m_clicks.clear();
         }
-        m_keyUps.clear();
     }
 
     // empty section detection & skipping
@@ -2953,7 +3036,7 @@ void OsuBeatmap::write_frame() {
     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{
+    live_replay.push_back(OsuReplay::Frame{
         .cur_music_pos = m_iCurMusicPosWithOffsets,
         .milliseconds_since_last_frame = delta,
         .x = pos.x,
@@ -3257,6 +3340,14 @@ Vector2 OsuBeatmap::osuCoords2LegacyPixels(Vector2 coords) const {
     return coords;
 }
 
+Vector2 OsuBeatmap::getMousePos() const {
+    if(m_bIsWatchingReplay) {
+        return m_interpolatedMousePos;
+    } else {
+        return engine->getMouse()->getPos();
+    }
+}
+
 Vector2 OsuBeatmap::getCursorPos() const {
     if(OsuGameRules::osu_mod_fps.getBool() && !m_bIsPaused) {
         if(m_osu->getModAuto() || m_osu->getModAutopilot())
@@ -3266,7 +3357,7 @@ Vector2 OsuBeatmap::getCursorPos() const {
     } else if(m_osu->getModAuto() || m_osu->getModAutopilot())
         return m_vAutoCursorPos;
     else {
-        Vector2 pos = engine->getMouse()->getPos();
+        Vector2 pos = getMousePos();
         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()),
@@ -3278,8 +3369,7 @@ Vector2 OsuBeatmap::getCursorPos() const {
 }
 
 Vector2 OsuBeatmap::getFirstPersonCursorDelta() const {
-    return m_vPlayfieldCenter -
-           (m_osu->getModAuto() || m_osu->getModAutopilot() ? m_vAutoCursorPos : engine->getMouse()->getPos());
+    return m_vPlayfieldCenter - (m_osu->getModAuto() || m_osu->getModAutopilot() ? m_vAutoCursorPos : getMousePos());
 }
 
 float OsuBeatmap::getHitcircleDiameter() const { return m_fHitcircleDiameter; }
@@ -3340,8 +3430,8 @@ void OsuBeatmap::onBeforeStop(bool quit) {
     // 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();
+    const bool isCheated = (m_osu->getModAuto() || (m_osu->getModAutopilot() && m_osu->getModRelax())) ||
+                           m_osu->getScore()->isUnranked() || m_bIsWatchingReplay;
 
     OsuDatabase::Score score;
     score.isLegacyScore = false;
@@ -3409,7 +3499,7 @@ void OsuBeatmap::onBeforeStop(bool quit) {
         OsuRichPresence::onPlayEnd(m_osu, quit);
 
         if(bancho.submit_scores() && !isZero && vanilla) {
-            score.replay = replay;
+            score.replay = live_replay;
             submit_score(score);
         }
 
@@ -3436,7 +3526,7 @@ void OsuBeatmap::onPaused(bool first) {
     debugLog("OsuBeatmap::onPaused()\n");
 
     if(first) {
-        m_vContinueCursorPoint = engine->getMouse()->getPos();
+        m_vContinueCursorPoint = getMousePos();
 
         if(OsuGameRules::osu_mod_fps.getBool()) m_vContinueCursorPoint = OsuGameRules::getPlayfieldCenter(m_osu);
     }

+ 11 - 9
src/App/Osu/OsuBeatmap.h

@@ -22,10 +22,6 @@ struct OsuBeatmap {
     friend class OsuBackgroundStarCacheLoader;
     friend class OsuBackgroundStarCalcHandler;
 
-    struct CLICK {
-        long musicPos;
-    };
-
     OsuBeatmap(Osu *osu);
     ~OsuBeatmap();
 
@@ -63,6 +59,7 @@ struct OsuBeatmap {
                                 // the static slider mesh) centered at (0, 0, 0)
 
     // cursor
+    Vector2 getMousePos() const;
     Vector2 getCursorPos() const;
     Vector2 getFirstPersonCursorDelta() const;
     inline Vector2 getContinueCursorPoint() const { return m_vContinueCursorPoint; }
@@ -106,6 +103,7 @@ struct OsuBeatmap {
                     // clicking on a beatmap)
     void selectDifficulty2(OsuDatabaseBeatmap *difficulty2);
     void deselect();  // stops + unloads the currently loaded music and deletes all hitobjects
+    bool watch(std::vector<OsuReplay::Frame> replay);
     bool play();
     void restart(bool quick = false);
     void pause(bool quitIfWaiting = true);
@@ -179,14 +177,20 @@ struct OsuBeatmap {
     // set to false when using non-vanilla mods (disables score submission)
     bool vanilla = true;
 
-    // replay recording (see OsuBeatmap)
+    // replay recording
     void write_frame();
-    std::vector<OsuReplay::Frame> replay;
+    std::vector<OsuReplay::Frame> live_replay;
     double last_event_time = 0.0;
     long last_event_ms = 0;
     uint8_t current_keys = 0;
     uint8_t last_keys = 0;
 
+    // replay replaying
+    // last_event_ms, current_keys, last_keys also reused
+    std::vector<OsuReplay::Frame> spectated_replay;
+    Vector2 m_interpolatedMousePos;
+    bool m_bIsWatchingReplay = false;
+
     // used by OsuHitObject children and OsuModSelector
     inline Osu *getOsu() const { return m_osu; }
     OsuSkin *getSkin() const;  // maybe use this for beatmap skins, maybe
@@ -266,7 +270,6 @@ struct OsuBeatmap {
     Osu *m_osu;
 
     // beatmap state
-    bool m_bIsWatchingReplay = false;  // TODO @kiwec: set this somewhere
     bool m_bIsPlaying;
     bool m_bIsPaused;
     bool m_bIsWaiting;
@@ -324,8 +327,7 @@ struct OsuBeatmap {
     bool m_bClickedContinue;
     bool m_bPrevKeyWasKey1;
     int m_iAllowAnyNextKeyForFullAlternateUntilHitObjectIndex;
-    std::vector<CLICK> m_clicks;
-    std::vector<CLICK> m_keyUps;
+    std::vector<long> m_clicks;
 
     // hitobjects
     std::vector<OsuHitObject *> m_hitobjects;

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

@@ -563,7 +563,7 @@ void OsuCircle::miss(long curPos) {
     onHit(OsuScore::HIT::HIT_MISS, delta);
 }
 
-void OsuCircle::onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks) {
+void OsuCircle::onClickEvent(std::vector<long> &clicks) {
     if(m_bFinished) return;
 
     const Vector2 cursorPos = m_beatmap->getCursorPos();
@@ -578,7 +578,7 @@ void OsuCircle::onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks) {
             return;  // ignore click event completely
         }
 
-        const long delta = (long)clicks[0].musicPos - (long)m_iTime;
+        const long delta = clicks[0] - (long)m_iTime;
 
         OsuScore::HIT result = OsuGameRules::getHitResult(delta, m_beatmap);
         if(result != OsuScore::HIT::HIT_NULL) {

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

@@ -73,7 +73,7 @@ class OsuCircle : public OsuHitObject {
     Vector2 getOriginalRawPosAt(long pos) { return m_vOriginalRawPos; }
     Vector2 getAutoCursorPos(long curPos);
 
-    virtual void onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks);
+    virtual void onClickEvent(std::vector<long> &clicks);
     virtual void onReset(long curPos);
 
    private:

+ 3 - 0
src/App/Osu/OsuDatabase.h

@@ -63,6 +63,9 @@ class OsuDatabase {
         uint64_t play_time_ms = 0;
         std::vector<OsuReplay::Frame> replay;
 
+        bool has_replay = false;
+        uint64_t online_score_id = 0;
+
         int num300s;
         int num100s;
         int num50s;

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

@@ -80,8 +80,7 @@ class OsuHitObject {
     inline bool isBlocked() const { return m_bBlocked; }
     inline bool hasMisAimed() const { return m_bMisAim; }
 
-    virtual void onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks) { ; }
-    virtual void onKeyUpEvent(std::vector<OsuBeatmap::CLICK> &keyUps) { ; }
+    virtual void onClickEvent(std::vector<long> &clicks) { ; }
     virtual void onReset(long curPos);
 
    protected:

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

@@ -1098,7 +1098,7 @@ float OsuSlider::getT(long pos, bool raw) {
     }
 }
 
-void OsuSlider::onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks) {
+void OsuSlider::onClickEvent(std::vector<long> &clicks) {
     if(m_points.size() == 0 || m_bBlocked)
         return;  // also handle note blocking here (doesn't need fancy shake logic, since sliders don't shake in
                  // osu!stable)
@@ -1110,7 +1110,7 @@ void OsuSlider::onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks) {
         const float cursorDelta = (cursorPos - pos).length();
 
         if(cursorDelta < m_beatmap->getHitcircleDiameter() / 2.0f) {
-            const long delta = (long)clicks[0].musicPos - (long)m_iTime;
+            const long delta = clicks[0] - (long)m_iTime;
 
             OsuScore::HIT result = OsuGameRules::getHitResult(delta, m_beatmap);
             if(result != OsuScore::HIT::HIT_NULL) {

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

@@ -48,7 +48,7 @@ class OsuSlider : public OsuHitObject {
     Vector2 getOriginalRawPosAt(long pos);
     inline Vector2 getAutoCursorPos(long curPos) { return m_vCurPoint; }
 
-    virtual void onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks);
+    virtual void onClickEvent(std::vector<long> &clicks);
     virtual void onReset(long curPos);
 
     void rebuildVertexBuffer(bool useRawCoords = false);

+ 1 - 1
src/App/Osu/OsuSpinner.cpp

@@ -374,7 +374,7 @@ void OsuSpinner::update(long curPos) {
     }
 }
 
-void OsuSpinner::onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks) {
+void OsuSpinner::onClickEvent(std::vector<long> &clicks) {
     if(m_bFinished) return;
 
     // needed for nightmare mod

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

@@ -27,7 +27,7 @@ class OsuSpinner : public OsuHitObject {
     Vector2 getOriginalRawPosAt(long pos) { return m_vOriginalRawPos; }
     Vector2 getAutoCursorPos(long curPos);
 
-    virtual void onClickEvent(std::vector<OsuBeatmap::CLICK> &clicks);
+    virtual void onClickEvent(std::vector<long> &clicks);
     virtual void onReset(long curPos);
 
    private:

+ 18 - 2
src/App/Osu/OsuUISongBrowserScoreButton.cpp

@@ -532,11 +532,16 @@ void OsuUISongBrowserScoreButton::onRightMouseUpInside() {
         m_contextMenu->begin(0, true);
         {
             m_contextMenu->addButton("Use Mods", 1);  // for scores without mods this will just nomod
+
+            if(m_score.has_replay) {
+                m_contextMenu->addButton("View replay", 2);
+            }
+
             CBaseUIButton *spacer = m_contextMenu->addButton("---");
             spacer->setEnabled(false);
             spacer->setTextColor(0xff888888);
             spacer->setTextDarkColor(0xff000000);
-            CBaseUIButton *deleteButton = m_contextMenu->addButton("Delete Score", 2);
+            CBaseUIButton *deleteButton = m_contextMenu->addButton("Delete Score", 3);
             if(m_score.isLegacyScore) {
                 deleteButton->setEnabled(false);
                 deleteButton->setTextColor(0xff888888);
@@ -551,12 +556,23 @@ void OsuUISongBrowserScoreButton::onRightMouseUpInside() {
 }
 
 void OsuUISongBrowserScoreButton::onContextMenu(UString text, int id) {
-    if(id == 1) onUseModsClicked();
+    if(id == 1) {
+        onUseModsClicked();
+        return;
+    }
+
     if(id == 2) {
+        // TODO @kiwec: view replay
+        return;
+    }
+
+    if(id == 3) {
         if(engine->getKeyboard()->isShiftDown())
             onDeleteScoreConfirmed(text, 1);
         else
             onDeleteScoreClicked();
+
+        return;
     }
 }