Browse Source

Add ability to get spectated

Clément Wolf 4 months ago
parent
commit
0de23ce367

+ 18 - 8
src/App/Osu/Bancho.cpp

@@ -312,15 +312,29 @@ void handle_packet(Packet *packet) {
         }
     } else if(packet->id == SPECTATOR_JOINED) {
         i32 spectator_id = read<u32>(packet);
+        bancho.spectators.push_back(spectator_id);
         debugLog("Spectator joined: user id %d\n", spectator_id);
     } else if(packet->id == SPECTATOR_LEFT) {
         i32 spectator_id = read<u32>(packet);
+        auto it = std::find(bancho.spectators.begin(), bancho.spectators.end(), spectator_id);
+        if(it != bancho.spectators.end()) {
+            bancho.spectators.erase(it);
+        }
         debugLog("Spectator left: user id %d\n", spectator_id);
-    } else if(packet->id == VERSION_UPDATE) {
-        // (nothing to do)
     } else if(packet->id == SPECTATOR_CANT_SPECTATE) {
         i32 spectator_id = read<u32>(packet);
         debugLog("Spectator can't spectate: user id %d\n", spectator_id);
+    } else if(packet->id == FELLOW_SPECTATOR_JOINED) {
+        i32 spectator_id = read<u32>(packet);
+        bancho.fellow_spectators.push_back(spectator_id);
+        debugLog("Fellow spectator joined: user id %d\n", spectator_id);
+    } else if(packet->id == FELLOW_SPECTATOR_LEFT) {
+        i32 spectator_id = read<u32>(packet);
+        auto it = std::find(bancho.fellow_spectators.begin(), bancho.fellow_spectators.end(), spectator_id);
+        if(it != bancho.fellow_spectators.end()) {
+            bancho.fellow_spectators.erase(it);
+        }
+        debugLog("Fellow spectator left: user id %d\n", spectator_id);
     } else if(packet->id == GET_ATTENTION) {
         // (nothing to do)
     } else if(packet->id == NOTIFICATION) {
@@ -349,12 +363,6 @@ void handle_packet(Packet *packet) {
     } else if(packet->id == ROOM_JOIN_FAIL) {
         osu->getNotificationOverlay()->addNotification("Failed to join room.");
         osu->m_lobby->on_room_join_failed();
-    } else if(packet->id == FELLOW_SPECTATOR_JOINED) {
-        u32 spectator_id = read<u32>(packet);
-        (void)spectator_id;  // (spectating not implemented; nothing to do)
-    } else if(packet->id == FELLOW_SPECTATOR_LEFT) {
-        u32 spectator_id = read<u32>(packet);
-        (void)spectator_id;  // (spectating not implemented; nothing to do)
     } else if(packet->id == MATCH_STARTED) {
         auto room = Room(packet);
         osu->m_room->on_match_started(room);
@@ -492,6 +500,8 @@ void handle_packet(Packet *packet) {
         UString blocked = read_string(packet);
         read<u32>(packet);
         debugLog("Silenced %s.\n", blocked.toUtf8());
+    } else if(packet->id == VERSION_UPDATE) {
+        // (nothing to do)
     } else if(packet->id == VERSION_UPDATE_FORCED) {
         disconnect();
         osu->getNotificationOverlay()->addNotification("This server requires a newer client version.");

+ 2 - 0
src/App/Osu/Bancho.h

@@ -20,6 +20,8 @@ struct Bancho {
     UString username;
     MD5Hash pw_md5;
     Room room;
+    std::vector<u32> spectators;
+    std::vector<u32> fellow_spectators;
 
     UString server_icon_url;
     Image *server_icon = nullptr;

+ 3 - 0
src/App/Osu/BanchoNetworking.cpp

@@ -90,7 +90,10 @@ void disconnect() {
     auth_header = "";
     free(outgoing.memory);
     outgoing = Packet();
+
     bancho.user_id = 0;
+    bancho.spectators.clear();
+    bancho.fellow_spectators.clear();
     bancho.server_icon_url = "";
     if(bancho.server_icon != nullptr) {
         engine->getResourceManager()->destroyResource(bancho.server_icon);

+ 34 - 0
src/App/Osu/BanchoProtocol.cpp

@@ -4,6 +4,8 @@
 #include <string.h>
 
 #include "Bancho.h"
+#include "Beatmap.h"
+#include "Osu.h"
 
 // Null array for returning empty structures when trying to read more data out of a Packet than expected.
 u8 NULL_ARRAY[NULL_ARRAY_SIZE] = {0};
@@ -231,3 +233,35 @@ void write_hash(Packet *packet, MD5Hash hash) {
     write<u8>(packet, 0x20);
     write_bytes(packet, (u8 *)hash.hash, 32);
 }
+
+ScoreFrame ScoreFrame::get() {
+    u8 slot_id = 0;
+    for(u8 i = 0; i < 16; i++) {
+        if(bancho.room.slots[i].player_id == bancho.user_id) {
+            slot_id = i;
+            break;
+        }
+    }
+
+    auto score = osu->getScore();
+    auto perfect = (score->getNumSliderBreaks() == 0 && score->getNumMisses() == 0 && score->getNum50s() == 0 &&
+                    score->getNum100s() == 0);
+
+    return ScoreFrame{
+        .time = (i32)osu->getSelectedBeatmap()->getCurMusicPos(),  // NOTE: might be incorrect
+        .slot_id = slot_id,
+        .num300 = (u16)score->getNum300s(),
+        .num100 = (u16)score->getNum100s(),
+        .num50 = (u16)score->getNum50s(),
+        .num_geki = (u16)score->getNum300gs(),
+        .num_katu = (u16)score->getNum100ks(),
+        .num_miss = (u16)score->getNumMisses(),
+        .total_score = (i32)score->getScore(),
+        .max_combo = (u16)score->getComboMax(),
+        .current_combo = (u16)score->getCombo(),
+        .is_perfect = perfect,
+        .current_hp = (u8)(osu->getSelectedBeatmap()->getHealth() * 200.0),
+        .tag = 0,         // tag gamemode currently not supported
+        .is_scorev2 = 0,  // scorev2 currently not supported
+    };
+}

+ 52 - 0
src/App/Osu/BanchoProtocol.h

@@ -248,6 +248,58 @@ class Room {
     void pack(Packet *packet);
 };
 
+#pragma pack(push, 1)
+struct ScoreFrame {
+    i32 time;
+    u8 slot_id;
+    u16 num300;
+    u16 num100;
+    u16 num50;
+    u16 num_geki;
+    u16 num_katu;
+    u16 num_miss;
+    i32 total_score;
+    u16 max_combo;
+    u16 current_combo;
+    u8 is_perfect;
+    u8 current_hp;
+    u8 tag;
+    u8 is_scorev2;
+
+    static ScoreFrame get();
+};
+#pragma pack(pop)
+
+#pragma pack(push, 1)
+struct LiveReplayFrame {
+    u8 key_flags;
+    u8 padding;  // was used in very old versions of the game
+    f32 mouse_x;
+    f32 mouse_y;
+    i32 time;
+};
+#pragma pack(pop)
+
+struct LiveReplayBundle {
+    enum Action {
+        NONE = 0,
+        NEW_SONG = 1,
+        SKIP = 2,
+        COMPLETION = 3,
+        FAIL = 4,
+        PAUSE = 5,
+        UNPAUSE = 6,
+        SONG_SELECT = 7,
+        WATCHING_OTHER = 8,
+    };
+
+    Action action;
+    u16 nb_frames;
+    LiveReplayFrame *frames;
+    ScoreFrame score;
+    u16 sequence;
+};
+
 void read_bytes(Packet *packet, u8 *bytes, size_t n);
 u32 read_uleb128(Packet *packet);
 UString read_string(Packet *packet);

+ 114 - 23
src/App/Osu/Beatmap.cpp

@@ -571,6 +571,21 @@ void Beatmap::skipEmptySection() {
         m_music->setPositionMS(std::max(m_iNextHitObjectTime - (long)(offset * offsetMultiplier), (long)0));
 
     engine->getSound()->play(osu->getSkin()->getMenuHit());
+
+    if(!bancho.spectators.empty()) {
+        // TODO @kiwec: how does skip work? does client jump to latest time or just skip beginning?
+        //              is signaling skip even necessary?
+        broadcast_spectator_frames();
+
+        Packet packet;
+        packet.id = SPECTATE_FRAMES;
+        write<i32>(&packet, 0);
+        write<u16>(&packet, 0);
+        write<u8>(&packet, LiveReplayBundle::Action::SKIP);
+        write<ScoreFrame>(&packet, ScoreFrame::get());
+        write<u16>(&packet, spectator_sequence++);
+        send_packet(packet);
+    }
 }
 
 void Beatmap::keyPressed1(bool mouse) {
@@ -695,31 +710,54 @@ void Beatmap::deselect() {
     unloadObjects();
 }
 
+bool Beatmap::play() {
+    if(start()) {
+        m_bIsWatchingReplay = false;
+        m_bIsPlaying = true;
+        last_spectator_broadcast = engine->getTime();
+
+        RichPresence::onPlayStart();
+        if(!bancho.spectators.empty()) {
+            Packet packet;
+            packet.id = SPECTATE_FRAMES;
+            write<i32>(&packet, 0);
+            write<u16>(&packet, 0);  // 0 frames, we're just signaling map start
+            write<u8>(&packet, LiveReplayBundle::Action::NEW_SONG);
+            write<ScoreFrame>(&packet, ScoreFrame::get());
+            write<u16>(&packet, spectator_sequence++);
+            send_packet(packet);
+        }
+
+        return true;
+    }
+
+    return false;
+}
+
 bool Beatmap::watch(FinishedScore score, double start_percent) {
-    // Replay is invalid
     if(score.replay.size() < 3) {
+        // Replay is invalid
         return false;
     }
 
-    osu->replay_score = score;
-
     m_bIsPlaying = false;
     m_bIsPaused = false;
     m_bContinueScheduled = false;
     stopStarCacheLoader();
     unloadObjects();
 
+    osu->watched_user_name = score.playerName.c_str();
+    osu->watched_user_id = score.player_id;
     m_bIsWatchingReplay = true;
 
     osu->getModSelector()->resetMods();
     osu->useMods(&score);
 
-    // Map failed to load
-    if(!play()) {
+    if(!start()) {
+        // Map failed to load
         return false;
     }
 
-    m_bIsWatchingReplay = true;  // play() resets this to false
     spectated_replay = score.replay;
 
     osu->m_songBrowser2->m_bHasSelectedAndIsPlaying = true;
@@ -730,12 +768,10 @@ bool Beatmap::watch(FinishedScore score, double start_percent) {
         seekPercent(start_percent);
     }
 
-    osu->onPlayStart();
-
     return true;
 }
 
-bool Beatmap::play() {
+bool Beatmap::start() {
     if(m_selectedDifficulty2 == NULL) return false;
 
     engine->getSound()->play(osu->m_skin->getMenuHit());
@@ -891,8 +927,22 @@ bool Beatmap::play() {
     m_bIsWaiting = true;
     m_fWaitTime = engine->getTimeReal();
 
-    m_bIsPlaying = true;
-    m_bIsWatchingReplay = false;
+    osu->m_snd_change_check_interval_ref->setValue(0.0f);
+
+    if(osu->m_bModAuto || osu->m_bModAutopilot || m_bIsWatchingReplay) {
+        osu->m_bShouldCursorBeVisible = true;
+        env->setCursorVisible(osu->m_bShouldCursorBeVisible);
+    }
+
+    if(getSelectedDifficulty2()->getLocalOffset() != 0)
+        osu->m_notificationOverlay->addNotification(
+            UString::format("Using local beatmap offset (%ld ms)", getSelectedDifficulty2()->getLocalOffset()),
+            0xffffffff, false, 0.75f);
+
+    osu->m_fQuickSaveTime = 0.0f;  // reset
+
+    osu->updateConfineCursor();
+    osu->updateWindowsKeyDisable();
 
     // NOTE: loading failures are handled dynamically in update(), so temporarily assume everything has worked in here
     return true;
@@ -1499,18 +1549,14 @@ LiveScore::HIT Beatmap::addHitResult(HitObject *hitObject, LiveScore::HIT hit, l
                 addHealth(osu->getScore()->getHealthIncrease(this, returnedHit), true);
                 osu->getScore()->addHitResultComboEnd(returnedHit);
             } else if((comboEndBitmask & 2) == 0) {
-                switch(hit) {
-                    case LiveScore::HIT::HIT_100:
-                        returnedHit = LiveScore::HIT::HIT_100K;
-                        addHealth(osu->getScore()->getHealthIncrease(this, returnedHit), true);
-                        osu->getScore()->addHitResultComboEnd(returnedHit);
-                        break;
-
-                    case LiveScore::HIT::HIT_300:
-                        returnedHit = LiveScore::HIT::HIT_300K;
-                        addHealth(osu->getScore()->getHealthIncrease(this, returnedHit), true);
-                        osu->getScore()->addHitResultComboEnd(returnedHit);
-                        break;
+                if(hit == LiveScore::HIT::HIT_100) {
+                    returnedHit = LiveScore::HIT::HIT_100K;
+                    addHealth(osu->getScore()->getHealthIncrease(this, returnedHit), true);
+                    osu->getScore()->addHitResultComboEnd(returnedHit);
+                } else if(hit == LiveScore::HIT::HIT_300) {
+                    returnedHit = LiveScore::HIT::HIT_300K;
+                    addHealth(osu->getScore()->getHealthIncrease(this, returnedHit), true);
+                    osu->getScore()->addHitResultComboEnd(returnedHit);
                 }
             } else if(hit != LiveScore::HIT::HIT_MISS)
                 addHealth(osu->getScore()->getHealthIncrease(this, LiveScore::HIT::HIT_MU), true);
@@ -3137,6 +3183,25 @@ void Beatmap::update2() {
     }
 }
 
+void Beatmap::broadcast_spectator_frames() {
+    if(bancho.spectators.empty()) return;
+
+    Packet packet;
+    packet.id = SPECTATE_FRAMES;
+    write<i32>(&packet, 0);
+    write<u16>(&packet, frame_batch.size());
+    for(auto batch : frame_batch) {
+        write<LiveReplayFrame>(&packet, batch);
+    }
+    write<u8>(&packet, LiveReplayBundle::Action::NONE);
+    write<ScoreFrame>(&packet, ScoreFrame::get());
+    write<u16>(&packet, spectator_sequence++);
+    send_packet(packet);
+
+    frame_batch.clear();
+    last_spectator_broadcast = engine->getTime();
+}
+
 void Beatmap::write_frame() {
     if(!m_bIsPlaying || m_bFailed || m_bIsWatchingReplay) return;
 
@@ -3155,9 +3220,22 @@ void Beatmap::write_frame() {
         .y = pos.y,
         .key_flags = current_keys,
     });
+
+    frame_batch.push_back(LiveReplayFrame{
+        .key_flags = current_keys,
+        .padding = 0,
+        .mouse_x = pos.x,
+        .mouse_y = pos.y,
+        .time = (i32)m_iCurMusicPos,  // NOTE: might be incorrect
+    });
+
     last_event_time = m_fLastRealTimeForInterpolationDelta;
     last_event_ms = m_iCurMusicPosWithOffsets;
     last_keys = current_keys;
+
+    if(!bancho.spectators.empty() && engine->getTime() > last_spectator_broadcast + 1.0) {
+        broadcast_spectator_frames();
+    }
 }
 
 void Beatmap::onModUpdate(bool rebuildSliderVertexBuffers, bool recomputeDrainRate) {
@@ -3618,6 +3696,19 @@ void Beatmap::saveAndSubmitScore(bool quit) {
     osu->getScore()->setIndex(scoreIndex);
     osu->getScore()->setComboFull(maxPossibleCombo);  // used in RankingScreen/UIRankingScreenRankingPanel
 
+    if(!bancho.spectators.empty()) {
+        broadcast_spectator_frames();
+
+        Packet packet;
+        packet.id = SPECTATE_FRAMES;
+        write<i32>(&packet, 0);
+        write<u16>(&packet, 0);
+        write<u8>(&packet, isComplete ? LiveReplayBundle::Action::COMPLETION : LiveReplayBundle::Action::FAIL);
+        write<ScoreFrame>(&packet, ScoreFrame::get());
+        write<u16>(&packet, spectator_sequence++);
+        send_packet(packet);
+    }
+
     // special case: incomplete scores should NEVER show pp, even if auto
     if(!isComplete) {
         osu->getScore()->setPPv2(0.0f);

+ 9 - 1
src/App/Osu/Beatmap.h

@@ -108,8 +108,9 @@ class Beatmap {
                     // clicking on a beatmap)
     void selectDifficulty2(DatabaseBeatmap *difficulty2);
     void deselect();  // stops + unloads the currently loaded music and deletes all hitobjects
-    bool watch(FinishedScore score, double start_percent);
     bool play();
+    bool watch(FinishedScore score, double start_percent);
+    bool start();
     void restart(bool quick = false);
     void pause(bool quitIfWaiting = true);
     void pausePreviewMusic(bool toggle = true);
@@ -197,6 +198,13 @@ class Beatmap {
     bool m_bIsWatchingReplay = false;
     long current_frame_idx = 0;
 
+    // spectating
+    void broadcast_spectator_frames();
+    std::vector<LiveReplayFrame> frame_batch;
+    double last_spectator_broadcast = 0;
+    u16 spectator_sequence = 0;
+    bool is_spectating = false;
+
     // used by HitObject children and ModSelector
     Skin *getSkin() const;  // maybe use this for beatmap skins, maybe
     inline int getRandomSeed() const { return m_iRandomSeed; }

+ 1 - 0
src/App/Osu/Changelog.cpp

@@ -29,6 +29,7 @@ Changelog::Changelog() : ScreenBackable() {
     CHANGELOG latest;
     latest.title =
         UString::format("%.2f (%s, %s)", convar->getConVarByName("osu_version")->getFloat(), __DATE__, __TIME__);
+    latest.changes.push_back("- Added ability to get spectated");
     latest.changes.push_back("- Added option for servers to override convars using neosu.json");
     latest.changes.push_back("- Added option for servers to override convars using a custom bancho packet");
     latest.changes.push_back("- Hid password cvar from console command list");

+ 28 - 2
src/App/Osu/HUD.cpp

@@ -243,6 +243,8 @@ ConVar osu_combo_anim1_size("osu_combo_anim1_size", 0.15f, FCVAR_DEFAULT);
 ConVar osu_combo_anim2_duration("osu_combo_anim2_duration", 0.4f, FCVAR_DEFAULT);
 ConVar osu_combo_anim2_size("osu_combo_anim2_size", 0.5f, FCVAR_DEFAULT);
 
+ConVar draw_spectator_list("draw_spectator_list", true, FCVAR_DEFAULT);
+
 HUD::HUD() : OsuScreen() {
     // convar refs
     m_name_ref = convar->getConVarByName("name");
@@ -441,6 +443,30 @@ void HUD::draw(Graphics *g) {
        ((m_osu_skip_intro_enabled_ref->getBool() && beatmap->getHitObjectIndexForCurrentTime() < 1) ||
         (m_osu_skip_breaks_enabled_ref->getBool() && beatmap->getHitObjectIndexForCurrentTime() > 0)))
         drawSkip(g);
+
+    u32 nb_spectators = beatmap->is_spectating ? bancho.fellow_spectators.size() : bancho.spectators.size();
+    if(nb_spectators > 0 && draw_spectator_list.getBool()) {
+        // XXX: maybe draw player names? avatars?
+        const UString str = UString::format("%d spectators", nb_spectators);
+
+        g->pushTransform();
+        McFont *font = osu->getSongBrowserFont();
+        const float height = roundf(osu->getScreenHeight() * 0.07f);
+        const float scale = (height / font->getHeight()) * 0.315f;
+        g->scale(scale, scale);
+        g->translate(30.f * scale, osu->getScreenHeight() / 2.f - ((height * 2.5f) + font->getHeight() * scale));
+
+        if(convar->getConVarByName("osu_background_dim")->getFloat() < 0.7f) {
+            g->translate(1, 1);
+            g->setColor(0x66000000);
+            g->drawString(font, str);
+            g->translate(-1, -1);
+        }
+
+        g->setColor(0xffffffff);
+        g->drawString(font, str);
+        g->popTransform();
+    }
 }
 
 void HUD::mouse_update(bool *propagate_clicks) {
@@ -1491,8 +1517,8 @@ std::vector<SCORE_ENTRY> HUD::getCurrentScores() {
         if(osu->getModAuto() || (osu->getModAutopilot() && osu->getModRelax())) {
             playerScoreEntry.name = "neosu";
         } else if(beatmap->m_bIsWatchingReplay) {
-            playerScoreEntry.name = osu->replay_score.playerName.c_str();
-            playerScoreEntry.player_id = osu->replay_score.player_id;
+            playerScoreEntry.name = osu->watched_user_name;
+            playerScoreEntry.player_id = osu->watched_user_id;
         } else {
             playerScoreEntry.name = m_name_ref->getString();
             playerScoreEntry.player_id = bancho.user_id;

+ 11 - 0
src/App/Osu/MainMenu.cpp

@@ -1118,6 +1118,17 @@ CBaseUIContainer *MainMenu::setVisible(bool visible) {
     if(visible) {
         RichPresence::onMainMenu();
 
+        if(!bancho.spectators.empty()) {
+            Packet packet;
+            packet.id = SPECTATE_FRAMES;
+            write<i32>(&packet, 0);
+            write<u16>(&packet, 0);
+            write<u8>(&packet, LiveReplayBundle::Action::NONE);
+            write<ScoreFrame>(&packet, ScoreFrame::get());
+            write<u16>(&packet, osu->getSelectedBeatmap()->spectator_sequence++);
+            send_packet(packet);
+        }
+
         updateLayout();
 
         m_fMainMenuAnimDuration = 15.0f;

+ 29 - 27
src/App/Osu/ModSelector.cpp

@@ -292,85 +292,85 @@ void ModSelector::updateButtons(bool initial) {
     m_modButtonEasy = setModButtonOnGrid(
         0, 0, 0, initial && osu->getModEZ(), "ez",
         "Reduces overall difficulty - larger circles, more forgiving HP drain, less accuracy required.",
-        [this]() -> SkinImage * { return osu->getSkin()->getSelectionModEasy(); });
+        []() -> SkinImage * { return osu->getSkin()->getSelectionModEasy(); });
     m_modButtonNofail =
         setModButtonOnGrid(1, 0, 0, initial && osu->getModNF(), "nf",
                            "You can't fail. No matter what.\nNOTE: To disable drain completely:\nOptions > Gameplay > "
                            "Mechanics > \"Select HP Drain\" > \"None\".",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModNoFail(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModNoFail(); });
     m_modButtonNofail->setAvailable(m_osu_drain_type_ref->getInt() > 1);
     setModButtonOnGrid(4, 0, 0, initial && osu->getModNightmare(), "nightmare",
                        "Unnecessary clicks count as misses.\nMassively reduced slider follow circle radius.",
-                       [this]() -> SkinImage * { return osu->getSkin()->getSelectionModNightmare(); });
+                       []() -> SkinImage * { return osu->getSkin()->getSelectionModNightmare(); });
 
     m_modButtonHardrock =
         setModButtonOnGrid(0, 1, 0, initial && osu->getModHR(), "hr", "Everything just got a bit harder...",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModHardRock(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModHardRock(); });
     m_modButtonSuddendeath =
         setModButtonOnGrid(1, 1, 0, initial && osu->getModSD(), "sd", "Miss a note and fail.",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModSuddenDeath(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModSuddenDeath(); });
     setModButtonOnGrid(1, 1, 1, initial && osu->getModSS(), "ss", "SS or quit.",
-                       [this]() -> SkinImage * { return osu->getSkin()->getSelectionModPerfect(); });
+                       []() -> SkinImage * { return osu->getSkin()->getSelectionModPerfect(); });
 
     if(convar->getConVarByName("nightcore_enjoyer")->getBool()) {
         m_modButtonHalftime =
             setModButtonOnGrid(2, 0, 0, initial && osu->getModDC(), "dc", "A E S T H E T I C",
-                               [this]() -> SkinImage * { return osu->getSkin()->getSelectionModDayCore(); });
+                               []() -> SkinImage * { return osu->getSkin()->getSelectionModDayCore(); });
         setModButtonOnGrid(2, 0, 1, initial && osu->getModHT(), "ht", "Less zoom.",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModHalfTime(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModHalfTime(); });
 
         m_modButtonDoubletime =
             setModButtonOnGrid(2, 1, 0, initial && osu->getModNC(), "nc", "uguuuuuuuu",
-                               [this]() -> SkinImage * { return osu->getSkin()->getSelectionModNightCore(); });
+                               []() -> SkinImage * { return osu->getSkin()->getSelectionModNightCore(); });
         setModButtonOnGrid(2, 1, 1, initial && osu->getModDT(), "dt", "Zoooooooooom.",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModDoubleTime(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModDoubleTime(); });
     } else {
         m_modButtonHalftime =
             setModButtonOnGrid(2, 0, 0, initial && osu->getModHT(), "ht", "Less zoom.",
-                               [this]() -> SkinImage * { return osu->getSkin()->getSelectionModHalfTime(); });
+                               []() -> SkinImage * { return osu->getSkin()->getSelectionModHalfTime(); });
         setModButtonOnGrid(2, 0, 1, initial && osu->getModDC(), "dc", "A E S T H E T I C",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModDayCore(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModDayCore(); });
 
         m_modButtonDoubletime =
             setModButtonOnGrid(2, 1, 0, initial && osu->getModDT(), "dt", "Zoooooooooom.",
-                               [this]() -> SkinImage * { return osu->getSkin()->getSelectionModDoubleTime(); });
+                               []() -> SkinImage * { return osu->getSkin()->getSelectionModDoubleTime(); });
         setModButtonOnGrid(2, 1, 1, initial && osu->getModNC(), "nc", "uguuuuuuuu",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModNightCore(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModNightCore(); });
     }
 
     m_modButtonHidden =
         setModButtonOnGrid(3, 1, 0, initial && osu->getModHD(), "hd",
                            "Play with no approach circles and fading notes for a slight score advantage.",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModHidden(); });
-    m_modButtonFlashlight = setModButtonOnGrid(4, 1, 0, false, "fl", "Restricted view area.", [this]() -> SkinImage * {
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModHidden(); });
+    m_modButtonFlashlight = setModButtonOnGrid(4, 1, 0, false, "fl", "Restricted view area.", []() -> SkinImage * {
         return osu->getSkin()->getSelectionModFlashlight();
     });
     m_modButtonTD = setModButtonOnGrid(5, 1, 0, initial && (osu->getModTD() || m_osu_mod_touchdevice_ref->getBool()),
                                        "nerftd", "Simulate pp nerf for touch devices.\nOnly affects pp calculation.",
-                                       [this]() -> SkinImage * { return osu->getSkin()->getSelectionModTD(); });
+                                       []() -> SkinImage * { return osu->getSkin()->getSelectionModTD(); });
     getModButtonOnGrid(5, 1)->setAvailable(!m_osu_mod_touchdevice_ref->getBool());
 
     m_modButtonRelax = setModButtonOnGrid(
         0, 2, 0, initial && osu->getModRelax(), "relax",
         "You don't need to click.\nGive your clicking/tapping fingers a break from the heat of things.\n** UNRANKED **",
-        [this]() -> SkinImage * { return osu->getSkin()->getSelectionModRelax(); });
+        []() -> SkinImage * { return osu->getSkin()->getSelectionModRelax(); });
     m_modButtonAutopilot =
         setModButtonOnGrid(1, 2, 0, initial && osu->getModAutopilot(), "autopilot",
                            "Automatic cursor movement - just follow the rhythm.\n** UNRANKED **",
-                           [this]() -> SkinImage * { return osu->getSkin()->getSelectionModAutopilot(); });
-    m_modButtonSpunout = setModButtonOnGrid(
-        2, 2, 0, initial && osu->getModSpunout(), "spunout", "Spinners will be automatically completed.",
-        [this]() -> SkinImage * { return osu->getSkin()->getSelectionModSpunOut(); });
+                           []() -> SkinImage * { return osu->getSkin()->getSelectionModAutopilot(); });
+    m_modButtonSpunout = setModButtonOnGrid(2, 2, 0, initial && osu->getModSpunout(), "spunout",
+                                            "Spinners will be automatically completed.",
+                                            []() -> SkinImage * { return osu->getSkin()->getSelectionModSpunOut(); });
     m_modButtonAuto = setModButtonOnGrid(3, 2, 0, initial && osu->getModAuto(), "auto",
                                          "Watch a perfect automated play through the song.",
-                                         [this]() -> SkinImage * { return osu->getSkin()->getSelectionModAutoplay(); });
+                                         []() -> SkinImage * { return osu->getSkin()->getSelectionModAutoplay(); });
     setModButtonOnGrid(4, 2, 0, initial && osu->getModTarget(), "practicetarget",
                        "Accuracy is based on the distance to the center of all hitobjects.\n300s still require at "
                        "least being in the hit window of a 100 in addition to the rule above.",
-                       [this]() -> SkinImage * { return osu->getSkin()->getSelectionModTarget(); });
-    m_modButtonScoreV2 = setModButtonOnGrid(
-        5, 2, 0, initial && osu->getModScorev2(), "v2", "Try the future scoring system.\n** UNRANKED **",
-        [this]() -> SkinImage * { return osu->getSkin()->getSelectionModScorev2(); });
+                       []() -> SkinImage * { return osu->getSkin()->getSelectionModTarget(); });
+    m_modButtonScoreV2 = setModButtonOnGrid(5, 2, 0, initial && osu->getModScorev2(), "v2",
+                                            "Try the future scoring system.\n** UNRANKED **",
+                                            []() -> SkinImage * { return osu->getSkin()->getSelectionModScorev2(); });
 
     // Enable all mods that we disable conditionally below
     getModButtonOnGrid(2, 0)->setAvailable(true);
@@ -1233,6 +1233,8 @@ u32 ModSelector::getModFlags() {
     if(m_modButtonSpunout->isOn()) flags |= ModFlags::SpunOut;
     if(m_modButtonAutopilot->isOn()) flags |= ModFlags::Autopilot;
     if(getModButtonOnGrid(4, 2)->isOn()) flags |= ModFlags::Target;
+    if(m_modButtonAuto->isOn()) flags |= ModFlags::Autoplay;
+    if(m_modButtonFlashlight) flags |= ModFlags::Flashlight;
 
     return flags;
 }

+ 0 - 22
src/App/Osu/Osu.cpp

@@ -1634,28 +1634,6 @@ void Osu::saveScreenshot() {
                        screenshot_path);
 }
 
-void Osu::onPlayStart() {
-    m_snd_change_check_interval_ref->setValue(0.0f);
-
-    if(m_bModAuto || m_bModAutopilot || getSelectedBeatmap()->m_bIsWatchingReplay) {
-        m_bShouldCursorBeVisible = true;
-        env->setCursorVisible(m_bShouldCursorBeVisible);
-    }
-
-    if(getSelectedBeatmap()->getSelectedDifficulty2()->getLocalOffset() != 0)
-        m_notificationOverlay->addNotification(
-            UString::format("Using local beatmap offset (%ld ms)",
-                            getSelectedBeatmap()->getSelectedDifficulty2()->getLocalOffset()),
-            0xffffffff, false, 0.75f);
-
-    m_fQuickSaveTime = 0.0f;  // reset
-
-    updateConfineCursor();
-    updateWindowsKeyDisable();
-
-    RichPresence::onPlayStart();
-}
-
 void Osu::onPlayEnd(bool quit, bool aborted) {
     m_snd_change_check_interval_ref->setValue(m_snd_change_check_interval_ref->getDefaultFloat());
 

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

@@ -81,7 +81,6 @@ class Osu : public App, public MouseListener {
     virtual void onMinimized();
     virtual bool onShutdown();
 
-    void onPlayStart();  // called when a beatmap has successfully started playing
     void onPlayEnd(bool quit = true,
                    bool aborted = false);  // called when a beatmap is finished playing (or the player quit)
 
@@ -345,7 +344,8 @@ class Osu : public App, public MouseListener {
     CWindowManager *m_windowManager;
 
     // replay
-    FinishedScore replay_score;
+    UString watched_user_name;
+    u32 watched_user_id = 0;
 
     // custom
     bool m_bScheduleEndlessModNextBeatmap;

+ 26 - 10
src/App/Osu/PauseMenu.cpp

@@ -1,13 +1,7 @@
-//================ Copyright (c) 2016, PG, All rights reserved. =================//
-//
-// Purpose:		pause menu (while playing)
-//
-// $NoKeywords: $
-//===============================================================================//
-
 #include "PauseMenu.h"
 
 #include "AnimationHandler.h"
+#include "Bancho.h"
 #include "Beatmap.h"
 #include "CBaseUIContainer.h"
 #include "Chat.h"
@@ -50,9 +44,9 @@ PauseMenu::PauseMenu() : OsuScreen() {
 
     setSize(osu->getScreenWidth(), osu->getScreenHeight());
 
-    UIPauseMenuButton *continueButton = addButton([this]() -> Image * { return osu->getSkin()->getPauseContinue(); });
-    UIPauseMenuButton *retryButton = addButton([this]() -> Image * { return osu->getSkin()->getPauseRetry(); });
-    UIPauseMenuButton *backButton = addButton([this]() -> Image * { return osu->getSkin()->getPauseBack(); });
+    UIPauseMenuButton *continueButton = addButton([]() -> Image * { return osu->getSkin()->getPauseContinue(); });
+    UIPauseMenuButton *retryButton = addButton([]() -> Image * { return osu->getSkin()->getPauseRetry(); });
+    UIPauseMenuButton *backButton = addButton([]() -> Image * { return osu->getSkin()->getPauseBack(); });
 
     continueButton->setClickCallback(fastdelegate::MakeDelegate(this, &PauseMenu::onContinueClicked));
     retryButton->setClickCallback(fastdelegate::MakeDelegate(this, &PauseMenu::onRetryClicked));
@@ -352,11 +346,33 @@ CBaseUIContainer *PauseMenu::setVisible(bool visible) {
         if(m_bContinueEnabled) {
             RichPresence::setStatus("Paused");
             RichPresence::setBanchoStatus("Taking a break", PAUSED);
+
+            if(!bancho.spectators.empty()) {
+                Packet packet;
+                packet.id = SPECTATE_FRAMES;
+                write<i32>(&packet, 0);
+                write<u16>(&packet, 0);
+                write<u8>(&packet, LiveReplayBundle::Action::PAUSE);
+                write<ScoreFrame>(&packet, ScoreFrame::get());
+                write<u16>(&packet, osu->getSelectedBeatmap()->spectator_sequence++);
+                send_packet(packet);
+            }
         } else {
             RichPresence::setBanchoStatus("Failed", SUBMITTING);
         }
     } else {
         RichPresence::onPlayStart();
+
+        if(!bancho.spectators.empty()) {
+            Packet packet;
+            packet.id = SPECTATE_FRAMES;
+            write<i32>(&packet, 0);
+            write<u16>(&packet, 0);
+            write<u8>(&packet, LiveReplayBundle::Action::UNPAUSE);
+            write<ScoreFrame>(&packet, ScoreFrame::get());
+            write<u16>(&packet, osu->getSelectedBeatmap()->spectator_sequence++);
+            send_packet(packet);
+        }
     }
 
     // HACKHACK: force disable mod selection screen in case it was open and the beatmap ended/failed

+ 17 - 19
src/App/Osu/RoomScreen.cpp

@@ -710,7 +710,6 @@ void RoomScreen::on_match_started(Room room) {
         m_bVisible = false;
         bancho.match_started = true;
         osu->m_songBrowser2->m_bHasSelectedAndIsPlaying = true;
-        osu->onPlayStart();
         osu->m_chat->updateVisibility();
     } else {
         ragequit();  // map failed to load
@@ -718,24 +717,23 @@ void RoomScreen::on_match_started(Room room) {
 }
 
 void RoomScreen::on_match_score_updated(Packet *packet) {
-    i32 update_tms = read<u32>(packet);
-    u8 slot_id = read<u8>(packet);
-    if(slot_id > 15) return;
-
-    auto slot = &bancho.room.slots[slot_id];
-    slot->last_update_tms = update_tms;
-    slot->num300 = read<u16>(packet);
-    slot->num100 = read<u16>(packet);
-    slot->num50 = read<u16>(packet);
-    slot->num_geki = read<u16>(packet);
-    slot->num_katu = read<u16>(packet);
-    slot->num_miss = read<u16>(packet);
-    slot->total_score = read<u32>(packet);
-    slot->max_combo = read<u16>(packet);
-    slot->current_combo = read<u16>(packet);
-    slot->is_perfect = read<u8>(packet);
-    slot->current_hp = read<u8>(packet);
-    slot->tag = read<u8>(packet);
+    auto frame = read<ScoreFrame>(packet);
+    if(frame.slot_id > 15) return;
+
+    auto slot = &bancho.room.slots[frame.slot_id];
+    slot->last_update_tms = frame.time;
+    slot->num300 = frame.num300;
+    slot->num100 = frame.num100;
+    slot->num50 = frame.num50;
+    slot->num_geki = frame.num_geki;
+    slot->num_katu = frame.num_katu;
+    slot->num_miss = frame.num_miss;
+    slot->total_score = frame.total_score;
+    slot->max_combo = frame.max_combo;
+    slot->current_combo = frame.current_combo;
+    slot->is_perfect = frame.is_perfect;
+    slot->current_hp = frame.current_hp;
+    slot->tag = frame.tag;
 
     bool is_scorev2 = read<u8>(packet);
     if(is_scorev2) {

+ 9 - 10
src/App/Osu/SongBrowser/SongBrowser.cpp

@@ -464,17 +464,17 @@ SongBrowser::SongBrowser() : ScreenBackable() {
     // build bottombar
     m_bottombar = new CBaseUIContainer(0, 0, 0, 0, "");
 
-    addBottombarNavButton([this]() -> SkinImage * { return osu->getSkin()->getSelectionMode(); },
-                          [this]() -> SkinImage * { return osu->getSkin()->getSelectionModeOver(); })
+    addBottombarNavButton([]() -> SkinImage * { return osu->getSkin()->getSelectionMode(); },
+                          []() -> SkinImage * { return osu->getSkin()->getSelectionModeOver(); })
         ->setClickCallback(fastdelegate::MakeDelegate(this, &SongBrowser::onSelectionMode));
-    addBottombarNavButton([this]() -> SkinImage * { return osu->getSkin()->getSelectionMods(); },
-                          [this]() -> SkinImage * { return osu->getSkin()->getSelectionModsOver(); })
+    addBottombarNavButton([]() -> SkinImage * { return osu->getSkin()->getSelectionMods(); },
+                          []() -> SkinImage * { return osu->getSkin()->getSelectionModsOver(); })
         ->setClickCallback(fastdelegate::MakeDelegate(this, &SongBrowser::onSelectionMods));
-    addBottombarNavButton([this]() -> SkinImage * { return osu->getSkin()->getSelectionRandom(); },
-                          [this]() -> SkinImage * { return osu->getSkin()->getSelectionRandomOver(); })
+    addBottombarNavButton([]() -> SkinImage * { return osu->getSkin()->getSelectionRandom(); },
+                          []() -> SkinImage * { return osu->getSkin()->getSelectionRandomOver(); })
         ->setClickCallback(fastdelegate::MakeDelegate(this, &SongBrowser::onSelectionRandom));
-    addBottombarNavButton([this]() -> SkinImage * { return osu->getSkin()->getSelectionOptions(); },
-                          [this]() -> SkinImage * { return osu->getSkin()->getSelectionOptionsOver(); })
+    addBottombarNavButton([]() -> SkinImage * { return osu->getSkin()->getSelectionOptions(); },
+                          []() -> SkinImage * { return osu->getSkin()->getSelectionOptionsOver(); })
         ->setClickCallback(fastdelegate::MakeDelegate(this, &SongBrowser::onSelectionOptions));
 
     m_userButton = new UserButton();
@@ -597,6 +597,7 @@ SongBrowser::~SongBrowser() {
         delete m_sortingMethods[i].comparator;
     }
 
+    SAFE_DELETE(m_selectedBeatmap);
     SAFE_DELETE(m_search);
     SAFE_DELETE(m_topbarLeft);
     SAFE_DELETE(m_topbarRight);
@@ -1619,8 +1620,6 @@ void SongBrowser::onDifficultySelected(DatabaseBeatmap *diff2, bool play) {
             if(m_selectedBeatmap->play()) {
                 m_bHasSelectedAndIsPlaying = true;
                 setVisible(false);
-
-                osu->onPlayStart();
             }
         }
     }