diff --git a/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.cpp b/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.cpp index 4e7d656550e7..d592530d962b 100644 --- a/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.cpp +++ b/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.cpp @@ -1193,6 +1193,9 @@ void CEXISlippi::handleOnlineInputs(u8* payload) stall_frame_counts[i] = 0; } + last_interval_time_us = 0; + perf_debt = 0; + // Reset skip variables frames_to_skip = 0; is_currently_skipping = false; @@ -1211,6 +1214,19 @@ void CEXISlippi::handleOnlineInputs(u8* payload) if (isDisconnected()) { + // Both clients reach this path when a poor-performance termination fires: the initiating + // side set the reason locally, the other side received it over the disconnect. Show the + // message on both ends rather than only where the debt happened to cross the threshold first. + if (slippi_netplay->GetDisconnectReason() == + SlippiNetplayClient::SlippiDisconnectReason::POOR_PERFORMANCE) + { + OSD::AddTypedMessage( + OSD::MessageType::PoorPerformanceTermination, + "The match has been terminated due to poor network quality.\nIf you see this message in " + "most of your matches, you probably shouldn't be playing ranked.", + 15000, OSD::Color::RED); + } + m_read_queue.push_back(3); // Indicate we disconnected return; } @@ -1226,6 +1242,9 @@ void CEXISlippi::handleOnlineInputs(u8* payload) } else { + // Consider disconnecting from a match if performance is poor + handlePoorMatchPerformance(frame); + // Send the input for this frame along with everything that has yet to be acked handleSendInputs(frame, delay, finalized_frame, finalized_frame_checksum, inputs); } @@ -1233,6 +1252,68 @@ void CEXISlippi::handleOnlineInputs(u8* payload) prepareOpponentInputs(frame, should_skip); } +void CEXISlippi::handlePoorMatchPerformance(s32 frame) +{ + // Only handle poor match performance in ranked. In other modes players can just manually leave. + // Plus ranked is mostly where it matters + if (last_search.mode != SlippiMatchmaking::OnlinePlayMode::RANKED) + return; + + u64 interval_frames = 150; // Check every 2.5 seconds + u64 frame_time_us = 16683; + + // Skip the first 50 frames of the game and check timing info every interval + if ((frame + (interval_frames - 50)) % interval_frames != 0) + return; + + auto cur_time_us = Common::Timer::NowUs(); + + // Iniitalize the first instance + if (last_interval_time_us == 0) + { + last_interval_time_us = cur_time_us; + return; // We will start processing the next time + } + + auto expected_time_us = frame_time_us * interval_frames; + double ratio = static_cast(cur_time_us - last_interval_time_us) / + static_cast(expected_time_us); + last_interval_time_us = cur_time_us; + + // Leaky accumulator: each interval adds "debt" proportional to how far over the expected + // duration we ran, and healthy intervals pay it back down. We only terminate once enough debt + // builds up, so a single hitch (local or otherwise) is survivable but sustained degradation + // isn't. The decay keeps this independent of match length: scattered blips drain away before + // they can accumulate to the termination threshold. + s32 terminate_threshold = 30; + s32 debt; + if (ratio >= 1.75) + debt = 15; // Severe + else if (ratio >= 1.50) + debt = 8; // Bad + else if (ratio >= 1.10) + debt = 4; // Mild + else + debt = -1; // Healthy interval, pay down accumulated debt + + perf_debt = std::max(0, perf_debt + debt); + INFO_LOG_FMT(SLIPPI_ONLINE, "Modifying performance debt by {}. Currently at: {}/{}", debt, + perf_debt, terminate_threshold); + if (perf_debt >= terminate_threshold) + { + // Tell the server about the poor performance, then drop all remote players with a + // POOR_PERFORMANCE reason. Flipping the connection to DISCONNECTED lets the existing + // disconnect-detection path end the game naturally (next handleOnlineInputs sees + // isDisconnected()), same as a real disconnect. The reason rides the disconnect to the + // peer so both clients surface the OSD message from the shared disconnect path below. + slprs_exi_device_report_match_status(slprs_exi_device_ptr, recent_mm_result.id.c_str(), + "poor_performance", true); + slippi_netplay->ForceDisconnect(SlippiNetplayClient::SlippiDisconnectReason::POOR_PERFORMANCE); + ERROR_LOG_FMT(SLIPPI_ONLINE, "Match terminated due to poor performance. {}/{}", perf_debt, + terminate_threshold); + } +} + bool CEXISlippi::shouldSkipOnlineFrame(s32 frame, s32 finalized_frame) { auto status = slippi_netplay->GetSlippiConnectStatus(); @@ -1421,11 +1502,14 @@ bool CEXISlippi::shouldAdvanceOnlineFrame(s32 frame) bool is_slow = (offset_us < -t1 && fall_behind_counter > 50) || (offset_us < -t2 && fall_far_behind_counter > 15); - if (is_slow && matchmaking->RemotePlayerCount() == 1) + if (is_slow && matchmaking->RemotePlayerCount() == 1 && + last_search.mode != SlippiMatchmaking::OnlinePlayMode::RANKED) { // Only show this in 1v1. With more peers, CalcTimeOffsetUs returns the min across peers, // which biases negative as the peer count grows and false-positives this warning. The - // message text ("if this appears with most opponents") also only makes sense in 1v1. + // message text ("if this appears with most opponents") also only makes sense in 1v1. Don't + // show in ranked because we have the poor match logic running there which has its own + // message. OSD::AddTypedMessage( OSD::MessageType::PerformanceWarning, "Possible poor match performance detected.\nIf this message appears with most opponents, " diff --git a/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.h b/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.h index 9fb9fa397a31..a5b0afb3862a 100644 --- a/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.h +++ b/Source/Core/Core/HW/EXI/EXI_DeviceSlippi.h @@ -240,6 +240,7 @@ class CEXISlippi : public IEXIDevice void prepareOnlineMatchState(); void setMatchSelections(u8* payload); bool shouldSkipOnlineFrame(s32 frame, s32 finalized_frame); + void handlePoorMatchPerformance(s32 frame); bool shouldAdvanceOnlineFrame(s32 frame); bool opponentRunahead(); void handleLogInRequest(); @@ -291,6 +292,9 @@ class CEXISlippi : public IEXIDevice std::vector gecko_list; u32 stall_frame_counts[SLIPPI_REMOTE_PLAYER_MAX] = {}; + u64 last_interval_time_us = 0; + // Leaky accumulator of poor-performance intervals (see handlePoorMatchPerformance) + s32 perf_debt = 0; std::vector m_read_queue; std::unique_ptr m_current_game = nullptr; diff --git a/Source/Core/Core/Slippi/SlippiNetplay.cpp b/Source/Core/Core/Slippi/SlippiNetplay.cpp index 3e036f71aedb..0e6c03c75b55 100644 --- a/Source/Core/Core/Slippi/SlippiNetplay.cpp +++ b/Source/Core/Core/Slippi/SlippiNetplay.cpp @@ -729,8 +729,14 @@ void SlippiNetplayClient::Disconnect() { for (auto peer : conn.second) { - INFO_LOG_FMT(SLIPPI_ONLINE, "[Netplay] Disconnecting peer {}", peer.first->address.port); - enet_peer_disconnect(peer.first, 0); + // Carry the pending reason (default 0 = unspecified) so an intentional teardown such as a + // poor-performance termination reaches the peer. In 1v1, ForceDisconnect flips the status to + // DISCONNECTED before the network loop's force-kick runs, so this teardown call is the one + // that actually reaches the peer — it must forward the reason rather than send 0. + u32 reason = m_pending_disconnect_reason.load(std::memory_order_acquire); + INFO_LOG_FMT(SLIPPI_ONLINE, "[Netplay] Disconnecting peer {} with reason {}", + peer.first->address.port, reason); + enet_peer_disconnect(peer.first, reason); } } @@ -1045,7 +1051,8 @@ void SlippiNetplayClient::ThreadFunc() INFO_LOG_FMT(SLIPPI_ONLINE, "[Netplay] Force-disconnecting ENet peer {}:{} for player {}", peer_entry.first->address.host, peer_entry.first->address.port, peer_entry.second.player_idx); - enet_peer_disconnect(peer_entry.first, 0); + enet_peer_disconnect(peer_entry.first, + m_pending_disconnect_reason.load(std::memory_order_acquire)); peer_entry.second.is_disconnected = true; } } @@ -1107,6 +1114,12 @@ void SlippiNetplayClient::ThreadFunc() } } + // Capture any reason the peer encoded in the disconnect data (e.g. poor performance) so the + // EXI thread can surface the same UI we'd show on the initiating side. Only a deliberate + // disconnect sends a non-zero value; organic disconnects send 0. + if (is_connected_client && net_event.data != 0) + m_disconnect_reason.store(net_event.data, std::memory_order_release); + INFO_LOG_FMT( SLIPPI_ONLINE, "[Netplay] Disconnect late {}:{}. All peers disconnected: {}. Is connected client: {}", @@ -1574,6 +1587,27 @@ void SlippiNetplayClient::ForceDisconnectPlayer(u8 player_idx) Common::ENet::WakeupThread(m_client); } +// Force-disconnect every remote player. Delegates to ForceDisconnectPlayer so the liveness +// flip, peer teardown, and network-thread wakeup all reuse the same logic; the final call +// transitions the overall status to DISCONNECTED once no remote players remain active. +void SlippiNetplayClient::ForceDisconnect(SlippiDisconnectReason reason) +{ + // Record the reason both for our own UI (we never receive a disconnect event from ourselves) + // and for the network thread to forward to the peer via the enet_peer_disconnect data field. + m_pending_disconnect_reason.store(static_cast(reason), std::memory_order_release); + m_disconnect_reason.store(static_cast(reason), std::memory_order_release); + + for (u8 i = 0; i < m_remote_player_count; i++) + { + ForceDisconnectPlayer(match_info.remote_player_selections[i].player_idx); + } +} + +SlippiNetplayClient::SlippiDisconnectReason SlippiNetplayClient::GetDisconnectReason() +{ + return static_cast(m_disconnect_reason.load(std::memory_order_acquire)); +} + // Network-thread only (called from ThreadFunc disconnect handler). bool SlippiNetplayClient::AreAllPeersDisconnectedForKey(const std::string& key) { diff --git a/Source/Core/Core/Slippi/SlippiNetplay.h b/Source/Core/Core/Slippi/SlippiNetplay.h index 0b3f9856a916..c7d11d5a598c 100644 --- a/Source/Core/Core/Slippi/SlippiNetplay.h +++ b/Source/Core/Core/Slippi/SlippiNetplay.h @@ -177,6 +177,15 @@ class SlippiNetplayClient NET_CONNECT_STATUS_DISCONNECTED, }; + // Reason carried over the wire alongside an intentional disconnect, via the ENet + // disconnect data field. Values are transmitted as u32; 0 (UNSPECIFIED) is what a + // normal/organic disconnect sends, so any non-zero value is a deliberate reason. + enum class SlippiDisconnectReason : u32 + { + UNSPECIFIED = 0, + POOR_PERFORMANCE = 1, + }; + bool IsDecider(); bool IsConnectionSelected(); u8 LocalPlayerPort(); @@ -194,6 +203,8 @@ class SlippiNetplayClient void DropOldRemoteInputs(int32_t finalized_frame); std::unordered_map GetActivePlayerIndices(); void ForceDisconnectPlayer(u8 playerIdx); + void ForceDisconnect(SlippiDisconnectReason reason = SlippiDisconnectReason::UNSPECIFIED); + SlippiDisconnectReason GetDisconnectReason(); SlippiMatchInfo* GetMatchInfo(); SlippiPlayerSelections GetSlippiRemoteChatMessage(bool is_chat_enabled); u8 GetSlippiRemoteSentChatMessage(bool is_chat_enabled); @@ -286,6 +297,16 @@ class SlippiNetplayClient std::atomic slippi_connect_status{ SlippiConnectStatus::NET_CONNECT_STATUS_UNSET}; + + // Disconnect reason plumbing (see SlippiDisconnectReason). m_pending_disconnect_reason is set by + // the EXI thread before flipping playerActive and is read by the network thread when it issues + // enet_peer_disconnect so the peer learns why. m_disconnect_reason is the resolved reason for + // this client — set locally on the initiating side, or from the received disconnect data on the + // receiving side — and is read by the EXI thread to drive UI such as the poor-performance OSD. + std::atomic m_pending_disconnect_reason{ + static_cast(SlippiDisconnectReason::UNSPECIFIED)}; + std::atomic m_disconnect_reason{static_cast(SlippiDisconnectReason::UNSPECIFIED)}; + std::vector failed_connections; SlippiMatchInfo match_info; diff --git a/Source/Core/VideoCommon/OnScreenDisplay.h b/Source/Core/VideoCommon/OnScreenDisplay.h index 1c0f1efba84a..455c57b0dc7a 100644 --- a/Source/Core/VideoCommon/OnScreenDisplay.h +++ b/Source/Core/VideoCommon/OnScreenDisplay.h @@ -20,6 +20,7 @@ enum class MessageType NetPlayBuffer, PerformanceWarning, DesyncWarning, + PoorPerformanceTermination, // This entry must be kept last so that persistent typed messages are // displayed before other messages Typeless,