diff --git a/Source/Core/Common/HttpRequest.cpp b/Source/Core/Common/HttpRequest.cpp index 2a7e716774ca..723f1dfd2cfa 100644 --- a/Source/Core/Common/HttpRequest.cpp +++ b/Source/Core/Common/HttpRequest.cpp @@ -22,6 +22,7 @@ class HttpRequest::Impl final { GET, POST, + Delete, }; explicit Impl(std::chrono::milliseconds timeout_ms, ProgressCallback callback); @@ -29,6 +30,8 @@ class HttpRequest::Impl final bool IsValid() const; std::string GetHeaderValue(std::string_view name) const; void SetCookies(const std::string& cookies); + void SetTimeout(std::chrono::milliseconds timeout); + void SetSensitive(bool sensitive) { m_sensitive = sensitive; } void UseIPv4(); void FollowRedirects(long max); s32 GetLastResponseCode(); @@ -49,6 +52,7 @@ class HttpRequest::Impl final std::unique_ptr m_curl{nullptr, curl_easy_cleanup}; std::string m_error_string; std::string m_final_url; + bool m_sensitive = false; }; HttpRequest::HttpRequest(std::chrono::milliseconds timeout_ms, ProgressCallback callback) @@ -77,6 +81,27 @@ void HttpRequest::SetCookies(const std::string& cookies) m_impl->SetCookies(cookies); } +void HttpRequest::SetTimeout(std::chrono::milliseconds timeout) +{ + m_impl->SetTimeout(timeout); +} + +void HttpRequest::SetSensitive(bool sensitive) +{ + m_impl->SetSensitive(sensitive); +} + +void HttpRequest::Impl::SetTimeout(std::chrono::milliseconds timeout) +{ + curl_easy_setopt(m_curl.get(), CURLOPT_TIMEOUT_MS, static_cast(timeout.count())); +} + +HttpRequest::Response HttpRequest::Delete(const std::string& url, const Headers& headers, + AllowedReturnCodes codes) +{ + return m_impl->Fetch(url, Impl::Method::Delete, headers, nullptr, 0, codes); +} + void HttpRequest::UseIPv4() { m_impl->UseIPv4(); @@ -270,6 +295,9 @@ HttpRequest::Response HttpRequest::Impl::Fetch(const std::string& url, Method me const std::vector& multiform) { m_response_headers.clear(); + // CUSTOMREQUEST persists across transfers, so reset it for GET/POST reuse. + curl_easy_setopt(m_curl.get(), CURLOPT_CUSTOMREQUEST, + method == Impl::Method::Delete ? "DELETE" : nullptr); curl_easy_setopt(m_curl.get(), CURLOPT_POST, method == Impl::Method::POST); curl_easy_setopt(m_curl.get(), CURLOPT_URL, url.c_str()); if (method == Impl::Method::POST && multiform.empty()) @@ -316,11 +344,16 @@ HttpRequest::Response HttpRequest::Impl::Fetch(const std::string& url, Method me curl_easy_setopt(m_curl.get(), CURLOPT_WRITEFUNCTION, CurlWriteCallback); curl_easy_setopt(m_curl.get(), CURLOPT_WRITEDATA, &buffer); - const char* type = method == Impl::Method::POST ? "POST" : "GET"; + const char* type = method == Impl::Method::POST ? "POST" : + method == Impl::Method::Delete ? "DELETE" : + "GET"; const CURLcode res = curl_easy_perform(m_curl.get()); if (res != CURLE_OK) { - ERROR_LOG_FMT(COMMON, "Failed to {} {}: {}", type, url, m_error_string); + if (m_sensitive) + ERROR_LOG_FMT(COMMON, "Sensitive HTTP request failed"); + else + ERROR_LOG_FMT(COMMON, "Failed to {} {}: {}", type, url, m_error_string); return {}; } @@ -337,7 +370,11 @@ HttpRequest::Response HttpRequest::Impl::Fetch(const std::string& url, Method me curl_easy_getinfo(m_curl.get(), CURLINFO_RESPONSE_CODE, &response_code); if (response_code != 200) { - if (buffer.empty()) + if (m_sensitive) + { + ERROR_LOG_FMT(COMMON, "Sensitive HTTP request failed with status {}", response_code); + } + else if (buffer.empty()) { ERROR_LOG_FMT(COMMON, "Failed to {} {}: server replied with code {}", type, url, response_code); diff --git a/Source/Core/Common/HttpRequest.h b/Source/Core/Common/HttpRequest.h index a41f2f7c2253..ba1993dacd2f 100644 --- a/Source/Core/Common/HttpRequest.h +++ b/Source/Core/Common/HttpRequest.h @@ -46,6 +46,10 @@ class HttpRequest final }; void SetCookies(const std::string& cookies); + // Optional total transfer deadline; existing callers retain their timeout behavior. + void SetTimeout(std::chrono::milliseconds timeout); + // Suppress URLs, response bodies and curl diagnostics containing bearer capabilities. + void SetSensitive(bool sensitive); void UseIPv4(); void FollowRedirects(long max = 1); s32 GetLastResponseCode() const; @@ -58,6 +62,9 @@ class HttpRequest final Response Post(const std::string& url, std::string_view payload, const Headers& headers = {}, AllowedReturnCodes codes = AllowedReturnCodes::Ok_Only); + Response Delete(const std::string& url, const Headers& headers = {}, + AllowedReturnCodes codes = AllowedReturnCodes::Ok_Only); + Response PostMultiform(const std::string& url, const std::vector& multiform, const Headers& headers = {}, AllowedReturnCodes codes = AllowedReturnCodes::Ok_Only); diff --git a/Source/Core/Core/Config/NetplaySettings.cpp b/Source/Core/Core/Config/NetplaySettings.cpp index 1a4f24a4979e..dabee8915f6b 100644 --- a/Source/Core/Core/Config/NetplaySettings.cpp +++ b/Source/Core/Core/Config/NetplaySettings.cpp @@ -11,6 +11,17 @@ static constexpr u16 DEFAULT_LISTEN_PORT = 2626; // Main.NetPlay +const Info NETPLAY_QUICKPLAY_MATCHMAKING{ + {System::Main, "NetPlay", "QuickPlayMatchmaking"}, "lobby"}; +const Info NETPLAY_QUICKPLAY_COORDINATOR{ + {System::Main, "NetPlay", "QuickPlayCoordinator"}, ""}; +const Info NETPLAY_QUICKPLAY_REGIONS{{System::Main, "NetPlay", "QuickPlayRegions"}, + ""}; +const Info NETPLAY_QUICKPLAY_REGION{{System::Main, "NetPlay", "QuickPlayRegion"}, + "us-east"}; +const Info NETPLAY_QUICKPLAY_HOST_PREFERENCE{ + {System::Main, "NetPlay", "QuickPlayHostPreference"}, "none"}; + const Info NETPLAY_TRAVERSAL_SERVER{{System::Main, "NetPlay", "TraversalServer"}, "stun.dolphin-emu.org"}; const Info NETPLAY_TRAVERSAL_PORT{{System::Main, "NetPlay", "TraversalPort"}, 6262}; diff --git a/Source/Core/Core/Config/NetplaySettings.h b/Source/Core/Core/Config/NetplaySettings.h index 6fa667d3fbd5..6b64931905ea 100644 --- a/Source/Core/Core/Config/NetplaySettings.h +++ b/Source/Core/Core/Config/NetplaySettings.h @@ -14,6 +14,16 @@ namespace Config // Main.NetPlay +// "lobby" (Dolphin's NetPlay index, NETPLAY_INDEX_URL) or "coordinator". +extern const Info NETPLAY_QUICKPLAY_MATCHMAKING; +extern const Info NETPLAY_QUICKPLAY_COORDINATOR; +// Comma-separated region tokens. Empty means not saved yet: read the legacy single +// QuickPlayRegion instead (see QuickPlayMatchmaker::LoadRegions). +extern const Info NETPLAY_QUICKPLAY_REGIONS; +extern const Info NETPLAY_QUICKPLAY_REGION; +// "host", "none" or "join". +extern const Info NETPLAY_QUICKPLAY_HOST_PREFERENCE; + extern const Info NETPLAY_TRAVERSAL_SERVER; extern const Info NETPLAY_TRAVERSAL_PORT; extern const Info NETPLAY_TRAVERSAL_PORT_ALT; diff --git a/Source/Core/Core/NetPlayClient.h b/Source/Core/Core/NetPlayClient.h index 50a122eb216f..1c0aed66e582 100644 --- a/Source/Core/Core/NetPlayClient.h +++ b/Source/Core/Core/NetPlayClient.h @@ -70,6 +70,10 @@ class NetPlayUI virtual void OnConnectionError(const std::string& message) = 0; virtual void OnTraversalError(Common::TraversalClient::FailureReason error) = 0; virtual void OnTraversalStateChanged(Common::TraversalClient::State state) = 0; + // Host-owned value snapshot; default no-op for other frontends. + virtual void OnHostTraversalStateChanged(u64, Common::TraversalClient::State, const std::string&) + { + } virtual void OnGameStartAborted() = 0; virtual void OnGolferChanged(bool is_golfer, const std::string& golfer_name) = 0; virtual void OnTtlDetermined(u8 ttl) = 0; diff --git a/Source/Core/Core/NetPlayServer.cpp b/Source/Core/Core/NetPlayServer.cpp index 473200ee800f..d9ca696af2bc 100644 --- a/Source/Core/Core/NetPlayServer.cpp +++ b/Source/Core/Core/NetPlayServer.cpp @@ -118,8 +118,8 @@ NetPlayServer::~NetPlayServer() // called from ---GUI--- thread NetPlayServer::NetPlayServer(const u16 port, const bool forward_port, NetPlayUI* dialog, - const NetTraversalConfig& traversal_config) - : m_dialog(dialog) + const NetTraversalConfig& traversal_config, u64 quickplay_attempt) + : m_quickplay_attempt(quickplay_attempt), m_dialog(dialog) { //--use server time if (enet_initialize() != 0) @@ -166,6 +166,8 @@ NetPlayServer::NetPlayServer(const u16 port, const bool forward_port, NetPlayUI* { is_connected = true; m_do_loop = true; + // Snapshot before starting the service thread: handles an already-connected shared client. + NotifyHostTraversalState(); m_thread = std::thread(&NetPlayServer::ThreadFunc, this); m_minimum_buffer_size = 3; m_chunked_data_thread = std::thread(&NetPlayServer::ChunkedDataThreadFunc, this); @@ -199,7 +201,9 @@ static bool IsValidPadIndex(const T& map_array, PadIndex index) void NetPlayServer::SetupIndex() { - if (!Config::Get(Config::NETPLAY_USE_INDEX) || Config::Get(Config::NETPLAY_INDEX_NAME).empty() || + // Immutable per-server override, including every traversal reconnect. Manual config is untouched. + if (m_quickplay_attempt != 0 || !Config::Get(Config::NETPLAY_USE_INDEX) || + Config::Get(Config::NETPLAY_INDEX_NAME).empty() || Config::Get(Config::NETPLAY_INDEX_REGION).empty()) { return; @@ -434,6 +438,10 @@ static void SendSyncIdentifier(sf::Packet& spac, const SyncIdentifier& sync_iden // called from ---NETPLAY--- thread ConnectionError NetPlayServer::OnConnect(ENetPeer* incoming_connection, sf::Packet& received_packet) { + // A Quick Play room is for one opponent, even after that opponent leaves. + if (m_quickplay_attempt != 0 && m_quickplay_had_peer) + return ConnectionError::ServerFull; + std::string netplay_version; received_packet >> netplay_version; if (netplay_version != Common::GetScmRevGitStr()) @@ -463,6 +471,9 @@ ConnectionError NetPlayServer::OnConnect(ENetPeer* incoming_connection, sf::Pack // force a ping on first netplay loop m_update_pings = true; + if (m_quickplay_attempt != 0 && new_player.pid != 1) + m_quickplay_had_peer = true; + AssignNewUserAPad(new_player); // tell other players a new player joined @@ -1337,8 +1348,24 @@ unsigned int NetPlayServer::OnData(sf::Packet& packet, Client& player) return 0; } +void NetPlayServer::NotifyHostTraversalState() +{ + if (!m_quickplay_attempt || !m_dialog || !m_traversal_client) + return; + const auto state = m_traversal_client->GetState(); + std::string code; + if (state == Common::TraversalClient::State::Connected) + { + const auto id = m_traversal_client->GetHostID(); + if (id[0] != '\0') + code.assign(id.begin(), id.end()); + } + m_dialog->OnHostTraversalStateChanged(m_quickplay_attempt, state, code); +} + void NetPlayServer::OnTraversalStateChanged() { + NotifyHostTraversalState(); const Common::TraversalClient::State state = m_traversal_client->GetState(); if (Common::g_TraversalClient->GetHostID()[0] != '\0') @@ -1347,7 +1374,7 @@ void NetPlayServer::OnTraversalStateChanged() if (!m_dialog) return; - if (state == Common::TraversalClient::State::Failure) + if (state == Common::TraversalClient::State::Failure && !m_quickplay_attempt) m_dialog->OnTraversalError(m_traversal_client->GetFailureReason()); m_dialog->OnTraversalStateChanged(state); diff --git a/Source/Core/Core/NetPlayServer.h b/Source/Core/Core/NetPlayServer.h index 13783b45189b..3c3baba42d1e 100644 --- a/Source/Core/Core/NetPlayServer.h +++ b/Source/Core/Core/NetPlayServer.h @@ -40,7 +40,8 @@ class NetPlayServer : public Common::TraversalClientClient const std::string& title = ""); NetPlayServer(u16 port, bool forward_port, NetPlayUI* dialog, - const NetTraversalConfig& traversal_config); + const NetTraversalConfig& traversal_config, u64 quickplay_attempt = 0); + u64 GetQuickPlayAttempt() const { return m_quickplay_attempt; } ~NetPlayServer() override; bool ChangeGame(const SyncIdentifier& sync_identifier, const std::string& netplay_name); @@ -181,6 +182,10 @@ class NetPlayServer : public Common::TraversalClientClient PlayerId m_current_golfer = 1; PlayerId m_pending_golfer = 0; + const u64 m_quickplay_attempt; + bool m_quickplay_had_peer = false; // Latched under m_crit.game. + void NotifyHostTraversalState(); + std::map m_players; std::unordered_map>> m_timebase_by_frame; diff --git a/Source/Core/DolphinQt/CMakeLists.txt b/Source/Core/DolphinQt/CMakeLists.txt index f21cfb7b089d..1b13afa20014 100644 --- a/Source/Core/DolphinQt/CMakeLists.txt +++ b/Source/Core/DolphinQt/CMakeLists.txt @@ -311,6 +311,22 @@ add_executable(project-plus-dolphin ProjectPlus/InstallUpdateDialog.h ProjectPlus/UpdateDialog.cpp ProjectPlus/UpdateDialog.h + QuickPlay/QuickPlayClient.cpp + QuickPlay/QuickPlayClient.h + QuickPlay/QuickPlayController.cpp + QuickPlay/QuickPlayController.h + QuickPlay/QuickPlayDialog.cpp + QuickPlay/QuickPlayDialog.h + QuickPlay/QuickPlayIndexMatchmaker.cpp + QuickPlay/QuickPlayIndexMatchmaker.h + QuickPlay/QuickPlayLauncher.cpp + QuickPlay/QuickPlayLauncher.h + QuickPlay/QuickPlayMatchmaker.cpp + QuickPlay/QuickPlayMatchmaker.h + QuickPlay/QuickPlaySession.cpp + QuickPlay/QuickPlaySession.h + QuickPlay/QuickPlaySettingsDialog.cpp + QuickPlay/QuickPlaySettingsDialog.h MenuBar.cpp MenuBar.h NetPlay/ChunkedProgressDialog.cpp diff --git a/Source/Core/DolphinQt/DolphinQt.vcxproj b/Source/Core/DolphinQt/DolphinQt.vcxproj index 362d6649a02d..2997916b3457 100644 --- a/Source/Core/DolphinQt/DolphinQt.vcxproj +++ b/Source/Core/DolphinQt/DolphinQt.vcxproj @@ -197,6 +197,14 @@ + + + + + + + + @@ -279,6 +287,10 @@ + + + + @@ -430,6 +442,10 @@ + + + + diff --git a/Source/Core/DolphinQt/MainWindow.cpp b/Source/Core/DolphinQt/MainWindow.cpp index a06c1f1c92ab..f2153c9a936b 100644 --- a/Source/Core/DolphinQt/MainWindow.cpp +++ b/Source/Core/DolphinQt/MainWindow.cpp @@ -122,6 +122,7 @@ #include "DolphinQt/QtUtils/QueueOnObject.h" #include "DolphinQt/QtUtils/RunOnObject.h" #include "DolphinQt/QtUtils/WindowActivationEventFilter.h" +#include "DolphinQt/QuickPlay/QuickPlaySession.h" #include "DolphinQt/RenderWidget.h" #include "DolphinQt/ResourcePackManager.h" #include "DolphinQt/Resources.h" @@ -223,6 +224,26 @@ static std::vector StringListToStdVector(const QStringList& list) return result; } +// Everything of MainWindow that Quick Play can reach. +class MainWindow::QuickPlayAdapter final : public QuickPlayWindow +{ +public: + explicit QuickPlayAdapter(MainWindow& window) : m_window(window) {} + + bool IsExitRequested() const override { return m_window.m_exit_requested; } + bool IsBootPending() const override { return m_window.m_pending_boot != nullptr; } + + void NetPlayQuit() override { m_window.NetPlayQuit(); } + bool NetPlayHostInternal(const UICommon::GameFile& game, u64 quickplay_attempt) override + { + return m_window.NetPlayHostInternal(game, quickplay_attempt); + } + bool NetPlayJoinInternal() override { return m_window.NetPlayJoinInternal(false); } + +private: + MainWindow& m_window; +}; + MainWindow::MainWindow(Core::System& system, std::unique_ptr boot_parameters, const std::string& movie_path) : QMainWindow(nullptr), m_system(system) @@ -270,6 +291,9 @@ MainWindow::MainWindow(Core::System& system, std::unique_ptr boo InitCoreCallbacks(); NetPlayInit(); + m_quickplay_window = std::make_unique(*this); + m_quickplay = new QuickPlaySession(*m_quickplay_window, m_system, this, m_netplay_dialog, + m_game_list->GetGameListModel(), m_tool_bar); #ifdef SHOW_UPDATER CheckForUpdatesAuto(); @@ -349,6 +373,8 @@ MainWindow::MainWindow(Core::System& system, std::unique_ptr boo MainWindow::~MainWindow() { + m_quickplay->Shutdown(); + // Shut down NetPlay first to avoid race condition segfault Settings::Instance().ResetNetPlayClient(); Settings::Instance().ResetNetPlayServer(); @@ -1734,6 +1760,13 @@ void MainWindow::NetPlayInit() } bool MainWindow::NetPlayJoin() +{ + if (m_quickplay->OwnsNetPlay()) + return false; + return NetPlayJoinInternal(false); +} + +bool MainWindow::NetPlayJoinInternal(bool force_traversal) { if (!Core::IsUninitialized(m_system)) { @@ -1753,7 +1786,7 @@ bool MainWindow::NetPlayJoin() // Settings const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); - const bool is_traversal = traversal_choice == "traversal"; + const bool is_traversal = force_traversal || traversal_choice == "traversal"; std::string host_ip; u16 host_port; @@ -1801,6 +1834,13 @@ bool MainWindow::NetPlayJoin() } bool MainWindow::NetPlayHost(const UICommon::GameFile& game) +{ + if (m_quickplay->OwnsNetPlay()) + return false; + return NetPlayHostInternal(game, 0); +} + +bool MainWindow::NetPlayHostInternal(const UICommon::GameFile& game, u64 quickplay_attempt) { if (!Core::IsUninitialized(m_system)) { @@ -1819,7 +1859,7 @@ bool MainWindow::NetPlayHost(const UICommon::GameFile& game) // Settings u16 host_port = Config::Get(Config::NETPLAY_HOST_PORT); const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); - const bool is_traversal = traversal_choice == "traversal"; + const bool is_traversal = quickplay_attempt != 0 || traversal_choice == "traversal"; const bool use_upnp = Config::Get(Config::NETPLAY_USE_UPNP); const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER); @@ -1830,10 +1870,10 @@ bool MainWindow::NetPlayHost(const UICommon::GameFile& game) host_port = Config::Get(Config::NETPLAY_LISTEN_PORT); // Create Server - Settings::Instance().ResetNetPlayServer( - new NetPlay::NetPlayServer(host_port, use_upnp, m_netplay_dialog, - NetPlay::NetTraversalConfig{is_traversal, traversal_host, - traversal_port, traversal_port_alt})); + Settings::Instance().ResetNetPlayServer(new NetPlay::NetPlayServer( + host_port, use_upnp, m_netplay_dialog, + NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port, traversal_port_alt}, + quickplay_attempt)); if (!Settings::Instance().GetNetPlayServer()->is_connected) { @@ -1849,13 +1889,14 @@ bool MainWindow::NetPlayHost(const UICommon::GameFile& game) m_game_list->GetNetPlayName(game)); // Join our local server - return NetPlayJoin(); + return NetPlayJoinInternal(quickplay_attempt != 0); } void MainWindow::NetPlayQuit() { Settings::Instance().ResetNetPlayClient(); Settings::Instance().ResetNetPlayServer(); + m_quickplay->OnNetPlayQuit(); #ifdef USE_DISCORD_PRESENCE Discord::UpdateDiscordPresence(); #endif diff --git a/Source/Core/DolphinQt/MainWindow.h b/Source/Core/DolphinQt/MainWindow.h index 9ec291c51171..91f59cd3341c 100644 --- a/Source/Core/DolphinQt/MainWindow.h +++ b/Source/Core/DolphinQt/MainWindow.h @@ -46,6 +46,7 @@ class MenuBar; class NetPlayDialog; class NetPlaySetupDialog; class NetworkWidget; +class QuickPlaySession; class RegisterWidget; class RenderWidget; class SearchBar; @@ -199,7 +200,9 @@ class MainWindow final : public QMainWindow void NetPlayInit(); bool NetPlayJoin(); + bool NetPlayJoinInternal(bool force_traversal); bool NetPlayHost(const UICommon::GameFile& game); + bool NetPlayHostInternal(const UICommon::GameFile& game, u64 quickplay_attempt); void NetPlayQuit(); void OnBootGameCubeIPL(DiscIO::Region region); @@ -274,6 +277,9 @@ class MainWindow final : public QMainWindow NetPlayDialog* m_netplay_dialog; DiscordHandler* m_netplay_discord; NetPlaySetupDialog* m_netplay_setup_dialog; + class QuickPlayAdapter; + std::unique_ptr m_quickplay_window; + QuickPlaySession* m_quickplay = nullptr; static constexpr int num_gc_controllers = 4; std::array m_gc_tas_input_windows{}; std::array m_gba_tas_input_windows{}; diff --git a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp index adec7781186e..72427d1985e8 100644 --- a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp +++ b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp @@ -736,6 +736,10 @@ void NetPlayDialog::UpdateGUI() if (!server) return; + // The host's local client has received the remote player's successful admission. + if (server->GetQuickPlayAttempt() != 0 && players.size() > 1) + emit QuickPlayOpponentConnected(server->GetQuickPlayAttempt()); + const bool is_local_ip_selected = m_room_box->currentIndex() > (m_use_traversal ? 1 : 0); if (is_local_ip_selected) { @@ -1069,6 +1073,17 @@ void NetPlayDialog::OnTraversalError(Common::TraversalClient::FailureReason erro }); } +void NetPlayDialog::OnHostTraversalStateChanged(u64 attempt, Common::TraversalClient::State state, + const std::string& code) +{ + // Copy on the traversal callback thread, never read the global client from a queued lambda. + const auto copied_code = QString::fromStdString(code); + const bool failed = state == Common::TraversalClient::State::Failure; + QueueOnObject(this, [this, attempt, copied_code, failed] { + emit HostTraversalChanged(attempt, copied_code, failed); + }); +} + void NetPlayDialog::OnTraversalStateChanged(Common::TraversalClient::State state) { switch (state) diff --git a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.h b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.h index b86de03e26cf..9772a938a0f0 100644 --- a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.h +++ b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.h @@ -72,6 +72,8 @@ class NetPlayDialog : public QDialog, public NetPlay::NetPlayUI void OnConnectionError(const std::string& message) override; void OnTraversalError(Common::TraversalClient::FailureReason error) override; void OnTraversalStateChanged(Common::TraversalClient::State state) override; + void OnHostTraversalStateChanged(u64 attempt, Common::TraversalClient::State state, + const std::string& code) override; void OnGameStartAborted() override; void OnGolferChanged(bool is_golfer, const std::string& golfer_name) override; void OnTtlDetermined(u8 ttl) override; @@ -104,6 +106,8 @@ class NetPlayDialog : public QDialog, public NetPlay::NetPlayUI void SetHostWiiSyncData(std::vector titles, std::string redirect_folder) override; signals: + void QuickPlayOpponentConnected(quint64 attempt); + void HostTraversalChanged(quint64 attempt, QString code, bool failed); void Stop(); private: diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayClient.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlayClient.cpp new file mode 100644 index 000000000000..a1b4ace8c092 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayClient.cpp @@ -0,0 +1,457 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlayClient.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "Common/Config/Config.h" +#include "Common/HttpRequest.h" +#include "Common/Logging/Log.h" +#include "Common/Version.h" +#include "Core/Config/NetplaySettings.h" + +namespace +{ +using Codes = Common::HttpRequest::AllowedReturnCodes; +constexpr auto REQUEST_TIMEOUT = std::chrono::milliseconds{5000}; +constexpr auto CANCEL_TIMEOUT = std::chrono::milliseconds{3000}; +constexpr int QUICKPLAY_PROTOCOL = 3; + +bool IsId(const QString& value) +{ + return value.size() == 64 && std::all_of(value.begin(), value.end(), [](QChar c) { + return (c >= QLatin1Char('0') && c <= QLatin1Char('9')) || + (c >= QLatin1Char('a') && c <= QLatin1Char('f')); + }); +} + +QString Platform() +{ +#if defined(_WIN32) + return QStringLiteral("windows"); +#elif defined(__APPLE__) + return QStringLiteral("macos"); +#elif defined(__linux__) + return QStringLiteral("linux"); +#else + return QStringLiteral("other"); +#endif +} + +QuickPlayClient::Result TransportError() +{ + return QuickPlayClient::Failure( + QObject::tr("Cannot reach the Quick Play coordinator, or the request timed out. " + "Check the server address and connection, then cancel and try again.")); +} +} // namespace + +struct QuickPlayClient::Attempt +{ + std::atomic cancelled{false}; + std::string base_url; // Immutable after submission. + QString ticket; // Accessed only on the serialized worker. + QString client_match_id; // GUI thread only; bound to this admission and role. +}; + +QuickPlayClient::QuickPlayClient(QObject* parent) : QuickPlayMatchmaker(parent) +{ +} + +QuickPlayClient::~QuickPlayClient() +{ + Cancel(); + // Join before QObject teardown: the worker may still queue Deliver() on this object. + // Dialog cancellation never waits. Only final destruction drains bounded HTTP/DELETE work. + m_worker.Shutdown(); +} + +QuickPlayClient::Result QuickPlayClient::ParseResponse(int status, const QByteArray& body, + bool joining) +{ + Result result; + result.error = tr("The Quick Play coordinator returned an invalid or incompatible response. " + "Cancel and try again."); + if (status != (joining ? 201 : 200) && status != 410) + { + result.error = tr("Quick Play coordinator request failed (HTTP %1). " + "Check the server configuration, then cancel and try again.") + .arg(status); + return result; + } + + QJsonParseError error; + const auto document = QJsonDocument::fromJson(body, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return result; + const auto object = document.object(); + const QString state = object.value(QStringLiteral("state")).toString(); + if (status == 410) + { + if (state == QStringLiteral("expired")) + { + result.state = State::Expired; + result.error = tr("The Quick Play queue expired or was cancelled. Cancel and try again."); + } + return result; + } + + if (joining) + { + const auto ticket = object.value(QStringLiteral("ticket")).toString(); + if (!IsId(ticket)) + return result; + // Keep a valid ticket even if the remaining schema is bad, so it can be cancelled. + result.ticket = ticket; + } + if (state == QStringLiteral("searching")) + { + if (object.contains(QStringLiteral("match_id")) || object.contains(QStringLiteral("role")) || + object.contains(QStringLiteral("region"))) + return result; + result.state = State::Searching; + } + else if (state == QStringLiteral("matched")) + { + const auto match_id = object.value(QStringLiteral("match_id")).toString(); + const auto role = object.value(QStringLiteral("role")).toString(); + const auto region = object.value(QStringLiteral("region")).toString(); + // A newcomer can host when host preferences say so. + if (!IsId(match_id) || (role != QStringLiteral("host") && role != QStringLiteral("client")) || + !IsValidRegion(region)) + return result; + result.match_id = match_id; + result.region = region; + result.state = role == QStringLiteral("host") ? State::MatchedHost : State::MatchedClient; + } + else + { + return result; + } + result.error.clear(); + return result; +} + +bool QuickPlayClient::HasConfiguredCoordinator() +{ + QString normalized; + return NormalizeCoordinatorUrl( + QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_COORDINATOR)), + &normalized) + .isEmpty(); +} + +QString QuickPlayClient::NormalizeCoordinatorUrl(const QString& input, QString* normalized) +{ + const QString trimmed = input.trimmed(); + if (trimmed.isEmpty()) + return tr("Enter a coordinator URL."); + const QUrl url(trimmed, QUrl::StrictMode); + if (url.scheme() != QStringLiteral("http") && url.scheme() != QStringLiteral("https")) + return tr("The coordinator URL must start with http:// or https://."); + if (!url.isValid() || url.host().isEmpty() || url.port() == 0) + return tr("Enter a valid coordinator URL with a hostname and optional port/path."); + if (!url.userInfo().isEmpty() || url.hasQuery() || url.hasFragment()) + return tr("The coordinator URL cannot contain credentials, a query, or a fragment."); + + // Keep the entered spelling/path; only trim whitespace and redundant trailing separators. + *normalized = trimmed; + while (normalized->endsWith(QLatin1Char('/'))) + normalized->chop(1); + return {}; +} + +void QuickPlayClient::Start(const QStringList& regions, const QString& host_preference_input, + Callback callback) +{ + const QString base_url = + QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_COORDINATOR)); + const QString host_preference = NormalizeHostPreference(host_preference_input); + const bool regions_valid = !regions.isEmpty() && regions.size() <= MAX_REGIONS && + std::all_of(regions.begin(), regions.end(), IsValidRegion) && + QStringList(regions).removeDuplicates() == 0; + Cancel(); + auto attempt = std::make_shared(); + m_attempt = attempt; + m_callback = std::move(callback); + m_pending = true; + + QString normalized_url; + const QString url_error = + base_url.trimmed().isEmpty() ? + tr("Custom coordinator is selected, but no coordinator URL is set. Enter one in Quick " + "Play Settings, or choose the Dolphin lobby.") : + NormalizeCoordinatorUrl(base_url, &normalized_url); + const QString build = QString::fromStdString(Common::GetScmRevGitStr()); + // Matched exactly. The handoff is ordinary NetPlay. + const QString session = QStringLiteral("ordinary"); + const QString version = QString::fromStdString(Common::GetScmDescStr()); + if (!url_error.isEmpty() || !regions_valid || !IsField(build, 128) || !IsField(session, 128) || + !IsField(version, 64)) + { + Deliver(attempt, Failure(!url_error.isEmpty() ? + url_error : + tr("Invalid Quick Play region or build metadata. Check Quick " + "Play Settings and use a compatible Dolphin build."))); + return; + } + attempt->base_url = + QUrl(normalized_url, QUrl::StrictMode).toString(QUrl::FullyEncoded).toStdString(); + + const QJsonObject fields{ + {QStringLiteral("protocol"), QUICKPLAY_PROTOCOL}, + {QStringLiteral("build"), build}, + {QStringLiteral("session"), session}, + {QStringLiteral("pplus_version"), version}, + {QStringLiteral("regions"), QJsonArray::fromStringList(regions)}, + {QStringLiteral("host_preference"), host_preference}, + {QStringLiteral("platform"), Platform()}, + {QStringLiteral("player_nonce"), QUuid::createUuid().toString(QUuid::WithoutBraces)}}; + const auto body = QJsonDocument(fields).toJson(QJsonDocument::Compact).toStdString(); + INFO_LOG_FMT(NETPLAY, "QuickPlay: queue request started"); + m_worker.Push([this, attempt, body] { + if (attempt->cancelled.load()) + return; + // POST is not idempotent. Let an in-flight join finish so Cancel can recover and DELETE its + // ticket. Never retry admission automatically, even if the response is lost. + Common::HttpRequest http(REQUEST_TIMEOUT); + http.SetSensitive(true); + http.SetTimeout(REQUEST_TIMEOUT); + auto result = TransportError(); + if (http.IsValid()) + { + const auto response = http.Post(attempt->base_url + "/v1/queue", std::string_view(body), + {{"Content-Type", "application/json"}}, Codes::All); + if (response) + { + result = ParseResponse(http.GetLastResponseCode(), + QByteArray(reinterpret_cast(response->data()), + static_cast(response->size())), + true); + attempt->ticket = result.ticket; + } + } + Deliver(attempt, std::move(result)); + }); +} + +void QuickPlayClient::Poll() +{ + if (!m_attempt || m_pending || m_attempt->cancelled.load()) + return; + m_pending = true; + m_worker.Push([this, attempt = m_attempt] { + if (attempt->cancelled.load() || attempt->ticket.isEmpty()) + return; + Common::HttpRequest http( + REQUEST_TIMEOUT, [attempt](auto, auto, auto, auto) { return !attempt->cancelled.load(); }); + http.SetSensitive(true); + http.SetTimeout(REQUEST_TIMEOUT); + auto result = TransportError(); + if (http.IsValid()) + { + const auto response = http.Get( + attempt->base_url + "/v1/queue/" + attempt->ticket.toStdString(), {}, Codes::All); + if (response) + result = ParseResponse(http.GetLastResponseCode(), + QByteArray(reinterpret_cast(response->data()), + static_cast(response->size())), + false); + } + Deliver(attempt, std::move(result)); + }); +} + +QuickPlayClient::Result QuickPlayClient::ParseMatchResponse(int status, const QByteArray& body) +{ + if (status == 410) + return ParseHostResponse(status, body); + Result result; + result.error = + tr("The Quick Play match returned an invalid response (HTTP %1). Cancel and try again.") + .arg(status); + if (status != 200) + return result; + QJsonParseError error; + const auto document = QJsonDocument::fromJson(body, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return result; + const auto object = document.object(); + const auto state = object.value(QStringLiteral("state")).toString(); + if (state == QStringLiteral("waiting_for_host") && !object.contains(QStringLiteral("host_code"))) + result.state = State::WaitingForHost; + else if (state == QStringLiteral("ready")) + { + const auto code = object.value(QStringLiteral("host_code")).toString(); + if (!IsValidHostCode(code)) + return result; + result.state = State::HostReady; + result.host_code = code; + } + else + return result; + result.error.clear(); + return result; +} + +void QuickPlayClient::PollMatch(const QString& match_id) +{ + if (!m_attempt || m_pending || m_attempt->cancelled.load()) + return; + m_pending = true; + if (!IsId(match_id) || m_attempt->client_match_id != match_id) + { + Deliver(m_attempt, Failure(tr("Invalid Quick Play client match."))); + return; + } + m_worker.Push([this, attempt = m_attempt, match_id] { + if (attempt->cancelled.load()) + return; + auto result = TransportError(); + Common::HttpRequest http( + REQUEST_TIMEOUT, [attempt](auto, auto, auto, auto) { return !attempt->cancelled.load(); }); + http.SetSensitive(true); + http.SetTimeout(REQUEST_TIMEOUT); + if (http.IsValid() && !attempt->ticket.isEmpty()) + { + const auto response = + http.Get(attempt->base_url + "/v1/matches/" + match_id.toStdString(), + {{"Authorization", "Bearer " + attempt->ticket.toStdString()}}, Codes::All); + if (response) + result = ParseMatchResponse(http.GetLastResponseCode(), + QByteArray(reinterpret_cast(response->data()), + static_cast(response->size()))); + } + result.match_id = match_id; + Deliver(attempt, std::move(result)); + }); +} + +QuickPlayClient::Result QuickPlayClient::ParseHostResponse(int status, const QByteArray& body) +{ + if (status == 204 && body.isEmpty()) + return {State::HostPublished, {}, {}, {}}; + if (status == 410) + { + auto result = ParseResponse(status, body, false); + if (result.state == State::Expired) + result.error = tr("The Quick Play match expired or was cancelled. Cancel and try again."); + return result; + } + return Failure(tr("Quick Play host publication returned an invalid response (HTTP %1). " + "Cancel and try again.") + .arg(status)); +} + +void QuickPlayClient::PublishHostCode(const QString& match_id, const QString& host_code) +{ + if (!m_attempt || m_pending || m_attempt->cancelled.load()) + return; + m_pending = true; + if (!IsId(match_id) || !IsValidHostCode(host_code)) + { + Deliver(m_attempt, Failure(tr("Invalid Quick Play traversal host code."))); + return; + } + m_worker.Push([this, attempt = m_attempt, match_id, host_code] { + if (attempt->cancelled.load()) + return; + if (attempt->ticket.isEmpty()) + { + Deliver(attempt, Failure(tr("Missing Quick Play host ticket."))); + return; + } + const auto body = QJsonDocument(QJsonObject{{QStringLiteral("ticket"), attempt->ticket}, + {QStringLiteral("host_code"), host_code}}) + .toJson(QJsonDocument::Compact) + .toStdString(); + auto result = TransportError(); + // Publication is idempotent for the identical code. Retry once on transport/server failure; + // never retry admission or change the body, ticket, match, or endpoint. + for (int retry = 0; retry < 2 && !attempt->cancelled.load(); ++retry) + { + Common::HttpRequest http(REQUEST_TIMEOUT, [attempt](auto, auto, auto, auto) { + return !attempt->cancelled.load(); + }); + http.SetSensitive(true); + http.SetTimeout(REQUEST_TIMEOUT); + bool retryable = true; + result = TransportError(); + if (http.IsValid()) + { + const auto response = + http.Post(attempt->base_url + "/v1/matches/" + match_id.toStdString() + "/host", + std::string_view(body), {{"Content-Type", "application/json"}}, Codes::All); + if (response) + { + const int status = http.GetLastResponseCode(); + result = + ParseHostResponse(status, QByteArray(reinterpret_cast(response->data()), + static_cast(response->size()))); + retryable = status == 408 || status >= 500; + } + } + if (!retryable) + break; + } + Deliver(attempt, std::move(result)); + }); +} + +void QuickPlayClient::Deliver(const std::shared_ptr& attempt, Result result) +{ + QMetaObject::invokeMethod( + this, + [this, attempt, result = std::move(result)]() mutable { + if (attempt != m_attempt || attempt->cancelled.load()) + return; + if (result.state == State::MatchedClient) + attempt->client_match_id = result.match_id; + m_pending = false; + // Copy before invoking: callback may call Cancel(), clearing m_callback. + auto callback = m_callback; + if (callback) + callback(std::move(result)); + }, + Qt::QueuedConnection); +} + +void QuickPlayClient::DeleteTicket(const std::shared_ptr& attempt) +{ + if (attempt->ticket.isEmpty()) + return; + Common::HttpRequest http(CANCEL_TIMEOUT); + http.SetSensitive(true); + http.SetTimeout(CANCEL_TIMEOUT); + const auto response = + http.IsValid() ? http.Delete(attempt->base_url + "/v1/queue/" + attempt->ticket.toStdString(), + {}, Codes::All) : + Common::HttpRequest::Response{}; + if (!response || http.GetLastResponseCode() != 204) + WARN_LOG_FMT(NETPLAY, "QuickPlay: coordinator cancellation failed; ticket will expire"); + attempt->ticket.clear(); +} + +void QuickPlayClient::Cancel() +{ + auto attempt = std::exchange(m_attempt, nullptr); + m_callback = {}; + m_pending = false; + if (!attempt) + return; + attempt->cancelled.store(true); + // Serialized after admission/poll: even a late join result is cleaned up. Pending polls skip + // HTTP entirely. The old endpoint/ticket remain attached to this attempt across new starts. + m_worker.Push([attempt] { DeleteTicket(attempt); }); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayClient.h b/Source/Core/DolphinQt/QuickPlay/QuickPlayClient.h new file mode 100644 index 000000000000..2da735f35047 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayClient.h @@ -0,0 +1,46 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include +#include + +#include "Common/WorkQueueThread.h" +#include "DolphinQt/QuickPlay/QuickPlayMatchmaker.h" + +// Coordinator backend. GUI-thread API, serialized HTTP worker. No NetPlay or widget dependencies. +class QuickPlayClient final : public QuickPlayMatchmaker +{ +public: + explicit QuickPlayClient(QObject* parent = nullptr); + ~QuickPlayClient() override; + + void Start(const QStringList& regions, const QString& host_preference, + Callback callback) override; + // A valid coordinator URL is set (there is no default), so it can take a search. + static bool HasConfiguredCoordinator(); + // Shared by settings and admission. Returns a user-facing error, or an empty string. + static QString NormalizeCoordinatorUrl(const QString& input, QString* normalized); + void Poll() override; + void PollMatch(const QString& match_id) override; + static Result ParseMatchResponse(int status, const QByteArray& body); + void PublishHostCode(const QString& match_id, const QString& host_code) override; + static Result ParseHostResponse(int status, const QByteArray& body); + void Cancel() override; + + // Pure schema parser shared by join/poll; transport failures are handled separately. + static Result ParseResponse(int status, const QByteArray& body, bool joining); + +private: + struct Attempt; + void Deliver(const std::shared_ptr& attempt, Result result); + static void DeleteTicket(const std::shared_ptr& attempt); + + std::shared_ptr m_attempt; + Callback m_callback; + bool m_pending = false; + Common::AsyncWorkThread m_worker{"QuickPlay HTTP"}; +}; diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayController.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlayController.cpp new file mode 100644 index 000000000000..6ef2d55891fe --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayController.cpp @@ -0,0 +1,370 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlayController.h" + +#include + +#include "Common/Logging/Log.h" +#include "Core/Config/NetplaySettings.h" + +QuickPlayController::QuickPlayController(StartHost start_host, CancelHost cancel_host, + JoinHost join_host, QObject* parent) + : QObject(parent), m_start_host(std::move(start_host)), m_cancel_host(std::move(cancel_host)), + m_join_host(std::move(join_host)) +{ + m_setup_timer.setSingleShot(true); + m_setup_timer.setInterval(25000); + connect(&m_setup_timer, &QTimer::timeout, this, + [this] { Fail(tr("Quick Play room setup timed out. Cancel and try again.")); }); + m_poll_timer.setSingleShot(true); + m_poll_timer.setInterval(1000); + connect(&m_poll_timer, &QTimer::timeout, this, [this] { + if (m_state == State::Searching) + m_matchmaker->Poll(); + else if (m_state == State::WaitingForHost) + m_matchmaker->PollMatch(m_match_id); + }); +} + +QuickPlayController::~QuickPlayController() +{ + Cancel(); +} + +bool QuickPlayController::Start() +{ + // A result remains part of the attempt until the user dismisses it. + if (m_state != State::Idle || m_creating_host || m_joining) + return false; + + ++m_attempt; + m_entered_queue = false; + m_error_message.clear(); + m_regions = QuickPlayMatchmaker::LoadRegions(); + m_match_region.clear(); + // Loaded before SetState: the searching dialog reads it on the state change. + m_host_preference = QuickPlayMatchmaker::LoadHostPreference(); + SetState(State::Searching); + StartBackend(Config::Get(Config::NETPLAY_QUICKPLAY_MATCHMAKING) == "coordinator" ? + Backend::Coordinator : + Backend::Lobby); + return true; +} + +void QuickPlayController::StartBackend(Backend backend) +{ + m_backend = backend; + m_matchmaker = backend == Backend::Lobby ? static_cast(&m_lobby) : + static_cast(&m_coordinator); + m_search_notice.clear(); + m_search_stage = QuickPlayMatchmaker::SearchStage::Normal; + INFO_LOG_FMT(NETPLAY, "QuickPlay: searching through the {}", + backend == Backend::Lobby ? "Dolphin lobby" : "coordinator"); + m_matchmaker->Start(m_regions, m_host_preference, + [this, attempt = m_attempt](QuickPlayMatchmaker::Result result) { + if (attempt == m_attempt && + (m_state == State::Searching || m_state == State::PublishingHostCode || + m_state == State::WaitingForHost || m_state == State::ValidatingMatch)) + HandleResult(std::move(result)); + }); +} + +QString QuickPlayController::GetBackendLabel() const +{ + return m_backend == Backend::Lobby ? tr("Dolphin lobby") : tr("custom coordinator"); +} + +void QuickPlayController::Cancel() +{ + const auto cancelled_attempt = m_attempt++; + m_poll_timer.stop(); + m_setup_timer.stop(); + m_host_code.clear(); + m_match_id.clear(); + m_matchmaker->Cancel(); + m_search_notice.clear(); + m_search_stage = QuickPlayMatchmaker::SearchStage::Normal; + if (!m_creating_host && !m_joining && m_state != State::NetPlayOwned) + m_cancel_host(cancelled_attempt); + if (m_state != State::Idle && m_state != State::NetPlayOwned) + INFO_LOG_FMT(NETPLAY, "QuickPlay: cancelled"); + m_error_message.clear(); + SetState(State::Idle); +} + +void QuickPlayController::HandleResult(QuickPlayMatchmaker::Result result) +{ + switch (result.state) + { + case QuickPlayMatchmaker::State::Searching: + if (m_state != State::Searching) + return; + if (!m_entered_queue) + { + INFO_LOG_FMT(NETPLAY, "QuickPlay: entered queue"); + m_entered_queue = true; + } + m_search_notice = std::move(result.notice); + m_search_stage = result.stage; + m_search_stage_seconds = result.stage_seconds; + // One poll a second after the previous response, never overlapping requests. + m_poll_timer.start(); + break; + case QuickPlayMatchmaker::State::MatchedHost: + case QuickPlayMatchmaker::State::MatchedClient: + if (m_state == State::Searching) + { + ClaimMatch(result); + } + else if (m_state == State::ValidatingMatch) + { + if (result.match_id != m_match_id || + (result.state == QuickPlayMatchmaker::State::MatchedHost) != m_is_host) + { + Fail(tr("The Quick Play assignment changed during handoff. Cancel and try again.")); + return; + } + if (m_is_host) + { + QTimer::singleShot(0, this, [this, attempt = m_attempt] { BeginHost(attempt); }); + } + else + { + SetState(State::WaitingForHost); + m_matchmaker->PollMatch(m_match_id); + } + } + break; + case QuickPlayMatchmaker::State::WaitingForHost: + if (m_state == State::WaitingForHost && result.match_id == m_match_id) + m_poll_timer.start(); + break; + case QuickPlayMatchmaker::State::HostReady: + if (m_state != State::WaitingForHost || result.match_id != m_match_id) + return; + if (!QuickPlayMatchmaker::IsValidHostCode(result.host_code)) + { + Fail(tr("Invalid Quick Play traversal host code.")); + return; + } + m_poll_timer.stop(); + m_setup_timer.stop(); + m_host_code = std::move(result.host_code); + INFO_LOG_FMT(NETPLAY, "QuickPlay: host code ready"); + SetState(State::Connecting); + QTimer::singleShot(0, this, [this, attempt = m_attempt] { BeginJoin(attempt); }); + break; + case QuickPlayMatchmaker::State::HostPublished: + if (m_state != State::PublishingHostCode) + return; + // Keep the bounded setup timeout until the opponent actually arrives. + INFO_LOG_FMT(NETPLAY, "QuickPlay: host code published, waiting for opponent"); + SetState(State::WaitingForOpponent); + break; + case QuickPlayMatchmaker::State::Expired: + case QuickPlayMatchmaker::State::Error: + // Only before any match: the lobby has no shared state to hand over. + if (result.unreachable && m_backend == Backend::Lobby && m_state == State::Searching && + QuickPlayClient::HasConfiguredCoordinator()) + { + WARN_LOG_FMT(NETPLAY, "QuickPlay: Dolphin lobby unavailable; using the configured " + "coordinator"); + m_lobby.Cancel(); + StartBackend(Backend::Coordinator); + return; + } + Fail(std::move(result.error)); + break; + } +} + +void QuickPlayController::ClaimMatch(const QuickPlayMatchmaker::Result& result) +{ + // Claim the assignment before any signal or asynchronous handoff. + const auto attempt = m_attempt; + m_match_id = result.match_id; + m_match_region = result.region; + m_is_host = result.state == QuickPlayMatchmaker::State::MatchedHost; + m_poll_timer.stop(); + m_setup_timer.start(); + INFO_LOG_FMT(NETPLAY, "QuickPlay: claimed {} assignment, attempt {}", + m_is_host ? "host" : "client", attempt); + SetState(m_is_host ? State::MatchedHost : State::MatchedClient); + if (attempt != m_attempt) + return; + SetState(State::PreparingHandoff); + if (attempt != m_attempt) + return; + SetState(State::WaitingForHandoff); + if (attempt == m_attempt) + emit PrepareHandoff(attempt); +} + +void QuickPlayController::OnHandoffReady(std::uint64_t attempt, const QString& error) +{ + if (attempt != m_attempt || m_state != State::WaitingForHandoff) + return; + if (!error.isEmpty()) + { + Fail(error); + return; + } + INFO_LOG_FMT(NETPLAY, "QuickPlay: boundary prepared, validating attempt {}", attempt); + SetState(State::ValidatingMatch); + // Recheck the match before constructing any NetPlay object. + if (attempt == m_attempt) + m_matchmaker->Poll(); +} + +void QuickPlayController::BeginJoin(std::uint64_t attempt) +{ + if (attempt != m_attempt || m_state != State::Connecting) + return; + ConnectJoin(attempt); +} + +void QuickPlayController::ConnectJoin(std::uint64_t attempt) +{ + m_joining = true; + const auto error = m_join_host(m_host_code); + m_joining = false; + // Existing synchronous NetPlay setup may enter a modal error loop. Cancel/close invalidates + // this result there; never tear down an opened lobby or overwrite a newer attempt. + if (attempt != m_attempt) + { + m_cancel_host(attempt); + return; + } + if (!error.isEmpty()) + { + WARN_LOG_FMT(NETPLAY, "QuickPlay: NetPlay join failed"); + Fail(error); + return; + } + INFO_LOG_FMT(NETPLAY, "QuickPlay: client connection established"); + Complete(); +} + +void QuickPlayController::OnHostOpponentConnected(quint64 attempt) +{ + // The peer can arrive before the publication HTTP response reaches the GUI thread. + if (attempt != m_attempt || + (m_state != State::PublishingHostCode && m_state != State::WaitingForOpponent)) + return; + INFO_LOG_FMT(NETPLAY, "QuickPlay: host opponent connected"); + Complete(); +} + +void QuickPlayController::Complete() +{ + m_poll_timer.stop(); + m_setup_timer.start(); + // No connected acknowledgement exists. One bounded DELETE releases the rendezvous; + // readiness/launch remains attempt-owned until the normal BootGame callback. + m_matchmaker->Cancel(); + m_match_id.clear(); + m_host_code.clear(); + SetState(State::NetPlayConnected); +} + +void QuickPlayController::OnLaunchOwned(std::uint64_t attempt) +{ + if (attempt != m_attempt || m_state != State::NetPlayConnected) + return; + m_setup_timer.stop(); + ++m_attempt; // Invalidate every queued setup callback before notifying observers. + SetState(State::NetPlayOwned); +} + +void QuickPlayController::OnLaunchFailed(std::uint64_t attempt, const QString& error) +{ + if (attempt == m_attempt && m_state != State::Idle && m_state != State::Error && + m_state != State::NetPlayOwned) + Fail(error); +} + +void QuickPlayController::BeginHost(std::uint64_t attempt) +{ + if (attempt != m_attempt || m_state != State::ValidatingMatch) + return; + SetState(State::CreatingHost); + if (attempt != m_attempt) + return; + ConnectHost(attempt); +} + +void QuickPlayController::ConnectHost(std::uint64_t attempt) +{ + INFO_LOG_FMT(NETPLAY, "QuickPlay: creating NetPlay host"); + m_creating_host = true; + const auto error = m_start_host(attempt); + m_creating_host = false; + // The normal host path can enter a modal error dialog. Cancel may run inside it. + if (attempt != m_attempt) + { + m_cancel_host(attempt); + return; + } + if (!error.isEmpty()) + { + Fail(error); + return; + } + SetState(State::WaitingForTraversalCode); + if (!m_host_code.isEmpty()) + OnHostTraversalChanged(attempt, m_host_code, false); +} + +void QuickPlayController::OnHostTraversalChanged(quint64 attempt, const QString& code, bool failed) +{ + if (attempt != m_attempt || + (m_state != State::CreatingHost && m_state != State::WaitingForTraversalCode && + m_state != State::PublishingHostCode && m_state != State::WaitingForOpponent)) + return; + if (failed) + { + Fail(tr("The Quick Play traversal connection failed. Cancel and try again.")); + return; + } + if (code.isEmpty()) + return; + // Duplicate callbacks are harmless; a changed room code must never replace a published code. + if (!m_host_code.isEmpty() && m_host_code != code) + { + Fail(tr("The Quick Play room connection changed. Cancel and try again.")); + return; + } + if (m_state == State::PublishingHostCode || m_state == State::WaitingForOpponent) + return; + m_host_code = code; + if (m_state == State::CreatingHost) + return; + SetState(State::PublishingHostCode); + INFO_LOG_FMT(NETPLAY, "QuickPlay: publishing host code"); + m_matchmaker->PublishHostCode(m_match_id, m_host_code); +} + +void QuickPlayController::OnHostClosed(std::uint64_t attempt) +{ + if (attempt == m_attempt && m_state != State::Idle && m_state != State::Error && + m_state != State::NetPlayOwned) + Fail(tr("The Quick Play NetPlay room was closed. Cancel and try again.")); +} + +void QuickPlayController::Fail(QString error) +{ + WARN_LOG_FMT(NETPLAY, "QuickPlay: attempt failed"); + Cancel(); + m_error_message = std::move(error); + SetState(State::Error); +} + +void QuickPlayController::SetState(State state) +{ + if (m_state == state) + return; + + m_state = state; + emit StateChanged(state); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayController.h b/Source/Core/DolphinQt/QuickPlay/QuickPlayController.h new file mode 100644 index 000000000000..b1bbac57c0f5 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayController.h @@ -0,0 +1,119 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include +#include +#include + +#include "DolphinQt/QuickPlay/QuickPlayClient.h" +#include "DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.h" + +// Orchestration and callbacks run on the GUI thread; the client owns HTTP work. +class QuickPlayController final : public QObject +{ + Q_OBJECT + +public: + enum class State + { + Idle, + Searching, + MatchedHost, + MatchedClient, + PreparingHandoff, + WaitingForHandoff, + ValidatingMatch, + CreatingHost, + WaitingForTraversalCode, + PublishingHostCode, + WaitingForOpponent, + WaitingForHost, + Connecting, + NetPlayConnected, + NetPlayOwned, + Error, + }; + Q_ENUM(State) + enum class Backend + { + Lobby, + Coordinator, + }; + + using StartHost = std::function; + using CancelHost = std::function; + using JoinHost = std::function; + QuickPlayController(StartHost start_host, CancelHost cancel_host, JoinHost join_host, + QObject* parent = nullptr); + ~QuickPlayController() override; + + State GetState() const { return m_state; } + // Regions searched by this attempt, and the agreed region once matched. + const QStringList& GetRegions() const { return m_regions; } + const QString& GetMatchRegion() const { return m_match_region; } + // "host", "none" or "join", as sent with this attempt. + const QString& GetHostPreference() const { return m_host_preference; } + const QString& GetErrorMessage() const { return m_error_message; } + // The backend of the current attempt, and its latest displayable search note. + QString GetBackendLabel() const; + const QString& GetSearchNotice() const { return m_search_notice; } + QuickPlayMatchmaker::SearchStage GetSearchStage() const { return m_search_stage; } + int GetSearchStageSeconds() const { return m_search_stage_seconds; } + bool Start(); + void Cancel(); + // Called by the owner once the frontend can host or join, or with the reason it can't. + void OnHandoffReady(std::uint64_t attempt, const QString& error = {}); + bool OwnsAttempt(std::uint64_t attempt) const { return attempt == m_attempt; } + void OnHostTraversalChanged(quint64 attempt, const QString& code, bool failed); + void OnHostOpponentConnected(quint64 attempt); + void OnHostClosed(std::uint64_t attempt); + void OnLaunchOwned(std::uint64_t attempt); + void OnLaunchFailed(std::uint64_t attempt, const QString& error); + +signals: + void PrepareHandoff(quint64 attempt); + void StateChanged(QuickPlayController::State state); + +private: + void SetState(State state); + void StartBackend(Backend backend); + void HandleResult(QuickPlayMatchmaker::Result result); + + void ClaimMatch(const QuickPlayMatchmaker::Result& result); + void BeginHost(std::uint64_t attempt); + void BeginJoin(std::uint64_t attempt); + void ConnectHost(std::uint64_t attempt); + void ConnectJoin(std::uint64_t attempt); + void Fail(QString error); + void Complete(); + StartHost m_start_host; + CancelHost m_cancel_host; + JoinHost m_join_host; + bool m_is_host = false; + bool m_joining = false; + QString m_host_code; + QString m_match_id; + bool m_creating_host = false; + QTimer m_setup_timer; + + QStringList m_regions; + QString m_match_region; + QString m_error_message; + State m_state = State::Idle; + std::uint64_t m_attempt = 0; + bool m_entered_queue = false; + QTimer m_poll_timer; + QuickPlayClient m_coordinator; + QuickPlayIndexMatchmaker m_lobby; + Backend m_backend = Backend::Lobby; + QuickPlayMatchmaker* m_matchmaker = &m_lobby; + QString m_host_preference; + QString m_search_notice; + QuickPlayMatchmaker::SearchStage m_search_stage = QuickPlayMatchmaker::SearchStage::Normal; + int m_search_stage_seconds = 0; +}; diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayDialog.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlayDialog.cpp new file mode 100644 index 000000000000..e2ca36bcf79f --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayDialog.cpp @@ -0,0 +1,178 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlayDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +QuickPlayDialog::QuickPlayDialog(QuickPlayController& controller, QWidget* parent) + : QDialog(parent), m_controller(controller) +{ + setWindowTitle(tr("Project+ Quick Play")); + setMinimumSize(560, 320); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(18, 18, 18, 18); + layout->setSpacing(12); + auto* preview = new QLabel(tr("Quick Play pairs players through the Dolphin lobby, or a custom " + "coordinator chosen in Quick Play Settings. The host creates a " + "room and the opponent joins automatically.")); + preview->setWordWrap(true); + layout->addWidget(preview); + + m_status = new QLabel; + m_status->setTextFormat(Qt::PlainText); + m_status->setAlignment(Qt::AlignCenter); + m_status->setWordWrap(true); + // Reserve space for multiline errors even while displaying the short Searching text. + m_status->setMinimumHeight(m_status->fontMetrics().lineSpacing() * 5); + layout->addWidget(m_status, 1); + + // Regions one per line in the first column, the host preference in the second. + m_details = new QWidget; + auto* details = new QHBoxLayout(m_details); + details->setContentsMargins(0, 0, 0, 0); + details->setSpacing(48); + m_region = new QLabel; + m_region->setObjectName(QStringLiteral("quickplay_search_region")); + m_region->setTextFormat(Qt::PlainText); + m_region->setAlignment(Qt::AlignLeft | Qt::AlignTop); + m_host_preference = new QLabel; + m_host_preference->setObjectName(QStringLiteral("quickplay_search_host_preference")); + m_host_preference->setTextFormat(Qt::PlainText); + m_host_preference->setAlignment(Qt::AlignLeft | Qt::AlignTop); + details->addStretch(); + details->addWidget(m_region, 0, Qt::AlignTop); + details->addWidget(m_host_preference, 0, Qt::AlignTop); + details->addStretch(); + layout->addWidget(m_details); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::rejected, this, &QuickPlayDialog::reject); + layout->addWidget(buttons); + + m_animation_timer.setInterval(400); + connect(&m_animation_timer, &QTimer::timeout, this, [this] { + m_dots = m_dots % 3 + 1; + UpdateSearchingText(); + }); + connect(&m_controller, &QuickPlayController::StateChanged, this, &QuickPlayDialog::UpdateState); + UpdateState(m_controller.GetState()); +} + +void QuickPlayDialog::done(int result) +{ + // Covers Cancel, Escape, window close, and programmatic dialog completion. + m_animation_timer.stop(); + // Successful acceptance transfers presentation to the ordinary lobby. Rejection + // remains an explicit user cancellation. + if (result != QDialog::Accepted) + m_controller.Cancel(); + QDialog::done(result); +} + +void QuickPlayDialog::UpdateState(QuickPlayController::State state) +{ + m_animation_timer.stop(); + + const auto& regions = m_controller.GetRegions(); + if (!m_controller.GetMatchRegion().isEmpty() || regions.size() == 1) + { + const auto& region = + m_controller.GetMatchRegion().isEmpty() ? regions.front() : m_controller.GetMatchRegion(); + m_region->setText(tr("Region:\n%1").arg(QuickPlayMatchmaker::GetRegionLabel(region))); + } + else + { + QStringList labels; + for (const auto& region : regions) + labels.append(QuickPlayMatchmaker::GetRegionLabel(region)); + m_region->setText(tr("Regions:\n%1").arg(labels.join(QLatin1Char('\n')))); + } + const auto& preference = m_controller.GetHostPreference(); + m_host_preference->setText(tr("Host preference:\n%1") + .arg(preference == QStringLiteral("host") ? tr("Prefer to host") : + preference == QStringLiteral("join") ? tr("Prefer to join") : + tr("No preference"))); + m_details->setVisible(state != QuickPlayController::State::Idle); + + switch (state) + { + case QuickPlayController::State::Idle: + m_status->setText(tr("Ready for Quick Play.")); + break; + case QuickPlayController::State::Searching: + m_dots = 1; + UpdateSearchingText(); + m_animation_timer.start(); + break; + case QuickPlayController::State::MatchedHost: + m_status->setText(tr("Opponent found...\nMatched as host")); + break; + case QuickPlayController::State::MatchedClient: + m_status->setText(tr("Opponent found...\nMatched as client")); + break; + case QuickPlayController::State::PreparingHandoff: + case QuickPlayController::State::WaitingForHandoff: + m_status->setText(tr("Opponent found. Preparing session...")); + break; + case QuickPlayController::State::ValidatingMatch: + m_status->setText(tr("Checking match...")); + break; + case QuickPlayController::State::CreatingHost: + m_status->setText(tr("Creating room...")); + break; + case QuickPlayController::State::WaitingForTraversalCode: + case QuickPlayController::State::PublishingHostCode: + m_status->setText(tr("Waiting for connection...")); + break; + case QuickPlayController::State::WaitingForOpponent: + m_status->setText(tr("Room ready. Waiting for opponent...")); + break; + case QuickPlayController::State::WaitingForHost: + m_status->setText(tr("Waiting for host...")); + break; + case QuickPlayController::State::Connecting: + m_status->setText(tr("Connecting...")); + break; + case QuickPlayController::State::NetPlayConnected: + case QuickPlayController::State::NetPlayOwned: + accept(); + return; + case QuickPlayController::State::Error: + m_status->setText(tr("Error\n%1").arg(m_controller.GetErrorMessage())); + break; + } + + // Grow for longer messages or larger fonts without shrinking on the next state change. + layout()->activate(); + resize(size().expandedTo(sizeHint())); +} + +void QuickPlayDialog::UpdateSearchingText() +{ + QString text = + tr("Searching (%1)").arg(m_controller.GetBackendLabel()) + QString(m_dots, QLatin1Char('.')); + switch (m_controller.GetSearchStage()) + { + case QuickPlayMatchmaker::SearchStage::JoinOnly: + text += QLatin1Char('\n') + tr("Looking for a host to join. Listing yourself in %n s.", "", + m_controller.GetSearchStageSeconds()); + break; + case QuickPlayMatchmaker::SearchStage::JoinListed: + text += QLatin1Char('\n') + tr("No host was waiting, so you are listed too. You host only if " + "your opponent also prefers to join."); + break; + case QuickPlayMatchmaker::SearchStage::Normal: + break; + } + if (!m_controller.GetSearchNotice().isEmpty()) + text += QLatin1Char('\n') + m_controller.GetSearchNotice(); + m_status->setText(text); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayDialog.h b/Source/Core/DolphinQt/QuickPlay/QuickPlayDialog.h new file mode 100644 index 000000000000..964287f3e979 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayDialog.h @@ -0,0 +1,33 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include "DolphinQt/QuickPlay/QuickPlayController.h" + +class QLabel; + +class QuickPlayDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit QuickPlayDialog(QuickPlayController& controller, QWidget* parent = nullptr); + + void done(int result) override; + +private: + void UpdateState(QuickPlayController::State state); + void UpdateSearchingText(); + + QuickPlayController& m_controller; + QLabel* m_status; + QWidget* m_details; + QLabel* m_region; + QLabel* m_host_preference; + QTimer m_animation_timer; + int m_dots = 1; +}; diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.cpp new file mode 100644 index 000000000000..1892c5528346 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.cpp @@ -0,0 +1,860 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Common/Config/Config.h" +#include "Common/Logging/Log.h" +#include "Common/StringUtil.h" +#include "Common/Version.h" +#include "Core/Config/NetplaySettings.h" +#include "UICommon/NetPlayIndex.h" + +namespace +{ +using Clock = std::chrono::steady_clock; +using namespace std::chrono_literals; + +// Readable in the ordinary server browser; the handshake tokens follow PPQP1. +constexpr std::string_view NAME_PREFIX = "P+ Quick Play \xc2\xb7 PPQP1 "; +constexpr std::string_view NAME_FILTER = "PPQP1"; +constexpr std::string_view GAME = "Project+"; +constexpr int PORT = 2626; +// Not the plain build version: ordinary browsers grey Quick Play listings out. +constexpr std::string_view VERSION_SUFFIX = "+ppqp1"; +// The origin digest token. Quick Play hands off to ordinary NetPlay, which has no origin; a +// listing with any other digest is ignored. +constexpr std::string_view DIGEST = "00000000"; +// Not secret: the derivation is open source. It keeps ordinary browsers out of the room. +constexpr std::string_view ROOM_KEY = "PPQP1:none"; + +// Same lifetime as the coordinator's queue ticket. +constexpr auto SEARCH_TIMEOUT = 180s; +constexpr auto SEARCH_LIST_INTERVAL = 3s; +constexpr auto CLAIM_LIST_INTERVAL = 2s; +constexpr auto CLAIM_TIMEOUT = 12s; +constexpr auto JOIN_PREFERENCE_SEARCH = 15s; +constexpr auto BLOCK_TIME = 60s; +// Consecutive lists without the peer's listing before it counts as gone. A player replacing +// its listing is briefly absent. +constexpr int PEER_MISSES = 3; +// The index times a session out after 15 s without a keepalive, but removes it only during a +// later list, up to 30 s after that. +constexpr double INDEX_SESSION_TIMEOUT = 15; +constexpr int LIST_FAILURES = 3; + +struct Listing +{ + char kind = 0; + // W and C. + std::uint16_t mask = 0; + char preference = 'n'; + // W: own nonce in b. C: claimed waiter in a, own nonce in b. A and H: waiter in a, claimer + // in b. + std::string a; + std::string b; + // A: 'w' when the waiter hosts, 'c' when the claimer hosts. + char host = 0; + std::string server_id; +}; + +bool IsHex(std::string_view value, std::size_t size) +{ + return value.size() == size && std::all_of(value.begin(), value.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); +} + +bool IsPreference(std::string_view value) +{ + return value == "h" || value == "n" || value == "j"; +} + +std::optional Parse(const NetPlaySession& session) +{ + if (!session.name.starts_with(NAME_PREFIX) || !session.has_password) + return std::nullopt; + const auto tokens = SplitString(session.name.substr(NAME_PREFIX.size()), ' '); + if (tokens.empty() || tokens[0].size() != 1) + return std::nullopt; + Listing listing; + listing.kind = tokens[0][0]; + listing.server_id = session.server_id; + std::string_view digest; + switch (listing.kind) + { + case 'W': + if (tokens.size() != 5 || !IsHex(tokens[1], 3) || !IsPreference(tokens[2]) || + !IsHex(tokens[4], 6)) + return std::nullopt; + listing.mask = static_cast(std::stoul(tokens[1], nullptr, 16)); + listing.preference = tokens[2][0]; + digest = tokens[3]; + listing.b = tokens[4]; + break; + case 'C': + if (tokens.size() != 6 || !IsHex(tokens[1], 3) || !IsPreference(tokens[2]) || + !IsHex(tokens[4], 6) || !IsHex(tokens[5], 6)) + return std::nullopt; + listing.mask = static_cast(std::stoul(tokens[1], nullptr, 16)); + listing.preference = tokens[2][0]; + digest = tokens[3]; + listing.a = tokens[4]; + listing.b = tokens[5]; + break; + case 'A': + if (tokens.size() != 5 || (tokens[1] != "w" && tokens[1] != "c") || !IsHex(tokens[3], 6) || + !IsHex(tokens[4], 6)) + return std::nullopt; + listing.host = tokens[1][0]; + digest = tokens[2]; + listing.a = tokens[3]; + listing.b = tokens[4]; + break; + case 'H': + if (tokens.size() != 4 || !IsHex(tokens[2], 6) || !IsHex(tokens[3], 6)) + return std::nullopt; + digest = tokens[1]; + listing.a = tokens[2]; + listing.b = tokens[3]; + break; + default: + return std::nullopt; + } + if (digest != DIGEST) + return std::nullopt; + return listing; +} + +std::uint16_t RegionMask(const QStringList& regions) +{ + const auto choices = QuickPlayMatchmaker::GetRegions(); + std::uint16_t mask = 0; + for (std::size_t i = 0; i < choices.size(); ++i) + { + if (regions.contains(choices[i].value)) + mask |= static_cast(1u << i); + } + return mask; +} + +// The first shared region in checklist order, which is also each player's wire order. +QString AgreedRegion(std::uint16_t mask) +{ + const auto choices = QuickPlayMatchmaker::GetRegions(); + for (std::size_t i = 0; i < choices.size(); ++i) + { + if (mask & (1u << i)) + return choices[i].value; + } + return {}; +} + +// The index knows 7 regions; ours live in the listing name. +std::string IndexRegion(std::uint16_t mask) +{ + const auto region = AgreedRegion(mask); + if (region == QStringLiteral("caribbean") || region == QStringLiteral("south-america")) + return "SA"; + if (region == QStringLiteral("europe")) + return "EU"; + if (region == QStringLiteral("asia")) + return "EA"; + if (region == QStringLiteral("australia")) + return "OC"; + return "NA"; +} + +QuickPlayMatchmaker::Result Searching(QString notice = {}) +{ + QuickPlayMatchmaker::Result result; + result.state = QuickPlayMatchmaker::State::Searching; + result.notice = std::move(notice); + return result; +} + +QuickPlayMatchmaker::Result Unreachable() +{ + return QuickPlayMatchmaker::Failure( + QObject::tr("Cannot reach the Dolphin lobby (NetPlay index), or the request timed out. " + "Check the connection, then cancel and try again."), + true); +} + +// Shared public IP (CGNAT, campus): every listing, a claim too, needs one of its 5 slots. +QString CapNotice() +{ + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: listing refused, too many sessions from this IP"); + return QObject::tr("Your network already has the most Dolphin lobby rooms allowed. " + "Waiting for a free slot."); +} + +QuickPlayMatchmaker::Result OpponentLeft() +{ + QuickPlayMatchmaker::Result result; + result.state = QuickPlayMatchmaker::State::Expired; + result.error = QObject::tr("The opponent left before the match started. Cancel and try again."); + return result; +} +} // namespace + +struct QuickPlayIndexMatchmaker::Attempt +{ + enum class Phase + { + Searching, + Waiting, + Claiming, + Matched, + Published, + }; + + Attempt() + { + index.SetErrorCallback([this] { listing_lost.store(true); }); + } + + // A step of the search: list when due, then claim, accept, yield or list as waiting. + // One worker step; a Searching result carries the current stage. + Result Step(); + // After the handoff: the peer's listing must still be there. + Result Validate(); + Result PollHost(const QString& id); + Result Publish(const QString& id, const std::string& host_code); + void Unlist(); + + std::atomic cancelled{false}; + // Set by the index keepalive thread when our listing expired or was refused. + std::atomic listing_lost{false}; + + // Immutable after Start. + std::uint16_t mask = 0; + char preference = 'n'; + std::string version; + std::string nonce; + std::string index_region; + Clock::time_point started; + + // Worker only. + Phase phase = Phase::Searching; + std::mt19937 random{std::random_device{}()}; + Clock::time_point next_list{}; + std::optional list_after; + Clock::time_point claim_deadline{}; + std::string target; + std::uint16_t target_mask = 0; + int target_misses = 0; + std::string waiter; + std::string claimer; + bool hosting = false; + QString match_id; + QString region; + int peer_misses = 0; + int failures = 0; + bool reached = false; + QString notice; + SearchStage stage = SearchStage::Normal; + int stage_seconds = 0; + std::map blocked; + std::optional listed; + // Last: its keepalive thread reports into listing_lost. + NetPlayIndex index; + +private: + Result Advance(); + void UpdateStage(Clock::time_point now); + bool List(std::vector* listings); + // Replaces our listing. Returns the index status on failure, else an empty string. + std::string ListAs(const std::string& tokens, bool handshake, const std::string& server_id = {}); + void RestoreListing(); + Result Search(const std::vector& listings, Clock::time_point now); + Result Wait(const std::vector& listings, Clock::time_point now); + Result AwaitAccept(const std::vector& listings, Clock::time_point now); + Result Claim(const Listing& waiting, Clock::time_point now); + Result Accept(const Listing& claim); + Result Matched(std::uint16_t peer_mask); + Result MatchedResult() const; + Result AddFailed(const std::string& status); + bool PeerPresent(const std::vector& listings) const; + const Listing* FindHost(const std::vector& listings) const; + Clock::duration WaitInterval(); +}; + +bool QuickPlayIndexMatchmaker::Attempt::List(std::vector* listings) +{ + const auto sessions = + index.List({{"name", std::string(NAME_FILTER)}, {"password", "1"}, {"version", version}}); + if (!sessions) + { + ++failures; + WARN_LOG_FMT(NETPLAY, "QuickPlay lobby: list failed ({})", index.GetLastError()); + return false; + } + failures = 0; + reached = true; + // Skip sessions that have already timed out (a crashed player). The freshest listing stands + // in for the server's clock; the index refreshes every live one every 5 s. + double newest = 0; + for (const auto& session : *sessions) + newest = std::max(newest, session.timestamp); + for (const auto& session : *sessions) + { + if (session.timestamp && session.timestamp < newest - INDEX_SESSION_TIMEOUT) + continue; + if (auto listing = Parse(session)) + listings->push_back(std::move(*listing)); + } + return true; +} + +std::string QuickPlayIndexMatchmaker::Attempt::ListAs(const std::string& tokens, bool handshake, + const std::string& server_id) +{ + Unlist(); + NetPlaySession session; + session.name = std::string(NAME_PREFIX) + tokens; + session.region = index_region; + session.method = "traversal"; + // Only H carries a room code. The others name a nonce, which no browser can decrypt. + session.server_id = server_id.empty() ? nonce : server_id; + session.game_id = GAME; + session.has_password = true; + // Handshake listings are in-game so that ordinary browsers can hide them. + session.in_game = handshake; + session.player_count = handshake ? 2 : 1; + session.port = PORT; + session.version = version; + listing_lost.store(false); + if (!index.Add(session)) + return index.GetLastError().empty() ? std::string("NO_RESPONSE") : index.GetLastError(); + listed = std::move(session); + return {}; +} + +void QuickPlayIndexMatchmaker::Attempt::Unlist() +{ + index.Remove(); + listed.reset(); +} + +void QuickPlayIndexMatchmaker::Attempt::RestoreListing() +{ + if (!listing_lost.exchange(false) || !listed) + return; + // The keepalive lost the session (timeout or index restart). List the same name again. + WARN_LOG_FMT(NETPLAY, "QuickPlay lobby: listing lost ({}); listing again", index.GetLastError()); + index.Remove(); + if (!index.Add(*listed)) + listed.reset(); +} + +Clock::duration QuickPlayIndexMatchmaker::Attempt::WaitInterval() +{ + return std::chrono::milliseconds{std::uniform_int_distribution(3000, 5000)(random)}; +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::AddFailed(const std::string& status) +{ + Unlist(); + if (status == "NO_RESPONSE" || status == "BAD_JSON") + return Unreachable(); + return Failure(QObject::tr("The Dolphin lobby refused the Quick Play listing (%1). " + "Cancel and try again.") + .arg(QString::fromStdString(status))); +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Step() +{ + auto result = Advance(); + if (result.state == State::Searching) + { + // A claim lasts a few seconds; keep showing the stage it started from. + if (phase != Phase::Claiming) + UpdateStage(Clock::now()); + result.stage = stage; + result.stage_seconds = stage_seconds; + } + return result; +} + +// Only Prefer to join changes over time: it looks for a waiting host before listing itself. +void QuickPlayIndexMatchmaker::Attempt::UpdateStage(Clock::time_point now) +{ + stage = SearchStage::Normal; + stage_seconds = 0; + if (preference != 'j') + return; + if (phase == Phase::Searching && (!list_after || now < *list_after)) + { + const auto left = list_after ? *list_after - now : Clock::duration(JOIN_PREFERENCE_SEARCH); + stage = SearchStage::JoinOnly; + stage_seconds = + std::max(1, static_cast(std::chrono::ceil(left).count())); + } + else if (phase == Phase::Waiting) + { + stage = SearchStage::JoinListed; + } +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Advance() +{ + const auto now = Clock::now(); + if (now - started >= SEARCH_TIMEOUT) + { + Unlist(); + Result result; + result.state = State::Expired; + result.error = QObject::tr("No opponent was found in the Dolphin lobby. Cancel and try again."); + return result; + } + if (now < next_list) + return Searching(notice); + RestoreListing(); + std::vector listings; + if (!List(&listings)) + { + next_list = now + SEARCH_LIST_INTERVAL; + if (!reached || failures >= LIST_FAILURES) + { + Unlist(); + return Unreachable(); + } + return Searching(notice); + } + std::erase_if(blocked, [now](const auto& entry) { return entry.second <= now; }); + switch (phase) + { + case Phase::Searching: + return Search(listings, now); + case Phase::Waiting: + return Wait(listings, now); + case Phase::Claiming: + return AwaitAccept(listings, now); + default: + return Searching(notice); + } +} + +QuickPlayMatchmaker::Result +QuickPlayIndexMatchmaker::Attempt::Search(const std::vector& listings, + Clock::time_point now) +{ + std::vector candidates; + for (const auto& listing : listings) + { + if (listing.kind == 'W' && listing.b != nonce && (listing.mask & mask) && + !blocked.contains(listing.b)) + candidates.push_back(&listing); + } + // Random among waiting players: the index has no FIFO order, and this spreads contention. + if (!candidates.empty()) + { + std::uniform_int_distribution pick(0, candidates.size() - 1); + return Claim(*candidates[pick(random)], now); + } + + // Nobody is waiting. Prefer to host: list now. No preference: list again after 0-2 s to damp + // simultaneous listing. Prefer to join: keep looking for a while first. + if (!list_after) + { + if (preference == 'h') + list_after = now; + else if (preference == 'j') + list_after = now + JOIN_PREFERENCE_SEARCH; + else + list_after = + now + std::chrono::milliseconds{std::uniform_int_distribution(0, 2000)(random)}; + } + if (now < *list_after) + { + next_list = std::min(*list_after, now + SEARCH_LIST_INTERVAL); + return Searching(notice); + } + const auto status = + ListAs(fmt::format("W {:03x} {} {} {}", mask, preference, DIGEST, nonce), false); + if (status == "TOO_MANY_SESSIONS") + { + notice = CapNotice(); + next_list = now + SEARCH_LIST_INTERVAL; + return Searching(notice); + } + if (!status.empty()) + return AddFailed(status); + notice.clear(); + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: waiting as {}", nonce); + phase = Phase::Waiting; + next_list = now + WaitInterval(); + return Searching(); +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Claim(const Listing& waiting, + Clock::time_point now) +{ + target = waiting.b; + target_mask = waiting.mask; + target_misses = 0; + const auto status = + ListAs(fmt::format("C {:03x} {} {} {} {}", mask, preference, DIGEST, target, nonce), true); + if (status == "TOO_MANY_SESSIONS") + { + notice = CapNotice(); + phase = Phase::Searching; + next_list = now + SEARCH_LIST_INTERVAL; + return Searching(notice); + } + if (!status.empty()) + return AddFailed(status); + notice.clear(); + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: {} claims {}", nonce, target); + phase = Phase::Claiming; + claim_deadline = now + CLAIM_TIMEOUT; + next_list = now + CLAIM_LIST_INTERVAL; + return Searching(); +} + +QuickPlayMatchmaker::Result +QuickPlayIndexMatchmaker::Attempt::Wait(const std::vector& listings, Clock::time_point now) +{ + // Accept one claim: the smallest nonce, so every claimer can predict the outcome. + const Listing* claim = nullptr; + for (const auto& listing : listings) + { + if (listing.kind == 'C' && listing.a == nonce && listing.b != nonce && (listing.mask & mask) && + (!claim || listing.b < claim->b)) + claim = &listing; + } + if (claim) + return Accept(*claim); + + // Two waiters never claim each other otherwise. The larger nonce yields to the smaller. + const Listing* smaller = nullptr; + for (const auto& listing : listings) + { + if (listing.kind == 'W' && listing.b < nonce && (listing.mask & mask) && + !blocked.contains(listing.b) && (!smaller || listing.b < smaller->b)) + smaller = &listing; + } + if (smaller) + { + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: {} yields to waiting {}", nonce, smaller->b); + return Claim(*smaller, now); + } + next_list = now + WaitInterval(); + return Searching(notice); +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Accept(const Listing& claim) +{ + // The coordinator's soft rule: the newcomer (claimer) hosts when the waiter prefers to join + // and the claimer does not, or the waiter has no preference and the claimer prefers to host. + // Otherwise the player who waited longer hosts. + const bool claimer_hosts = (preference == 'j' && claim.preference != 'j') || + (preference == 'n' && claim.preference == 'h'); + waiter = nonce; + claimer = claim.b; + hosting = !claimer_hosts; + const auto status = ListAs( + fmt::format("A {} {} {} {}", claimer_hosts ? 'c' : 'w', DIGEST, waiter, claimer), true); + if (!status.empty()) + { + // The claimer gives up after its timeout. Start over. + WARN_LOG_FMT(NETPLAY, "QuickPlay lobby: accept listing failed ({})", status); + phase = Phase::Searching; + list_after.reset(); + next_list = Clock::now(); + return Searching(notice); + } + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: {} accepted {}, {} hosts", waiter, claimer, + hosting ? "waiter" : "claimer"); + return Matched(claim.mask); +} + +QuickPlayMatchmaker::Result +QuickPlayIndexMatchmaker::Attempt::AwaitAccept(const std::vector& listings, + Clock::time_point now) +{ + const Listing* accepted = nullptr; + bool waiting = false; + for (const auto& listing : listings) + { + // A waiter that hosts can replace A with H before the next list. Only it can have + // published an H for this pair yet. + if ((listing.kind == 'A' || listing.kind == 'H') && listing.a == target && + (!accepted || listing.b == nonce)) + accepted = &listing; + else if (listing.kind == 'W' && listing.b == target) + waiting = true; + } + if (accepted && accepted->b == nonce) + { + waiter = target; + claimer = nonce; + hosting = accepted->kind == 'A' && accepted->host == 'c'; + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: {} accepted by {}, {} hosts", claimer, waiter, + hosting ? "claimer" : "waiter"); + return Matched(target_mask); + } + target_misses = waiting ? 0 : target_misses + 1; + if (accepted || target_misses >= 2 || now >= claim_deadline) + { + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: claim on {} not accepted", target); + blocked[target] = now + BLOCK_TIME; + Unlist(); + phase = Phase::Searching; + list_after.reset(); + return Search(listings, now); + } + next_list = now + CLAIM_LIST_INTERVAL; + return Searching(notice); +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Matched(std::uint16_t peer_mask) +{ + phase = Phase::Matched; + region = AgreedRegion(mask & peer_mask); + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: matched in {}", region.toStdString()); + match_id = QString::fromStdString(waiter + claimer); + peer_misses = 0; + next_list = {}; + notice.clear(); + return MatchedResult(); +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::MatchedResult() const +{ + Result result; + result.state = hosting ? State::MatchedHost : State::MatchedClient; + result.match_id = match_id; + result.region = region; + return result; +} + +bool QuickPlayIndexMatchmaker::Attempt::PeerPresent(const std::vector& listings) const +{ + const bool is_waiter = waiter == nonce; + return std::any_of(listings.begin(), listings.end(), [&](const Listing& listing) { + if (listing.a != waiter || listing.b != claimer) + return false; + // The waiter's listing is A until it publishes H; the claimer's is C until it does. + return listing.kind == 'H' || listing.kind == (is_waiter ? 'C' : 'A'); + }); +} + +const Listing* +QuickPlayIndexMatchmaker::Attempt::FindHost(const std::vector& listings) const +{ + const auto it = std::find_if(listings.begin(), listings.end(), [&](const Listing& listing) { + return listing.kind == 'H' && listing.a == waiter && listing.b == claimer; + }); + return it == listings.end() ? nullptr : &*it; +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Validate() +{ + for (int attempt = 0; attempt < PEER_MISSES && !cancelled.load(); ++attempt) + { + if (attempt) + { + for (int i = 0; i < 10 && !cancelled.load(); ++i) + std::this_thread::sleep_for(100ms); + } + RestoreListing(); + std::vector listings; + if (List(&listings) && PeerPresent(listings)) + return MatchedResult(); + } + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: opponent left before the handoff completed"); + Unlist(); + return OpponentLeft(); +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::PollHost(const QString& id) +{ + if (phase != Phase::Matched || hosting || id != match_id) + return Failure(QObject::tr("Invalid Quick Play client match.")); + Result result; + result.state = State::WaitingForHost; + result.match_id = match_id; + const auto now = Clock::now(); + if (now < next_list) + return result; + next_list = now + CLAIM_LIST_INTERVAL; + RestoreListing(); + std::vector listings; + if (!List(&listings)) + return failures >= LIST_FAILURES ? Unreachable() : result; + if (const auto* host = FindHost(listings)) + { + NetPlaySession session; + session.server_id = host->server_id; + const auto code = session.DecryptID(ROOM_KEY); + if (!code || !IsValidHostCode(QString::fromStdString(*code))) + return Failure(QObject::tr("The Quick Play host listed an unreadable room code. " + "Cancel and try again.")); + result.state = State::HostReady; + result.host_code = QString::fromStdString(*code); + return result; + } + if (PeerPresent(listings)) + { + peer_misses = 0; + return result; + } + if (++peer_misses < PEER_MISSES) + return result; + Unlist(); + auto left = OpponentLeft(); + left.match_id = match_id; + return left; +} + +QuickPlayMatchmaker::Result QuickPlayIndexMatchmaker::Attempt::Publish(const QString& id, + const std::string& host_code) +{ + if (phase != Phase::Matched || !hosting || id != match_id) + return Failure(QObject::tr("Invalid Quick Play traversal host code.")); + NetPlaySession code; + code.server_id = host_code; + code.EncryptID(ROOM_KEY); + const auto status = + ListAs(fmt::format("H {} {} {}", DIGEST, waiter, claimer), true, code.server_id); + if (!status.empty()) + { + if (status == "NO_RESPONSE" || status == "BAD_JSON") + return Unreachable(); + return Failure(QObject::tr("The Dolphin lobby refused the Quick Play room (%1). " + "Cancel and try again.") + .arg(QString::fromStdString(status))); + } + phase = Phase::Published; + Result result; + result.state = State::HostPublished; + return result; +} + +QuickPlayIndexMatchmaker::QuickPlayIndexMatchmaker(QObject* parent) : QuickPlayMatchmaker(parent) +{ +} + +QuickPlayIndexMatchmaker::~QuickPlayIndexMatchmaker() +{ + Cancel(); + // Join before QObject teardown: the worker may still queue Deliver() on this object, and the + // final removal of a listing is bounded by the HTTP timeouts. + m_worker.Shutdown(); +} + +void QuickPlayIndexMatchmaker::Start(const QStringList& regions, const QString& host_preference, + Callback callback) +{ + Cancel(); + auto attempt = std::make_shared(); + m_attempt = attempt; + m_callback = std::move(callback); + m_pending = true; + + attempt->mask = RegionMask(regions); + if (!attempt->mask) + { + Deliver(attempt, Failure(tr("The Dolphin lobby only knows the listed regions. Tick at least " + "one of them in Quick Play Settings."))); + return; + } + const auto normalized = NormalizeHostPreference(host_preference); + attempt->preference = normalized == QStringLiteral("host") ? 'h' : + normalized == QStringLiteral("join") ? 'j' : + 'n'; + attempt->version = Common::GetScmDescStr() + std::string(VERSION_SUFFIX); + attempt->nonce = fmt::format( + "{:06x}", std::uniform_int_distribution(0, 0xffffff)(attempt->random)); + attempt->index_region = IndexRegion(attempt->mask); + attempt->started = Clock::now(); + INFO_LOG_FMT(NETPLAY, "QuickPlay lobby: search started as {}", attempt->nonce); + m_worker.Push([this, attempt] { + if (!attempt->cancelled.load()) + Deliver(attempt, attempt->Step()); + }); +} + +void QuickPlayIndexMatchmaker::Push(std::function step) +{ + if (!m_attempt || m_pending || m_attempt->cancelled.load()) + return; + m_pending = true; + m_worker.Push([this, attempt = m_attempt, step = std::move(step)] { + if (!attempt->cancelled.load()) + Deliver(attempt, step(*attempt)); + }); +} + +void QuickPlayIndexMatchmaker::Poll() +{ + Push([](Attempt& attempt) { + return attempt.phase == Attempt::Phase::Matched ? attempt.Validate() : attempt.Step(); + }); +} + +void QuickPlayIndexMatchmaker::PollMatch(const QString& match_id) +{ + Push([match_id](Attempt& attempt) { return attempt.PollHost(match_id); }); +} + +void QuickPlayIndexMatchmaker::PublishHostCode(const QString& match_id, const QString& host_code) +{ + if (!IsValidHostCode(host_code)) + { + if (m_attempt && !m_pending) + { + m_pending = true; + Deliver(m_attempt, Failure(tr("Invalid Quick Play traversal host code."))); + } + return; + } + Push([match_id, code = host_code.toStdString()](Attempt& attempt) { + return attempt.Publish(match_id, code); + }); +} + +void QuickPlayIndexMatchmaker::Deliver(const std::shared_ptr& attempt, Result result) +{ + QMetaObject::invokeMethod( + this, + [this, attempt, result = std::move(result)]() mutable { + if (attempt != m_attempt || attempt->cancelled.load()) + return; + m_pending = false; + // Copy before invoking: callback may call Cancel(), clearing m_callback. + auto callback = m_callback; + if (callback) + callback(std::move(result)); + }, + Qt::QueuedConnection); +} + +void QuickPlayIndexMatchmaker::Cancel() +{ + auto attempt = std::exchange(m_attempt, nullptr); + m_callback = {}; + m_pending = false; + if (!attempt) + return; + attempt->cancelled.store(true); + // Serialized after any running step, so a listing added by it is removed too. + m_worker.Push([attempt] { attempt->Unlist(); }); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.h b/Source/Core/DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.h new file mode 100644 index 000000000000..51f8c54e1ef1 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayIndexMatchmaker.h @@ -0,0 +1,48 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include +#include + +#include "Common/WorkQueueThread.h" +#include "DolphinQt/QuickPlay/QuickPlayMatchmaker.h" + +// Dolphin lobby backend: a rendezvous through the NetPlay index (NetPlay/IndexServer), with no +// server of our own. Searchers exchange short-lived listings whose names carry the handshake: +// W waiting: region mask, host preference, origin digest, own nonce +// C claim: region mask, host preference, origin digest, claimed waiter, own nonce +// A accepted: who hosts, origin digest, waiter, claimer +// H host ready: origin digest, waiter, claimer; server_id is the traversal code, obfuscated +// with a key derived from the protocol +// The origin digest is always 00000000: the handoff is ordinary NetPlay. +// A searcher claims a random compatible W, or lists its own W. A waiter accepts one claim, and +// only then do both players leave their local game. Each player has at most one listing. +// GUI-thread API; the state machine and every index request run on one serialized worker. +class QuickPlayIndexMatchmaker final : public QuickPlayMatchmaker +{ +public: + explicit QuickPlayIndexMatchmaker(QObject* parent = nullptr); + ~QuickPlayIndexMatchmaker() override; + + void Start(const QStringList& regions, const QString& host_preference, + Callback callback) override; + void Poll() override; + void PollMatch(const QString& match_id) override; + void PublishHostCode(const QString& match_id, const QString& host_code) override; + void Cancel() override; + +private: + struct Attempt; + void Push(std::function step); + void Deliver(const std::shared_ptr& attempt, Result result); + + std::shared_ptr m_attempt; + Callback m_callback; + bool m_pending = false; + Common::AsyncWorkThread m_worker{"QuickPlay lobby"}; +}; diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayLauncher.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlayLauncher.cpp new file mode 100644 index 000000000000..5f330cc9af2e --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayLauncher.cpp @@ -0,0 +1,102 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlayLauncher.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include "Common/FileUtil.h" +#include "DolphinQt/GameList/GameListModel.h" +#include "UICommon/GameFile.h" + +namespace +{ +// Official Project+ v3.2 Netplay content names the launcher "Project+ Netplay Launcher.dol"; +// this repository's Data/user copy is "Netplay Launcher.dol". The first existing name wins. +constexpr std::array NETPLAY_LAUNCHER_NAMES{"Project+ Netplay Launcher.dol", + "Netplay Launcher.dol"}; + +QuickPlayLauncherResult FindNamedLauncher(const GameListModel& model, const QString& directory, + const QString& filename) +{ + const QString expected_path = QDir(directory).filePath(filename); + // A DOL's game ID is "ID-" plus its file name without extension. + const std::string game_id = "ID-" + QFileInfo(filename).completeBaseName().toStdString(); + std::shared_ptr launcher; + int candidates = 0; + for (int row = 0; row < model.rowCount(QModelIndex()); ++row) + { + const auto game = model.GetGameFile(row); + if (!game) + continue; + + const QFileInfo file(QString::fromStdString(game->GetFilePath())); + if (file.fileName() != filename || file.dir().canonicalPath() != directory) + continue; + + // Count entries at the expected location before validating their metadata. Even conflicting + // stale entries must fail closed instead of choosing whichever valid entry appears first. + if (++candidates > 1) + { + return {nullptr, + QCoreApplication::translate( + "QuickPlayLauncher", + "Multiple Project+ Netplay Launcher entries were found. Remove duplicate " + "game-list entries and refresh the game list, or repair your Project+ " + "installation.")}; + } + + if (game->GetGameID().find("Offline Launcher") != std::string::npos) + continue; + + // Canonical equality rejects a launcher symlink pointing outside the directory or at Offline. + // Filesystem checks also reject cached entries whose file has since been deleted. + if (!game->IsValid() || game->GetPlatform() != DiscIO::Platform::ELFOrDOL || + game->GetFileName() != filename.toStdString() || game->GetGameID() != game_id || + !file.isFile() || !file.isReadable() || file.canonicalFilePath() != expected_path) + continue; + + launcher = game; + } + return {launcher, {}}; +} +} // namespace + +QuickPlayLauncherResult FindPPlusNetplayLauncher(const GameListModel& model) +{ + const auto failure = [] { + return QuickPlayLauncherResult{ + nullptr, QCoreApplication::translate( + "QuickPlayLauncher", + "Project+ Netplay Launcher could not be found or verified. Add the Launcher " + "folder to the game list paths, refresh the game list or repair your " + "Project+ installation.")}; + }; + const QString launcher_path = QString::fromStdString(File::GetUserPath(D_LAUNCHERS_IDX)); + if (launcher_path.isEmpty()) + return failure(); + + const QString directory = QDir(launcher_path).canonicalPath(); + if (directory.isEmpty()) + return failure(); + + // Official content first, then this build's Data/user name; the first file present wins. + for (const auto name : NETPLAY_LAUNCHER_NAMES) + { + const QString filename = QString::fromLatin1(name); + if (!QFileInfo(QDir(directory).filePath(filename)).isFile()) + continue; + auto result = FindNamedLauncher(model, directory, filename); + if (!result.game && result.error.isEmpty()) + return failure(); + return result; + } + return failure(); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayLauncher.h b/Source/Core/DolphinQt/QuickPlay/QuickPlayLauncher.h new file mode 100644 index 000000000000..0e3f2d89ba12 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayLauncher.h @@ -0,0 +1,24 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include + +class GameListModel; +namespace UICommon +{ +class GameFile; +} + +struct QuickPlayLauncherResult +{ + std::shared_ptr game; + QString error; +}; + +// Call on the GUI thread. A failed lookup always returns a null game and an actionable error. +// This checks launcher identity, not content compatibility; recheck before any future real host. +QuickPlayLauncherResult FindPPlusNetplayLauncher(const GameListModel& model); diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayMatchmaker.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlayMatchmaker.cpp new file mode 100644 index 000000000000..6f347e183d01 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayMatchmaker.cpp @@ -0,0 +1,121 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlayMatchmaker.h" + +#include + +#include "Common/Config/Config.h" +#include "Common/TraversalProto.h" +#include "Core/Config/NetplaySettings.h" + +bool QuickPlayMatchmaker::IsField(const QString& value, qsizetype limit) +{ + return !value.isEmpty() && value.size() <= limit && + std::all_of(value.begin(), value.end(), + [](QChar c) { return c.unicode() >= 0x21 && c.unicode() <= 0x7e; }); +} + +std::array QuickPlayMatchmaker::GetRegions() +{ + return {{ + {QStringLiteral("us-east"), tr("United States — East")}, + {QStringLiteral("us-south"), tr("United States — South")}, + {QStringLiteral("us-midwest"), tr("United States — Midwest")}, + {QStringLiteral("us-west"), tr("United States — West")}, + {QStringLiteral("canada"), tr("Canada")}, + {QStringLiteral("mexico-central-america"), tr("Mexico / Central America")}, + {QStringLiteral("caribbean"), tr("Caribbean")}, + {QStringLiteral("south-america"), tr("South America")}, + {QStringLiteral("europe"), tr("Europe")}, + {QStringLiteral("asia"), tr("Asia")}, + {QStringLiteral("australia"), tr("Australia")}, + }}; +} + +QString QuickPlayMatchmaker::GetRegionLabel(const QString& region) +{ + for (const auto& choice : GetRegions()) + { + if (region == choice.value) + return choice.label; + } + return region; +} + +QString QuickPlayMatchmaker::NormalizeRegion(const QString& region) +{ + const QString normalized = region.trimmed().toLower(); + if (normalized == QStringLiteral("na-east")) + return QStringLiteral("us-east"); + if (normalized == QStringLiteral("na-central")) + return QStringLiteral("us-midwest"); + if (normalized == QStringLiteral("na-west")) + return QStringLiteral("us-west"); + if (normalized == QStringLiteral("eu")) + return QStringLiteral("europe"); + // Retired: tick the individual US regions instead. Invalid, so dropped from saved lists. + if (normalized == QStringLiteral("us-nationwide")) + return {}; + + for (const auto& region_choice : GetRegions()) + { + if (normalized == region_choice.value) + return region_choice.value; + } + // Exact matching makes even case changes significant for unknown custom regions. + return region; +} + +bool QuickPlayMatchmaker::IsValidRegion(const QString& region) +{ + return IsField(region, 32); +} + +QStringList QuickPlayMatchmaker::ParseRegions(const QString& comma_separated) +{ + QStringList regions; + for (const auto& token : comma_separated.split(QLatin1Char(','))) + { + const QString region = NormalizeRegion(token.trimmed()); + if (regions.size() < MAX_REGIONS && IsValidRegion(region) && !regions.contains(region)) + regions.append(region); + } + return regions; +} + +QStringList QuickPlayMatchmaker::LoadRegions() +{ + const auto saved = + ParseRegions(QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_REGIONS))); + if (!saved.isEmpty()) + return saved; + const auto legacy = + ParseRegions(QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_REGION))); + if (!legacy.isEmpty()) + return legacy; + return ParseRegions(QString::fromStdString(Config::NETPLAY_QUICKPLAY_REGION.GetDefaultValue())); +} + +QString QuickPlayMatchmaker::NormalizeHostPreference(const QString& preference) +{ + const QString normalized = preference.trimmed().toLower(); + if (normalized == QStringLiteral("host") || normalized == QStringLiteral("join")) + return normalized; + return QStringLiteral("none"); +} + +QString QuickPlayMatchmaker::LoadHostPreference() +{ + return NormalizeHostPreference( + QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_HOST_PREFERENCE))); +} + +bool QuickPlayMatchmaker::IsValidHostCode(const QString& code) +{ + return !code.isEmpty() && code.size() <= static_cast(Common::NETPLAY_CODE_SIZE) && + std::all_of(code.begin(), code.end(), [](QChar c) { + const auto u = c.unicode(); + return (u >= 'A' && u <= 'Z') || (u >= 'a' && u <= 'z') || (u >= '0' && u <= '9'); + }); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlayMatchmaker.h b/Source/Core/DolphinQt/QuickPlay/QuickPlayMatchmaker.h new file mode 100644 index 000000000000..f8e558b55d91 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlayMatchmaker.h @@ -0,0 +1,100 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +// One Quick Play rendezvous backend. GUI-thread API; every call answers with at most one +// queued Result per request, never synchronously. QuickPlayController drives the sequence: +// Start, Poll until matched, Poll once more after the handoff (validation), then the host +// publishes its traversal code or the client polls the match until the code is ready. +class QuickPlayMatchmaker : public QObject +{ +public: + enum class State + { + Searching, + MatchedHost, + MatchedClient, + HostPublished, + WaitingForHost, + HostReady, + Expired, + Error, + }; + // A search phase that changes what the player can be matched as, shown while it lasts. + enum class SearchStage + { + Normal, + // Prefer to join: only claims waiting players, and lists itself after stage_seconds. + JoinOnly, + // Prefer to join, after JoinOnly found nobody: listed too, and hosts only for an opponent + // who also prefers to join. + JoinListed, + }; + struct Result + { + State state = State::Error; + QString error; + // Capabilities: never display or log these values. + QString ticket; + QString match_id; + QString host_code; + // Agreed region of a match; displayable. + QString region; + // Searching only: a displayable status note, e.g. why this player is not listed yet. + QString notice; + // Searching only. + SearchStage stage = SearchStage::Normal; + int stage_seconds = 0; + // Error only: the backend itself could not be reached, so another backend may be tried. + bool unreachable = false; + }; + using Callback = std::function; + + using QObject::QObject; + + struct Region + { + QString value; + QString label; + }; + static std::array GetRegions(); + static QString GetRegionLabel(const QString& region); + static QString NormalizeRegion(const QString& region); + static bool IsValidRegion(const QString& region); + static constexpr qsizetype MAX_REGIONS = 12; + // Normalized, valid, distinct tokens in order, at most MAX_REGIONS. + static QStringList ParseRegions(const QString& comma_separated); + // Saved regions, else the legacy single region when none are saved yet, else the default. + static QStringList LoadRegions(); + // "host", "none" or "join"; anything else is "none". + static QString NormalizeHostPreference(const QString& preference); + static QString LoadHostPreference(); + static bool IsValidHostCode(const QString& code); + // Non-empty printable ASCII without spaces, at most `limit` characters. + static bool IsField(const QString& value, qsizetype limit); + static Result Failure(QString error, bool unreachable = false) + { + Result result; + result.error = std::move(error); + result.unreachable = unreachable; + return result; + } + + virtual void Start(const QStringList& regions, const QString& host_preference, + Callback callback) = 0; + virtual void Poll() = 0; + virtual void PollMatch(const QString& match_id) = 0; + virtual void PublishHostCode(const QString& match_id, const QString& host_code) = 0; + // Releases the rendezvous (ticket or listing) without waiting. Drops any queued result. + virtual void Cancel() = 0; +}; diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlaySession.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlaySession.cpp new file mode 100644 index 000000000000..eb62d7a62aae --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlaySession.cpp @@ -0,0 +1,247 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlaySession.h" + +#include +#include + +#include + +#include "Common/Config/Config.h" +#include "Common/Logging/Log.h" +#include "Common/ScopeGuard.h" +#include "Core/Config/NetplaySettings.h" +#include "Core/Core.h" +#include "Core/System.h" +#include "DolphinQt/GameList/GameListModel.h" +#include "DolphinQt/NetPlay/NetPlayDialog.h" +#include "DolphinQt/QtUtils/ModalMessageBox.h" +#include "DolphinQt/QuickPlay/QuickPlayController.h" +#include "DolphinQt/QuickPlay/QuickPlayDialog.h" +#include "DolphinQt/QuickPlay/QuickPlayLauncher.h" +#include "DolphinQt/QuickPlay/QuickPlaySettingsDialog.h" +#include "DolphinQt/Settings.h" +#include "DolphinQt/ToolBar.h" +#include "UICommon/GameFile.h" + +QuickPlaySession::QuickPlaySession(QuickPlayWindow& window, Core::System& system, QWidget* parent, + NetPlayDialog* netplay_dialog, + const GameListModel& game_list_model, ToolBar* tool_bar) + : QObject(parent), m_window(window), m_system(system), m_dialog_parent(parent), + m_netplay_dialog(netplay_dialog), m_game_list_model(game_list_model), m_tool_bar(tool_bar) +{ + connect(tool_bar, &ToolBar::QuickPlayPressed, this, &QuickPlaySession::StartQuickPlay); + connect(tool_bar, &ToolBar::QuickPlaySettingsPressed, this, + &QuickPlaySession::ShowQuickPlaySettings); +} + +QuickPlaySession::~QuickPlaySession() = default; + +void QuickPlaySession::Shutdown() +{ + // Destroy orchestration while its MainWindow callbacks and NetPlay dialog are still alive. + delete m_quickplay_dialog; + m_quickplay_dialog = nullptr; + delete m_quickplay_controller; + m_quickplay_controller = nullptr; +} + +bool QuickPlaySession::OwnsNetPlay() const +{ + return m_quickplay_setup_attempt || m_quickplay_host_attempt || m_quickplay_joining || + (m_quickplay_controller && + m_quickplay_controller->GetState() != QuickPlayController::State::Idle && + m_quickplay_controller->GetState() != QuickPlayController::State::Error); +} + +void QuickPlaySession::OnNetPlayQuit() +{ + const auto attempt = std::exchange(m_quickplay_setup_attempt, 0); + m_quickplay_join_attempt = 0; + m_quickplay_host_attempt = 0; + if (!m_quickplay_controller) + return; + if (attempt) + m_quickplay_controller->OnHostClosed(attempt); + else if (m_quickplay_controller->GetState() != QuickPlayController::State::Idle) + m_quickplay_controller->Cancel(); +} + +void QuickPlaySession::ShowQuickPlaySettings() +{ + if (m_quickplay_controller && + m_quickplay_controller->GetState() != QuickPlayController::State::Idle) + return; + + // Settings access must not construct a controller or start a matchmaking attempt. + m_tool_bar->SetQuickPlaySettingsOpen(true); + if (!m_quickplay_settings_dialog) + { + m_quickplay_settings_dialog = new QuickPlaySettingsDialog(m_dialog_parent); + connect(m_quickplay_settings_dialog, &QDialog::finished, this, + [this] { m_tool_bar->SetQuickPlaySettingsOpen(false); }); + } + m_quickplay_settings_dialog->Open(); +} + +void QuickPlaySession::StartQuickPlay() +{ + if (m_window.IsExitRequested() || m_window.IsBootPending()) + return; + if (m_netplay_dialog->isVisible()) + { + // An open NetPlay room takes precedence: bring it forward instead of searching. + m_netplay_dialog->raise(); + m_netplay_dialog->activateWindow(); + return; + } + if (Settings::Instance().GetNetPlayServer() || Settings::Instance().GetNetPlayClient()) + return; + if (m_quickplay_settings_dialog && m_quickplay_settings_dialog->isVisible()) + return; + if (m_quickplay_controller && + m_quickplay_controller->GetState() != QuickPlayController::State::Idle) + { + // An existing attempt is revealed without starting another one. + m_quickplay_dialog->show(); + m_quickplay_dialog->raise(); + m_quickplay_dialog->activateWindow(); + return; + } + if (!Core::IsUninitialized(m_system)) + { + ModalMessageBox::critical(m_dialog_parent, tr("Quick Play"), + tr("Close the running game before starting Quick Play.")); + return; + } + + m_tool_bar->SetQuickPlayActive(true); + if (!m_quickplay_controller) + { + m_quickplay_controller = new QuickPlayController( + [this](u64 attempt) { return StartQuickPlayHost(attempt); }, + [this](u64 attempt) { CancelQuickPlayHost(attempt); }, + [this](const QString& code) { + return JoinQuickPlayHost(code) ? + QString{} : + tr("Could not join the Quick Play host. Cancel and try again."); + }, + this); + connect(m_quickplay_controller, &QuickPlayController::PrepareHandoff, this, + &QuickPlaySession::PrepareQuickPlayHandoff); + connect(m_netplay_dialog, &NetPlayDialog::HostTraversalChanged, m_quickplay_controller, + &QuickPlayController::OnHostTraversalChanged); + connect(m_netplay_dialog, &NetPlayDialog::QuickPlayOpponentConnected, m_quickplay_controller, + &QuickPlayController::OnHostOpponentConnected); + connect(m_quickplay_controller, &QuickPlayController::StateChanged, this, + [this](QuickPlayController::State state) { + if (state == QuickPlayController::State::Error) + { + INFO_LOG_FMT(NETPLAY, "QuickPlay: error shown: {}", + m_quickplay_controller->GetErrorMessage().toStdString()); + } + if (state == QuickPlayController::State::NetPlayConnected && + m_quickplay_setup_attempt) + { + // The ordinary NetPlay room now belongs to the user. + m_quickplay_host_attempt = 0; + m_quickplay_join_attempt = 0; + m_quickplay_controller->OnLaunchOwned(std::exchange(m_quickplay_setup_attempt, 0)); + } + m_tool_bar->SetQuickPlayActive(state != QuickPlayController::State::Idle && + state != QuickPlayController::State::NetPlayOwned); + }); + m_quickplay_dialog = new QuickPlayDialog(*m_quickplay_controller, m_dialog_parent); + } + + m_quickplay_controller->Start(); + m_quickplay_dialog->show(); + m_quickplay_dialog->raise(); + m_quickplay_dialog->activateWindow(); +} + +void QuickPlaySession::PrepareQuickPlayHandoff(u64 attempt) +{ + if (!m_quickplay_controller->OwnsAttempt(attempt)) + return; + if (m_window.IsExitRequested() || m_window.IsBootPending() || + Settings::Instance().IsBatchModeEnabled() || Settings::Instance().GetNetPlayClient() || + Settings::Instance().GetNetPlayServer() || m_netplay_dialog->isVisible()) + { + m_quickplay_controller->OnHandoffReady( + attempt, tr("Close the current NetPlay session before Quick Play connects.")); + return; + } + if (!Core::IsUninitialized(m_system)) + { + m_quickplay_controller->OnHandoffReady( + attempt, tr("Close the running game before Quick Play connects.")); + return; + } + m_quickplay_setup_attempt = attempt; + // Continue outside the controller's own state change. + QTimer::singleShot(0, this, [this, attempt] { + if (!m_window.IsExitRequested()) + m_quickplay_controller->OnHandoffReady(attempt); + }); +} + +bool QuickPlaySession::JoinQuickPlayHost(const QString& host_code) +{ + if (!m_quickplay_setup_attempt || !m_quickplay_controller || + !m_quickplay_controller->OwnsAttempt(m_quickplay_setup_attempt) || m_quickplay_joining || + m_quickplay_host_attempt || !QuickPlayMatchmaker::IsValidHostCode(host_code) || + !Core::IsUninitialized(m_system) || Settings::Instance().GetNetPlayServer() || + Settings::Instance().GetNetPlayClient() || m_netplay_dialog->isVisible()) + return false; + + if (!FindPPlusNetplayLauncher(m_game_list_model).game) + return false; + m_quickplay_joining = true; + m_quickplay_join_attempt = m_quickplay_setup_attempt; + const auto previous_method = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); + const auto previous_code = Config::Get(Config::NETPLAY_HOST_CODE); + Common::ScopeGuard restore([&] { + Config::SetBaseOrCurrent(Config::NETPLAY_TRAVERSAL_CHOICE, previous_method); + Config::SetBaseOrCurrent(Config::NETPLAY_HOST_CODE, previous_code); + m_quickplay_joining = false; + }); + // Same effective settings as a traversal browser session. The connect port is unused for + // traversal, and the configured traversal server/port stay in effect. Restore manual values. + Config::SetBaseOrCurrent(Config::NETPLAY_TRAVERSAL_CHOICE, std::string("traversal")); + Config::SetBaseOrCurrent(Config::NETPLAY_HOST_CODE, host_code.toStdString()); + return m_window.NetPlayJoinInternal(); +} + +QString QuickPlaySession::StartQuickPlayHost(u64 attempt) +{ + if (m_quickplay_setup_attempt != attempt || !m_quickplay_controller || + !m_quickplay_controller->OwnsAttempt(attempt) || !Core::IsUninitialized(m_system) || + Settings::Instance().GetNetPlayServer() || Settings::Instance().GetNetPlayClient() || + m_netplay_dialog->isVisible()) + return tr("Close the current game or NetPlay session before hosting Quick Play."); + const auto launcher = FindPPlusNetplayLauncher(m_game_list_model); + if (!launcher.game) + return launcher.error; + m_quickplay_host_attempt = attempt; + if (!m_window.NetPlayHostInternal(*launcher.game, attempt)) + return tr("Could not create the Quick Play NetPlay host. Cancel and try again."); + return {}; +} + +void QuickPlaySession::CancelQuickPlayHost(u64 attempt) +{ + if (!attempt) + return; + const bool owns_session = + m_quickplay_host_attempt == attempt || m_quickplay_join_attempt == attempt; + if (m_quickplay_setup_attempt == attempt) + m_quickplay_setup_attempt = 0; + if (!owns_session) + return; + m_quickplay_host_attempt = 0; + m_quickplay_join_attempt = 0; + m_netplay_dialog->hide(); + m_window.NetPlayQuit(); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlaySession.h b/Source/Core/DolphinQt/QuickPlay/QuickPlaySession.h new file mode 100644 index 000000000000..fc6b5f832400 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlaySession.h @@ -0,0 +1,85 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include "Common/CommonTypes.h" + +class GameListModel; +class NetPlayDialog; +class QuickPlayController; +class QuickPlayDialog; +class QuickPlaySettingsDialog; +class QWidget; +class ToolBar; + +namespace Core +{ +class System; +} + +namespace UICommon +{ +class GameFile; +} + +// What Quick Play needs from the main window, and nothing else. MainWindow implements it. +class QuickPlayWindow +{ +public: + virtual ~QuickPlayWindow() = default; + + virtual bool IsExitRequested() const = 0; + virtual bool IsBootPending() const = 0; + + virtual void NetPlayQuit() = 0; + virtual bool NetPlayHostInternal(const UICommon::GameFile& game, u64 quickplay_attempt) = 0; + virtual bool NetPlayJoinInternal() = 0; +}; + +// GUI-thread owner of Quick Play in the desktop frontend: the toolbar entry points, the search +// dialog, and the handoff into an ordinary NetPlay room (host or join, then cancel or close). +class QuickPlaySession final : public QObject +{ + Q_OBJECT + +public: + QuickPlaySession(QuickPlayWindow& window, Core::System& system, QWidget* parent, + NetPlayDialog* netplay_dialog, const GameListModel& game_list_model, + ToolBar* tool_bar); + ~QuickPlaySession() override; + + // MainWindow hooks. + void Shutdown(); + // Manual NetPlay host and join are refused while Quick Play owns NetPlay. + bool OwnsNetPlay() const; + // After NetPlayQuit destroyed the client and server. + void OnNetPlayQuit(); + +private: + void ShowQuickPlaySettings(); + void StartQuickPlay(); + void PrepareQuickPlayHandoff(u64 attempt); + bool JoinQuickPlayHost(const QString& host_code); + QString StartQuickPlayHost(u64 attempt); + void CancelQuickPlayHost(u64 attempt); + + QuickPlayWindow& m_window; + Core::System& m_system; + QWidget* const m_dialog_parent; + NetPlayDialog* const m_netplay_dialog; + const GameListModel& m_game_list_model; + ToolBar* const m_tool_bar; + + // The matched attempt that may create the NetPlay room, until the room is connected. + u64 m_quickplay_setup_attempt = 0; + bool m_quickplay_joining = false; + u64 m_quickplay_host_attempt = 0; + u64 m_quickplay_join_attempt = 0; + QuickPlayController* m_quickplay_controller = nullptr; + QuickPlayDialog* m_quickplay_dialog = nullptr; + QuickPlaySettingsDialog* m_quickplay_settings_dialog = nullptr; +}; diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlaySettingsDialog.cpp b/Source/Core/DolphinQt/QuickPlay/QuickPlaySettingsDialog.cpp new file mode 100644 index 000000000000..9277ec5d74a2 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlaySettingsDialog.cpp @@ -0,0 +1,281 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "DolphinQt/QuickPlay/QuickPlaySettingsDialog.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Common/Config/Config.h" +#include "Common/HttpRequest.h" +#include "Core/Config/NetplaySettings.h" +#include "DolphinQt/QuickPlay/QuickPlayClient.h" + +QuickPlaySettingsDialog::QuickPlaySettingsDialog(QWidget* parent) : QDialog(parent) +{ + setWindowTitle(tr("Quick Play Settings")); + setWindowModality(Qt::WindowModal); + setMinimumWidth(460); + auto* layout = new QVBoxLayout(this); + layout->setSpacing(16); + auto* form = new QFormLayout; + form->setVerticalSpacing(14); + m_matchmaking = new QComboBox; + m_matchmaking->setObjectName(QStringLiteral("quickplay_matchmaking")); + m_matchmaking->addItem(tr("Dolphin lobby (default)"), QStringLiteral("lobby")); + m_matchmaking->addItem(tr("Custom coordinator"), QStringLiteral("coordinator")); + m_matchmaking->setToolTip( + tr("The Dolphin lobby needs no server of your own: players meet through Dolphin's NetPlay " + "server browser. A custom coordinator keeps a fair queue and finer region matching.")); + form->addRow(tr("Matchmaking:"), m_matchmaking); + m_coordinator = new QLineEdit; + m_coordinator->setObjectName(QStringLiteral("quickplay_coordinator")); + m_coordinator->setPlaceholderText(QStringLiteral("https://your-coordinator.example")); + // Hidden by default: a private coordinator URL is effectively an access secret. + m_coordinator->setEchoMode(QLineEdit::Password); + m_show_coordinator = new QPushButton(tr("Show")); + m_show_coordinator->setCheckable(true); + auto* coordinator_row = new QHBoxLayout; + coordinator_row->addWidget(m_coordinator); + coordinator_row->addWidget(m_show_coordinator); + form->addRow(tr("Coordinator Server:"), coordinator_row); + m_test = new QPushButton(tr("Test Connection to Coordinator Server")); + m_test->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + form->addRow(QString(), m_test); + auto* region_list = new QWidget; + region_list->setObjectName(QStringLiteral("quickplay_regions")); + m_region_grid = new QGridLayout(region_list); + m_region_grid->setContentsMargins(0, 0, 0, 0); + m_region_grid->setVerticalSpacing(8); + m_region_grid->setHorizontalSpacing(16); + for (const auto& region : QuickPlayMatchmaker::GetRegions()) + AddRegion(region.value, region.label); + m_canonical_region_count = m_regions.size(); + form->addRow(tr("Regions:"), region_list); + m_host_preference = new QComboBox; + m_host_preference->setObjectName(QStringLiteral("quickplay_host_preference")); + m_host_preference->addItem(tr("Prefer to host"), QStringLiteral("host")); + m_host_preference->addItem(tr("No preference"), QStringLiteral("none")); + m_host_preference->addItem(tr("Prefer to join"), QStringLiteral("join")); + m_host_preference->setToolTip( + tr("Used when it complements your opponent's preference. Otherwise the player who " + "searched first hosts. It never delays a match.")); + form->addRow(tr("Host Preference:"), m_host_preference); + layout->addLayout(form); + + m_result = new QLabel; + m_result->setObjectName(QStringLiteral("quickplay_settings_result")); + m_result->setTextFormat(Qt::PlainText); + m_result->setWordWrap(true); + layout->addWidget(m_result); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel); + layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::accepted, this, &QuickPlaySettingsDialog::Save); + connect(buttons, &QDialogButtonBox::rejected, this, &QuickPlaySettingsDialog::reject); + connect(m_test, &QPushButton::clicked, this, &QuickPlaySettingsDialog::TestConnection); + connect(m_show_coordinator, &QPushButton::toggled, this, [this](bool shown) { + m_coordinator->setEchoMode(shown ? QLineEdit::Normal : QLineEdit::Password); + m_show_coordinator->setText(shown ? tr("Hide") : tr("Show")); + }); + connect(m_matchmaking, &QComboBox::currentIndexChanged, this, + &QuickPlaySettingsDialog::UpdateMatchmaking); + connect(m_coordinator, &QLineEdit::textChanged, this, [this] { + InvalidateTest(); + m_result->clear(); + }); +} + +QuickPlaySettingsDialog::~QuickPlaySettingsDialog() +{ + InvalidateTest(); + // Join before QObject teardown; queued deliveries are removed by QObject destruction. + // Only final owner destruction waits, never Save/Cancel/Escape/window close. + m_worker.Shutdown(); +} + +void QuickPlaySettingsDialog::AddRegion(const QString& value, const QString& label) +{ + auto* box = new QCheckBox(label); + const int index = static_cast(m_regions.size()); + m_region_grid->addWidget(box, index / 2, index % 2); + m_regions.emplace_back(box, value); +} + +void QuickPlaySettingsDialog::UpdateMatchmaking() +{ + const bool coordinator = m_matchmaking->currentData().toString() == QStringLiteral("coordinator"); + m_coordinator->setEnabled(coordinator); + m_show_coordinator->setEnabled(coordinator); + m_test->setEnabled(coordinator); + InvalidateTest(); + m_result->clear(); +} + +void QuickPlaySettingsDialog::InvalidateTest() +{ + if (m_test_cancelled) + m_test_cancelled->store(true); +} + +void QuickPlaySettingsDialog::Open() +{ + if (!isVisible()) + { + m_coordinator->setText( + QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_COORDINATOR))); + m_show_coordinator->setChecked(false); + const int matchmaking = m_matchmaking->findData( + QString::fromStdString(Config::Get(Config::NETPLAY_QUICKPLAY_MATCHMAKING))); + m_matchmaking->setCurrentIndex(matchmaking < 0 ? 0 : matchmaking); + UpdateMatchmaking(); + // Remove previous custom entries when reopening after a config change. + while (m_regions.size() > m_canonical_region_count) + { + delete m_regions.back().first; + m_regions.pop_back(); + } + const auto saved = QuickPlayMatchmaker::LoadRegions(); + for (const auto& region : saved) + { + const auto it = std::find_if(m_regions.begin(), m_regions.end(), + [&](const auto& entry) { return entry.second == region; }); + if (it == m_regions.end()) + AddRegion(region, tr("Custom (preserved): %1").arg(region)); + } + for (const auto& [box, value] : m_regions) + box->setChecked(saved.contains(value)); + m_host_preference->setCurrentIndex( + m_host_preference->findData(QuickPlayMatchmaker::LoadHostPreference())); + m_result->clear(); + } + show(); + raise(); + activateWindow(); +} + +void QuickPlaySettingsDialog::done(int result) +{ + InvalidateTest(); + QDialog::done(result); +} + +void QuickPlaySettingsDialog::Save() +{ + const auto matchmaking = m_matchmaking->currentData().toString().toStdString(); + QString normalized; + const auto error = QuickPlayClient::NormalizeCoordinatorUrl(m_coordinator->text(), &normalized); + // The lobby ignores the coordinator field; an unusable entry there is left unsaved. + if (!error.isEmpty() && matchmaking == "coordinator") + { + m_result->setText(m_coordinator->text().trimmed().isEmpty() ? + tr("Custom coordinator needs a coordinator URL.") : + error); + m_coordinator->setFocus(); + return; + } + QStringList regions; + for (const auto& [box, value] : m_regions) + { + if (box->isChecked()) + regions.append(value); + } + if (regions.isEmpty()) + { + m_result->setText(tr("Choose at least one region.")); + return; + } + if (regions.size() > QuickPlayMatchmaker::MAX_REGIONS) + { + m_result->setText(tr("Choose at most %1 regions.").arg(QuickPlayMatchmaker::MAX_REGIONS)); + return; + } + const auto joined = regions.join(QLatin1Char(',')).toStdString(); + const auto host_preference = m_host_preference->currentData().toString().toStdString(); + + // Match Dolphin's effective-layer convention, and persist even if a command-line override + // supplied the initial value. Saving explicitly replaces that override for this run too. + Config::SetBaseOrCurrent(Config::NETPLAY_QUICKPLAY_MATCHMAKING, matchmaking); + Config::SetBase(Config::NETPLAY_QUICKPLAY_MATCHMAKING, matchmaking); + if (error.isEmpty()) + { + Config::SetBaseOrCurrent(Config::NETPLAY_QUICKPLAY_COORDINATOR, normalized.toStdString()); + Config::SetBase(Config::NETPLAY_QUICKPLAY_COORDINATOR, normalized.toStdString()); + } + Config::SetBaseOrCurrent(Config::NETPLAY_QUICKPLAY_REGIONS, joined); + Config::SetBase(Config::NETPLAY_QUICKPLAY_REGIONS, joined); + // Migrated into QuickPlayRegions above. + Config::DeleteKey(Config::LayerType::Base, Config::NETPLAY_QUICKPLAY_REGION); + Config::SetBaseOrCurrent(Config::NETPLAY_QUICKPLAY_HOST_PREFERENCE, host_preference); + Config::SetBase(Config::NETPLAY_QUICKPLAY_HOST_PREFERENCE, host_preference); + Config::Save(); + accept(); +} + +void QuickPlaySettingsDialog::TestConnection() +{ + QString normalized; + const auto error = QuickPlayClient::NormalizeCoordinatorUrl(m_coordinator->text(), &normalized); + if (!error.isEmpty()) + { + m_result->setText(error); + return; + } + m_test->setEnabled(false); + m_result->setText(tr("Testing connection…")); + const auto cancelled = std::make_shared>(false); + m_test_cancelled = cancelled; + const auto endpoint = + QUrl(normalized, QUrl::StrictMode).toString(QUrl::FullyEncoded).toStdString() + "/health"; + m_worker.Push([this, cancelled, endpoint] { + constexpr auto timeout = std::chrono::milliseconds{5000}; + Common::HttpRequest http(timeout, + [cancelled](auto, auto, auto, auto) { return !cancelled->load(); }); + http.SetSensitive(true); + http.SetTimeout(timeout); + QString message = tr("Unable to reach coordinator, or the connection timed out (5 seconds). " + "Check the URL and your network connection."); + if (!cancelled->load() && http.IsValid()) + { + const auto response = http.Get(endpoint, {}, Common::HttpRequest::AllowedReturnCodes::All); + if (response) + { + const int status = http.GetLastResponseCode(); + if (status != 200) + message = tr("Coordinator health check failed (HTTP %1).").arg(status); + else + { + const auto document = + QJsonDocument::fromJson(QByteArray(reinterpret_cast(response->data()), + static_cast(response->size()))); + message = + document.isObject() && document.object().value(QStringLiteral("status")).toString() == + QStringLiteral("ok") ? + tr("Connected") : + tr("Coordinator returned an invalid health response."); + } + } + } + QMetaObject::invokeMethod( + this, + [this, cancelled, message] { + m_test->setEnabled(m_coordinator->isEnabled()); + if (!cancelled->load()) + m_result->setText(message); + }, + Qt::QueuedConnection); + }); +} diff --git a/Source/Core/DolphinQt/QuickPlay/QuickPlaySettingsDialog.h b/Source/Core/DolphinQt/QuickPlay/QuickPlaySettingsDialog.h new file mode 100644 index 000000000000..7443ff8fbaa1 --- /dev/null +++ b/Source/Core/DolphinQt/QuickPlay/QuickPlaySettingsDialog.h @@ -0,0 +1,53 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include + +#include "Common/WorkQueueThread.h" + +class QCheckBox; +class QComboBox; +class QGridLayout; +class QLabel; +class QLineEdit; +class QPushButton; + +// Retained by its owning window: closing never joins the HTTP worker. +class QuickPlaySettingsDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit QuickPlaySettingsDialog(QWidget* parent = nullptr); + ~QuickPlaySettingsDialog() override; + void Open(); + void done(int result) override; + +private: + void Save(); + void TestConnection(); + void InvalidateTest(); + void UpdateMatchmaking(); + + QComboBox* m_matchmaking; + QLineEdit* m_coordinator; + QPushButton* m_show_coordinator; + void AddRegion(const QString& value, const QString& label); + + // Listed regions first, then preserved custom ones. + std::vector> m_regions; + QGridLayout* m_region_grid; + std::size_t m_canonical_region_count; + QComboBox* m_host_preference; + QLabel* m_result; + QPushButton* m_test; + std::shared_ptr> m_test_cancelled; + Common::AsyncWorkThread m_worker{"QuickPlay health"}; +}; diff --git a/Source/Core/DolphinQt/ToolBar.cpp b/Source/Core/DolphinQt/ToolBar.cpp index 41aab1adb728..4cfb20c3b742 100644 --- a/Source/Core/DolphinQt/ToolBar.cpp +++ b/Source/Core/DolphinQt/ToolBar.cpp @@ -8,6 +8,9 @@ #include #include +#include +#include +#include #include "Core/Core.h" #include "Core/System.h" @@ -17,6 +20,61 @@ static QSize ICON_SIZE(32, 32); +static QIcon MakeQuickPlaySettingsIcon(const QIcon& quickplay_icon) +{ + // Use Quickplay's strongest solid accent so gradients do not dull the gear. + const QImage reference = quickplay_icon.pixmap(ICON_SIZE).toImage(); + QColor accent = Qt::gray; + int strongest_accent = -1; + for (int y = 0; y < reference.height(); ++y) + { + for (int x = 0; x < reference.width(); ++x) + { + const QColor color = reference.pixelColor(x, y); + if (color.alpha() < 250) + continue; + const int strength = color.hsvSaturation() * color.value(); + if (strength > strongest_accent) + { + strongest_accent = strength; + accent = color; + accent.setAlpha(255); + } + } + } + + // Draw the same gear on every platform, independent of installed icon themes. + QPainterPath gear; + gear.addEllipse(QRectF(8, 10, 16, 16)); + for (int tooth = 0; tooth < 8; ++tooth) + { + QPainterPath rectangle; + rectangle.addRoundedRect(QRectF(13, 6, 6, 6), 1, 1); + QTransform rotation; + rotation.translate(16, 18); + rotation.rotate(tooth * 45); + rotation.translate(-16, -18); + gear = gear.united(rotation.map(rectangle)); + } + QPainterPath hole; + hole.addEllipse(QRectF(12, 14, 8, 8)); + gear = gear.subtracted(hole); + + QIcon result; + for (int scale : {1, 2, 3, 4}) + { + QPixmap pixmap(ICON_SIZE * scale); + pixmap.setDevicePixelRatio(scale); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.fillPath(gear, accent); + painter.end(); + result.addPixmap(pixmap); + } + return result; +} + ToolBar::ToolBar(QWidget* parent) : QToolBar(parent) { setToolButtonStyle(Qt::ToolButtonTextUnderIcon); @@ -124,7 +182,22 @@ void ToolBar::MakeActions() m_fullscreen_action = addAction(tr("FullScr"), this, &ToolBar::FullScreenPressed); m_screenshot_action = addAction(tr("ScrShot"), this, &ToolBar::ScreenShotPressed); m_netplay_setup_action = addAction(tr("Netplay"), this, &ToolBar::NetPlaySetupDialogPressed); - + addSeparator(); + m_quickplay_action = addAction(tr("Quickplay"), this, &ToolBar::QuickPlayPressed); + m_quickplay_action->setToolTip(tr("Start Quick Play")); + m_quickplay_settings_action = + addAction(tr("Quick Play Settings"), this, &ToolBar::QuickPlaySettingsPressed); + m_quickplay_settings_action->setToolTip(tr("Quick Play Settings")); + auto* settings_button = qobject_cast(widgetForAction(m_quickplay_settings_action)); + // Reserve the same caption row as Quick Play so their icons align vertically. + m_quickplay_settings_action->setIconText(QStringLiteral(" ")); + settings_button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + settings_button->setAccessibleName(tr("Quick Play Settings")); + settings_button->setFixedWidth(36); + + auto* quickplay_spacing = new QWidget(this); + quickplay_spacing->setFixedWidth(10); + addWidget(quickplay_spacing); addSeparator(); m_config_action = addAction(tr("Config"), this, &ToolBar::SettingsPressed); @@ -141,7 +214,7 @@ void ToolBar::MakeActions() {m_open_action, m_pause_play_action, m_stop_action, m_stop_action, m_fullscreen_action, m_screenshot_action, m_config_action, m_graphics_action, m_controllers_action, m_step_action, m_step_over_action, m_step_out_action, m_skip_action, m_show_pc_action, - m_set_pc_action, m_netplay_setup_action, m_updater_action}) + m_set_pc_action, m_netplay_setup_action, m_quickplay_action, m_updater_action}) { items.emplace_back(widgetForAction(action)); } @@ -173,6 +246,16 @@ void ToolBar::UpdatePausePlayButtonState(const bool playing_state) } } +void ToolBar::SetQuickPlaySettingsOpen(bool open) +{ + m_quickplay_action->setEnabled(!open); +} + +void ToolBar::SetQuickPlayActive(bool active) +{ + m_quickplay_settings_action->setEnabled(!active); +} + void ToolBar::UpdateIcons() { m_step_action->setIcon(Resources::GetThemeIcon("debugger_step_in")); @@ -196,6 +279,8 @@ void ToolBar::UpdateIcons() m_fullscreen_action->setIcon(Resources::GetThemeIcon("fullscreen")); m_screenshot_action->setIcon(Resources::GetThemeIcon("screenshot")); m_netplay_setup_action->setIcon(Resources::GetThemeIcon("netplay")); + m_quickplay_action->setIcon(Resources::GetThemeIcon("netplay")); + m_quickplay_settings_action->setIcon(MakeQuickPlaySettingsIcon(m_quickplay_action->icon())); m_config_action->setIcon(Resources::GetThemeIcon("config")); m_controllers_action->setIcon(Resources::GetThemeIcon("gcpad")); m_graphics_action->setIcon(Resources::GetThemeIcon("graphics")); diff --git a/Source/Core/DolphinQt/ToolBar.h b/Source/Core/DolphinQt/ToolBar.h index 465c96ee5663..b5ec5dff7ec4 100644 --- a/Source/Core/DolphinQt/ToolBar.h +++ b/Source/Core/DolphinQt/ToolBar.h @@ -20,6 +20,8 @@ class ToolBar final : public QToolBar explicit ToolBar(QWidget* parent = nullptr); void closeEvent(QCloseEvent*) override; + void SetQuickPlaySettingsOpen(bool open); + void SetQuickPlayActive(bool active); signals: void OpenPressed(); void RefreshPressed(); @@ -29,6 +31,8 @@ class ToolBar final : public QToolBar void FullScreenPressed(); void ScreenShotPressed(); void NetPlaySetupDialogPressed(); + void QuickPlayPressed(); + void QuickPlaySettingsPressed(); void SettingsPressed(); void ControllersPressed(); @@ -61,6 +65,8 @@ class ToolBar final : public QToolBar QAction* m_controllers_action; QAction* m_graphics_action; QAction* m_netplay_setup_action; + QAction* m_quickplay_action; + QAction* m_quickplay_settings_action; QAction* m_updater_action; QAction* m_step_action; diff --git a/Source/Core/UICommon/NetPlayIndex.cpp b/Source/Core/UICommon/NetPlayIndex.cpp index a1b0edf97612..ebde5ea48b04 100644 --- a/Source/Core/UICommon/NetPlayIndex.cpp +++ b/Source/Core/UICommon/NetPlayIndex.cpp @@ -116,6 +116,8 @@ NetPlayIndex::List(const std::map& filters) session.player_count = static_cast(player_count.get()); session.port = static_cast(port.get()); session.in_game = in_game.get(); + if (const auto& timestamp = entry.get("timestamp"); timestamp.is()) + session.timestamp = timestamp.get(); sessions.push_back(std::move(session)); } @@ -176,7 +178,9 @@ bool NetPlayIndex::Add(const NetPlaySession& session) fmt::arg("password", session.has_password), fmt::arg("method", session.method), fmt::arg("server_id", session.server_id), fmt::arg("in_game", session.in_game), fmt::arg("port", session.port), fmt::arg("player_count", session.player_count), - fmt::arg("version", Common::GetScmDescStr())), + fmt::arg("version", request.EscapeComponent(session.version.empty() ? + Common::GetScmDescStr() : + session.version))), {{"X-Is-Dolphin", "1"}}, Common::HttpRequest::AllowedReturnCodes::All); if (!response.has_value()) { diff --git a/Source/Core/UICommon/NetPlayIndex.h b/Source/Core/UICommon/NetPlayIndex.h index 13fb674a66e7..6e7c9651ad23 100644 --- a/Source/Core/UICommon/NetPlayIndex.h +++ b/Source/Core/UICommon/NetPlayIndex.h @@ -25,6 +25,8 @@ struct NetPlaySession int player_count = 0; int port = 0; + // Server time of the last keepalive, when the index reports it. + double timestamp = 0; bool has_password = false; bool in_game = false;