From 9329921bd07ab604873a64f97c7f042a2b7ea4fe Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 00:47:01 -0300 Subject: [PATCH 01/25] Add steamworks support --- .gitignore | 6 + Makefile | 38 +++- lib/Socket.hpp | 3 +- lib/SteamManager.cpp | 498 +++++++++++++++++++++++++++++++++++++++++++ lib/SteamManager.hpp | 137 ++++++++++++ lib/SteamSocket.cpp | 281 ++++++++++++++++++++++++ lib/SteamSocket.hpp | 117 ++++++++++ netplay/Messages.hpp | 6 +- targets/DllMain.cpp | 43 +++- targets/MainApp.cpp | 90 +++++++- targets/MainUi.cpp | 139 +++++++++++- targets/MainUi.hpp | 1 + 12 files changed, 1328 insertions(+), 31 deletions(-) create mode 100644 lib/SteamManager.cpp create mode 100644 lib/SteamManager.hpp create mode 100644 lib/SteamSocket.cpp create mode 100644 lib/SteamSocket.hpp diff --git a/.gitignore b/.gitignore index 22f9e90c..f3cc56dd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ TAGS *.png scripts/seqlists* debugging +CLAUDE.local.md +steam_api*.dll +steam_appid.txt +spike/ +scripts/.recipe-shell.sh +.env diff --git a/Makefile b/Makefile index 2ff20d86..3111260c 100644 --- a/Makefile +++ b/Makefile @@ -98,6 +98,34 @@ CC_FLAGS += -mmmx -msse -msse2 -msse3 -mssse3 # Linker flags LD_FLAGS = -m32 -static -lws2_32 -lpsapi -lwinpthread -lwinmm -lole32 -ldinput -lwininet -ldwmapi -lgdi32 +# ----- Steamworks (optional Steam connection method) ----- +# Disabled unless STEAM_SDK is set to the 'public' dir of the Steamworks SDK, e.g.: +# make STEAM_SDK=../SteamworksSDK/public +# When set, -DENABLE_STEAM is defined and the 32-bit steam_api import lib is linked. +# steam_api.dll (32-bit, from /redistributable_bin/) + a steam_appid.txt containing +# 411370 must be shipped next to MBAA.exe at runtime. All Steam code is #ifdef ENABLE_STEAM +# so builds without STEAM_SDK are byte-for-byte unaffected. +STEAM_SDK ?= +STEAM_LIB = +STEAM_PKG_ROOT = true +STEAM_PKG_FOLDER = true +ifneq ($(STEAM_SDK),) + DEFINES += -DENABLE_STEAM + INCLUDES += -I$(STEAM_SDK) + # MinGW links the MSVC import lib directly (verified). If a future toolchain refuses, + # generate one with: gendef steam_api.dll && dlltool -d steam_api.def -l libsteam_api.a + # Linked ONLY into the main exe + hook.dll (see their recipes), NOT the build-time tools + # (generator/debugger), so those don't acquire a steam_api.dll runtime dependency. + STEAM_LIB = $(STEAM_SDK)/../redistributable_bin/steam_api.lib + # Runtime files: steam_api.dll (32-bit) + steam_appid.txt(411370) must sit in the game + # dir (next to MBAA.exe / the main exe, which is also MBAA.exe's working dir) so both the + # launcher and the injected hook.dll can load Steam. We drop them next to the main exe and + # bundle a copy in the cccaster/ folder for the release archive. + STEAM_DLL = $(STEAM_SDK)/../redistributable_bin/steam_api.dll + STEAM_PKG_ROOT = cp -f $(STEAM_DLL) ./ && printf '411370' > ./steam_appid.txt + STEAM_PKG_FOLDER = cp -f $(STEAM_DLL) $(FOLDER)/ && printf '411370' > $(FOLDER)/steam_appid.txt +endif + # Build options # DEFINES += -DDISABLE_LOGGING # DEFINES += -DDISABLE_ASSERTS @@ -162,17 +190,19 @@ endif $(BINARY): $(addprefix $(BUILD_PREFIX)/,$(MAIN_OBJECTS)) res/icon.res rm -f $(filter-out $(BINARY),$(wildcard $(NAME)*.exe)) - $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) + $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) $(STEAM_LIB) @echo $(STRIP) $@ $(CHMOD_X) + $(STEAM_PKG_ROOT) @echo $(FOLDER)/$(DLL): $(addprefix $(BUILD_PREFIX)/,$(DLL_OBJECTS)) res/rollback.o targets/CallDraw.s | $(FOLDER) - $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ -shared $(LD_FLAGS) -ld3dx9 + $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ -shared $(LD_FLAGS) -ld3dx9 $(STEAM_LIB) @echo $(STRIP) $@ $(GRANT) + $(STEAM_PKG_FOLDER) @echo $(FOLDER)/$(LAUNCHER): tools/Launcher.cpp | $(FOLDER) @@ -229,7 +259,7 @@ res/icon.res: res/icon.rc res/icon.ico LOGGING_PREFIX = build_logging_$(BRANCH) DEBUGGER_LIB_OBJECTS = \ - $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o,$(LIB_OBJECTS))) + $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o,$(LIB_OBJECTS))) tools/$(DEBUGGER): tools/Debugger.cpp $(DEBUGGER_LIB_OBJECTS) $(CXX) -o $@ $(CC_FLAGS) $(LOGGING_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) \ @@ -241,7 +271,7 @@ tools/$(DEBUGGER): tools/Debugger.cpp $(DEBUGGER_LIB_OBJECTS) GENERATOR_LIB_OBJECTS = \ - $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o,$(LIB_OBJECTS))) + $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o,$(LIB_OBJECTS))) tools/$(GENERATOR): tools/Generator.cpp $(GENERATOR_LIB_OBJECTS) $(CXX) -o $@ $(CC_FLAGS) $(LOGGING_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) diff --git a/lib/Socket.hpp b/lib/Socket.hpp index d0e9df26..b2fbf9b0 100644 --- a/lib/Socket.hpp +++ b/lib/Socket.hpp @@ -53,7 +53,7 @@ class Socket }; // Socket protocol - ENUM ( Protocol, TCP, UDP, Smart ); + ENUM ( Protocol, TCP, UDP, Smart, Steam ); // Connection state ENUM ( State, Listening, Connecting, Connected, Disconnected ); @@ -82,6 +82,7 @@ class Socket bool isTCP() const { return ( protocol == Protocol::TCP ); } bool isUDP() const { return ( protocol == Protocol::UDP ); } bool isSmart() const { return ( protocol == Protocol::Smart ); } + bool isSteam() const { return ( protocol == Protocol::Steam ); } bool gotGoodRead() const { return _gotGoodRead; } virtual State getState() const { return _state; } virtual bool isConnecting() const { return isClient() && ( _state == State::Connecting ); } diff --git a/lib/SteamManager.cpp b/lib/SteamManager.cpp new file mode 100644 index 00000000..94b3474b --- /dev/null +++ b/lib/SteamManager.cpp @@ -0,0 +1,498 @@ +#ifdef ENABLE_STEAM + +#include "SteamManager.hpp" +#include "SteamSocket.hpp" +#include "Logger.hpp" + +#include "steam/steam_api.h" +// Flat C API for ALL Steam interface calls. On 32-bit MinGW the C++ interface vtable + +// auto-callback (STEAM_CALLBACK/CCallResult) ABI does not match the MSVC-built steam_api.dll +// The flat API is plain extern "C", and we use MANUAL callback dispatch below instead of +// letting the DLL call into our C++ vtables. +// See the steamworks-mingw-flat-api note. +#include "steam/steam_api_flat.h" + +#include +#include +#include +#include + +using namespace std; + + +// How often to pump dispatch + drain messages +#define PUMP_INTERVAL_MS ( 2 ) + +// Max messages drained per connection per pump (leftovers come next pump). +#define RECV_BATCH ( 32 ) + +// Lobby metadata keys: the shareable join code, and the host's SteamID64 +static const char *LOBBY_CODE_KEY = "cccaster_code"; +static const char *LOBBY_HOST_KEY = "host_id"; + +// Route Steam diagnostics into our log file +static void S_CALLTYPE steamNetDebugOutput ( ESteamNetworkingSocketsDebugOutputType type, const char *msg ) +{ + LOG ( "[SteamNet] %s", msg ? msg : "" ); +} + +static void S_CALLTYPE steamWarningHook ( int severity, const char *msg ) +{ + LOG ( "[SteamWarn:%d] %s", severity, msg ? msg : "" ); +} + +// Human-shareable join code +static string makeLobbyCode() +{ + static bool seeded = false; + if ( ! seeded ) + { + srand ( ( unsigned ) time ( nullptr ) ); + seeded = true; + } + static const char alphabet[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + string s; + for ( int i = 0; i < 6; ++i ) + s += alphabet[rand() % ( sizeof ( alphabet ) - 1 )]; + return s; +} + + +SteamManager& SteamManager::get() +{ + static SteamManager instance; + return instance; +} + +bool SteamManager::ref() +{ + if ( _refCount > 0 ) + { + ++_refCount; + return _inited; + } + + if ( ! SteamAPI_Init() ) + { + LOG ( "SteamAPI_Init() failed (Steam not running, app not owned, or no steam_appid.txt)" ); + return false; + } + + _inited = true; + _refCount = 1; + + // poll callbacks from POD structs in pump() rather than + // having steam_api.dll call back into us + SteamAPI_ManualDispatch_Init(); + _pipe = SteamAPI_GetHSteamPipe(); + + // Redirect Steam's stdout into our log + SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction ( + SteamNetworkingUtils(), k_ESteamNetworkingSocketsDebugOutputType_Warning, steamNetDebugOutput ); + SteamAPI_ISteamUtils_SetWarningMessageHook ( SteamUtils(), steamWarningHook ); + + // Disable ECN for extra Wine compatibility + SteamAPI_ISteamNetworkingUtils_SetGlobalConfigValueInt32 ( + SteamNetworkingUtils(), k_ESteamNetworkingConfig_ECN, 0 ); + + SteamAPI_ISteamNetworkingUtils_InitRelayNetworkAccess ( SteamNetworkingUtils() ); + + LOG ( "Steam initialized; user=%llu", ( unsigned long long ) getSteamID() ); + + startPump(); + return true; +} + +void SteamManager::deref() +{ + if ( _refCount <= 0 ) + return; + + --_refCount; + + if ( _refCount > 0 ) + return; + + stopPump(); + + SteamAPI_Shutdown(); + _inited = false; + _pipe = 0; + _lobbyCreateCall = _lobbyListCall = 0; + + LOG ( "Steam shut down" ); +} + +uint64_t SteamManager::getSteamID() const +{ + if ( ! _inited ) + return 0; + + return SteamAPI_ISteamUser_GetSteamID ( SteamUser() ); +} + +bool SteamManager::isRelayNetworkReady() const +{ + if ( ! _inited ) + return false; + + return ( SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus ( SteamNetworkingUtils(), nullptr ) + == k_ESteamNetworkingAvailability_Current ); +} + + +// ---- transport wrappers ---- + +uint32_t SteamManager::createListenSocketP2P ( int virtualPort ) +{ + if ( ! _inited ) + return 0; + + return SteamAPI_ISteamNetworkingSockets_CreateListenSocketP2P ( + SteamNetworkingSockets(), virtualPort, 0, nullptr ); +} + +void SteamManager::closeListenSocket ( uint32_t listenHandle ) +{ + if ( _inited && listenHandle ) + SteamAPI_ISteamNetworkingSockets_CloseListenSocket ( SteamNetworkingSockets(), listenHandle ); +} + +uint32_t SteamManager::connectP2P ( uint64_t peerSteamId, int virtualPort ) +{ + if ( ! _inited ) + return 0; + + SteamNetworkingIdentity identity; + identity.SetSteamID64 ( peerSteamId ); // inline, no DLL call + + return SteamAPI_ISteamNetworkingSockets_ConnectP2P ( + SteamNetworkingSockets(), identity, virtualPort, 0, nullptr ); +} + +bool SteamManager::acceptConnection ( uint32_t conn ) +{ + if ( ! _inited || ! conn ) + return false; + + return ( SteamAPI_ISteamNetworkingSockets_AcceptConnection ( SteamNetworkingSockets(), conn ) + == k_EResultOK ); +} + +void SteamManager::closeConnection ( uint32_t conn ) +{ + if ( _inited && conn ) + SteamAPI_ISteamNetworkingSockets_CloseConnection ( SteamNetworkingSockets(), conn, 0, nullptr, false ); +} + +bool SteamManager::sendMessage ( uint32_t conn, const void *data, size_t len, bool reliable ) +{ + if ( ! _inited || ! conn ) + return false; + + const int flags = ( reliable ? k_nSteamNetworkingSend_Reliable : k_nSteamNetworkingSend_Unreliable ); + + const EResult r = SteamAPI_ISteamNetworkingSockets_SendMessageToConnection ( + SteamNetworkingSockets(), conn, data, ( uint32 ) len, flags, nullptr ); + + return ( r == k_EResultOK ); +} + +uint64_t SteamManager::getConnectionPeer ( uint32_t conn ) const +{ + if ( ! _inited || ! conn ) + return 0; + + SteamNetConnectionInfo_t info; + if ( ! SteamAPI_ISteamNetworkingSockets_GetConnectionInfo ( SteamNetworkingSockets(), conn, &info ) ) + return 0; + + return info.m_identityRemote.GetSteamID64(); // inline accessor +} + + +// ---- registries ---- + +void SteamManager::registerConnection ( uint32_t conn, SteamSocket *socket ) +{ + if ( conn ) + _connections[conn] = socket; +} + +void SteamManager::unregisterConnection ( uint32_t conn ) +{ + _connections.erase ( conn ); +} + +void SteamManager::registerListen ( uint32_t listenHandle, SteamSocket *server ) +{ + if ( listenHandle ) + _listeners[listenHandle] = server; +} + +void SteamManager::unregisterListen ( uint32_t listenHandle ) +{ + _listeners.erase ( listenHandle ); +} + + +// ---- lobby / matchmaking ---- + +void SteamManager::hostLobby() +{ + if ( ! _inited ) + { + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyIsHost = true; + _lobbyState = LobbyState::Working; + _lobbyCode.clear(); + _lobbyId = 0; + + // Result matched by id in pump() + _lobbyCreateCall = SteamAPI_ISteamMatchmaking_CreateLobby ( SteamMatchmaking(), k_ELobbyTypePublic, 2 ); +} + +void SteamManager::onLobbyCreatedResult ( bool ok, uint64_t lobbyId ) +{ + if ( ! ok ) + { + LOG ( "CreateLobby failed" ); + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyId = lobbyId; + + if ( _lobbyCode.empty() ) + _lobbyCode = makeLobbyCode(); + + char hostId[32]; + snprintf ( hostId, sizeof ( hostId ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_CODE_KEY, _lobbyCode.c_str() ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY, hostId ); + + _lobbyState = LobbyState::Ready; + LOG ( "Lobby ready; code=%s", _lobbyCode.c_str() ); +} + +void SteamManager::joinLobbyByCode ( const std::string& code ) +{ + if ( ! _inited ) + { + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyIsHost = false; + _joinCode = code; + _lobbyPeerId = 0; + _lobbyState = LobbyState::Working; + + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_CODE_KEY, code.c_str(), k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListDistanceFilter ( + SteamMatchmaking(), k_ELobbyDistanceFilterWorldwide ); + + _lobbyListCall = SteamAPI_ISteamMatchmaking_RequestLobbyList ( SteamMatchmaking() ); +} + +void SteamManager::onLobbyListResult ( bool found, uint64_t lobbyId ) +{ + if ( ! found ) + { + LOG ( "No lobby found for code '%s'", _joinCode.c_str() ); + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyId = lobbyId; + + const char *hostId = SteamAPI_ISteamMatchmaking_GetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY ); + _lobbyPeerId = ( hostId && hostId[0] ) ? strtoull ( hostId, nullptr, 10 ) : 0; + + if ( _lobbyPeerId == 0 ) + { + LOG ( "Lobby found but host_id missing" ); + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyState = LobbyState::Ready; + LOG ( "Joined lobby; host=%llu", ( unsigned long long ) _lobbyPeerId ); +} + +void SteamManager::leaveLobby() +{ + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_LeaveLobby ( SteamMatchmaking(), _lobbyId ); + + _lobbyId = 0; + _lobbyPeerId = 0; + _lobbyCode.clear(); + _lobbyState = LobbyState::Idle; +} + + +// ---- callback routing ---- + +void SteamManager::onConnectionStatusChanged ( uint32_t conn, uint32_t listenHandle, + int newState, uint64_t peerSteamId ) +{ + switch ( newState ) + { + case k_ESteamNetworkingConnectionState_Connecting: + { + if ( listenHandle ) + { + const auto it = _listeners.find ( listenHandle ); + if ( it != _listeners.end() ) + it->second->onIncomingConnection ( conn, peerSteamId ); + else + closeConnection ( conn ); + } + break; + } + + case k_ESteamNetworkingConnectionState_Connected: + { + const auto it = _connections.find ( conn ); + if ( it != _connections.end() ) + it->second->onConnected(); + break; + } + + case k_ESteamNetworkingConnectionState_ClosedByPeer: + case k_ESteamNetworkingConnectionState_ProblemDetectedLocally: + { + const auto it = _connections.find ( conn ); + if ( it != _connections.end() ) + it->second->onClosed(); + else + closeConnection ( conn ); + break; + } + + default: + break; + } +} + + +// ---- pump (manual dispatch) ---- + +void SteamManager::startPump() +{ + if ( ! _pumpTimer ) + _pumpTimer.reset ( new Timer ( this ) ); + _pumpTimer->start ( PUMP_INTERVAL_MS ); +} + +void SteamManager::stopPump() +{ + _pumpTimer.reset(); +} + +void SteamManager::timerExpired ( Timer *timer ) +{ + if ( timer != _pumpTimer.get() ) + return; + + pump(); + + if ( _pumpTimer ) + _pumpTimer->start ( PUMP_INTERVAL_MS ); +} + +void SteamManager::pump() +{ + if ( ! _inited ) + return; + + // --- manual callback dispatch --- + SteamAPI_ManualDispatch_RunFrame ( _pipe ); + + CallbackMsg_t cb; + while ( SteamAPI_ManualDispatch_GetNextCallback ( _pipe, &cb ) ) + { + if ( cb.m_iCallback == SteamAPICallCompleted_t::k_iCallback ) + { + // Async call result (CreateLobby / RequestLobbyList) + const SteamAPICallCompleted_t *cc = ( const SteamAPICallCompleted_t * ) cb.m_pubParam; + void *result = malloc ( cc->m_cubParam ); + bool failed = false; + + if ( result && SteamAPI_ManualDispatch_GetAPICallResult ( + _pipe, cc->m_hAsyncCall, result, cc->m_cubParam, cc->m_iCallback, &failed ) ) + { + if ( _lobbyCreateCall && cc->m_hAsyncCall == _lobbyCreateCall ) + { + const LobbyCreated_t *r = ( const LobbyCreated_t * ) result; + _lobbyCreateCall = 0; + onLobbyCreatedResult ( ( ! failed && r->m_eResult == k_EResultOK ), r->m_ulSteamIDLobby ); + } + else if ( _lobbyListCall && cc->m_hAsyncCall == _lobbyListCall ) + { + const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; + _lobbyListCall = 0; + const bool found = ( ! failed && r->m_nLobbiesMatching > 0 ); + const uint64 lobbyId = + found ? SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), 0 ) : 0; + onLobbyListResult ( found, lobbyId ); + } + } + + free ( result ); + } + else if ( cb.m_iCallback == SteamNetConnectionStatusChangedCallback_t::k_iCallback ) + { + const SteamNetConnectionStatusChangedCallback_t *p = + ( const SteamNetConnectionStatusChangedCallback_t * ) cb.m_pubParam; + onConnectionStatusChanged ( p->m_hConn, p->m_info.m_hListenSocket, + ( int ) p->m_info.m_eState, + p->m_info.m_identityRemote.GetSteamID64() ); + } + + SteamAPI_ManualDispatch_FreeLastCallback ( _pipe ); + } + + // --- drain inbound messages --- + vector conns; + conns.reserve ( _connections.size() ); + for ( const auto& kv : _connections ) + conns.push_back ( kv.first ); + + for ( uint32_t conn : conns ) + { + if ( _connections.find ( conn ) == _connections.end() ) + continue; + + SteamNetworkingMessage_t *msgs[RECV_BATCH]; + const int n = SteamAPI_ISteamNetworkingSockets_ReceiveMessagesOnConnection ( + SteamNetworkingSockets(), conn, msgs, RECV_BATCH ); + + vector decoded; + decoded.reserve ( n > 0 ? n : 0 ); + for ( int i = 0; i < n; ++i ) + { + size_t consumed = 0; + MsgPtr msg = ::Protocol::decode ( ( const char * ) msgs[i]->m_pData, msgs[i]->m_cbSize, consumed ); + msgs[i]->Release(); // frees via the message's own fn ptr + if ( msg.get() ) + decoded.push_back ( msg ); + } + + for ( const MsgPtr& msg : decoded ) + { + const auto it = _connections.find ( conn ); + if ( it == _connections.end() ) + break; + it->second->onReceive ( msg ); + } + } +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamManager.hpp b/lib/SteamManager.hpp new file mode 100644 index 00000000..9a5156c1 --- /dev/null +++ b/lib/SteamManager.hpp @@ -0,0 +1,137 @@ +#pragma once + +#ifdef ENABLE_STEAM + +#include "Timer.hpp" +#include "Protocol.hpp" + +#include +#include +#include + + +class SteamSocket; + + +// Owns the Steamworks API lifecycle and is the ONLY translation unit that includes the +// Steam SDK headers. Everything Steam-related is wrapped behind plain-typed functions +// (uint32_t connection/listen handles, uint64_t SteamIDs) so the rest of CCCaster never +// pulls in Steam headers. Calls that pass/return CSteamID use the flat C API to avoid the +// MinGW<->MSVC ABI mismatch (see the steamworks-mingw-flat-api note). +// +// A self-driven repeating Timer pumps Steam's manual callback dispatch + drains incoming +// messages, so SteamSocket does not need to register with SocketManager (which select()s on an fd). +class SteamManager : private Timer::Owner +{ +public: + + static SteamManager& get(); + + // Ref-counted SteamAPI init. ref() returns false if Steam is unavailable (not running, + // app 411370 not owned, or no steam_appid.txt). Safe to call repeatedly. + bool ref(); + void deref(); + + bool isInitialized() const { return _inited; } + + // Local user's SteamID64 (0 if not initialized). + uint64_t getSteamID() const; + + // Whether the SDR relay network is ready (ping data current). ConnectP2P before this is + // ready tends to fail, so callers should gate on it. + bool isRelayNetworkReady() const; + + // ---- transport wrappers (all handles are plain ints; 0 == invalid) ---- + + // Host: open a P2P listen socket on a virtual port. Returns the listen handle. + uint32_t createListenSocketP2P ( int virtualPort ); + void closeListenSocket ( uint32_t listenHandle ); + + // Client: begin a P2P connection to a peer SteamID on a virtual port. Returns conn handle. + uint32_t connectP2P ( uint64_t peerSteamId, int virtualPort ); + + bool acceptConnection ( uint32_t conn ); + void closeConnection ( uint32_t conn ); + + // Send already-encoded bytes over a connection. reliable picks the Steam send flag. + bool sendMessage ( uint32_t conn, const void *data, size_t len, bool reliable ); + + // Peer SteamID for an established connection (0 if unknown). + uint64_t getConnectionPeer ( uint32_t conn ) const; + + // ---- lobby / matchmaking (driven by the launcher UI) ---- + // Asynchronous; poll lobbyState() until Ready/Failed. SteamManager's pump runs the + // underlying Steam callbacks, so the caller just needs to keep pumping the event loop. + enum class LobbyState { Idle, Working, Ready, Failed }; + + // Host: create a searchable lobby and advertise a short join code + our SteamID. + // On Ready, lobbyCode() returns the code to share. + void hostLobby(); + + // Client: find the lobby advertising the given code and resolve the host's SteamID. + // On Ready, lobbyPeerId() returns the host SteamID64 to ConnectP2P to. + void joinLobbyByCode ( const std::string& code ); + + void leaveLobby(); + + LobbyState lobbyState() const { return _lobbyState; } + const std::string& lobbyCode() const { return _lobbyCode; } + uint64_t lobbyPeerId() const { return _lobbyPeerId; } + + // Run one manual-dispatch + message-drain cycle. Normally driven by the internal pump + // timer once the EventManager loop is running, but callers (e.g. the lobby UI) can call + // it directly in a wait loop when no EventManager loop is pumping yet. + void pump(); + + // Called by the internal Steam callback shim (defined in the .cpp). + void onLobbyCreatedResult ( bool ok, uint64_t lobbyId ); + void onLobbyListResult ( bool found, uint64_t lobbyId ); + + // ---- routing registries (used by SteamSocket) ---- + + void registerConnection ( uint32_t conn, SteamSocket *socket ); + void unregisterConnection ( uint32_t conn ); + + void registerListen ( uint32_t listenHandle, SteamSocket *server ); + void unregisterListen ( uint32_t listenHandle ); + + // Called by the internal Steam callback shim (defined in the .cpp). + void onConnectionStatusChanged ( uint32_t conn, uint32_t listenHandle, int newState, uint64_t peerSteamId ); + +private: + + SteamManager() {} + + int _refCount = 0; + bool _inited = false; + + // Steam pipe for manual callback dispatch (HSteamPipe; 0 == none). + int _pipe = 0; + + // Pending async call-result ids (SteamAPICall_t) matched in the manual-dispatch pump. + uint64_t _lobbyCreateCall = 0; + uint64_t _lobbyListCall = 0; + + TimerPtr _pumpTimer; + + // Lobby state + LobbyState _lobbyState = LobbyState::Idle; + std::string _lobbyCode; // host's advertised code (valid when host + Ready) + std::string _joinCode; // code the client is searching for + uint64_t _lobbyId = 0; // current lobby (host or joined) + uint64_t _lobbyPeerId = 0; // resolved host SteamID (valid when client + Ready) + bool _lobbyIsHost = false; + + // conn handle -> the SteamSocket that owns it (client or accepted child) + std::unordered_map _connections; + + // listen handle -> the server SteamSocket that owns it + std::unordered_map _listeners; + + void startPump(); + void stopPump(); + + void timerExpired ( Timer *timer ) override; +}; + +#endif // ENABLE_STEAM diff --git a/lib/SteamSocket.cpp b/lib/SteamSocket.cpp new file mode 100644 index 00000000..e6ad5811 --- /dev/null +++ b/lib/SteamSocket.cpp @@ -0,0 +1,281 @@ +#ifdef ENABLE_STEAM + +#include "SteamSocket.hpp" +#include "SteamManager.hpp" +#include "Logger.hpp" + +#include +#include + +using namespace std; + + +#define LOG_STEAM_SOCKET(SOCKET, FORMAT, ...) \ + LOG ( "SteamSocket=%08x; conn=%u; listen=%u; peer=%llu; state=%s; " FORMAT, \ + SOCKET, SOCKET->_conn, SOCKET->_listenHandle, \ + ( unsigned long long ) SOCKET->_peerSteamId, SOCKET->_state, ## __VA_ARGS__ ) + + +// Encode a SteamID into the synthetic address string used everywhere CCCaster expects an +// IpAddrPort. Keeping addr non-empty also makes the base Socket's isClient()/isServer() +// (which key off address.addr.empty()) behave correctly. Port is unused for Steam. +std::string steamAddr ( uint64_t steamId ) +{ + return "steam:" + to_string ( steamId ); +} + +uint64_t steamIdFromAddr ( const IpAddrPort& address ) +{ + const std::string& a = address.addr; + if ( a.compare ( 0, 6, "steam:" ) != 0 ) + return 0; + return strtoull ( a.c_str() + 6, nullptr, 10 ); +} + +static SteamSocket *asSteam ( const SocketPtr& ptr ) +{ + return static_cast ( ptr.get() ); +} + + +// ---- constructors ---- + +SteamSocket::SteamSocket ( Socket::Owner *owner, int virtualPort ) + : Socket ( owner, IpAddrPort ( "", 0 ), Protocol::Steam, false ) + , _virtualPort ( virtualPort ) +{ + freeBuffer(); + _state = State::Listening; + + _listenHandle = SteamManager::get().createListenSocketP2P ( virtualPort ); + + if ( _listenHandle ) + SteamManager::get().registerListen ( _listenHandle, this ); + else + LOG_STEAM_SOCKET ( this, "createListenSocketP2P failed" ); +} + +SteamSocket::SteamSocket ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ) + : Socket ( owner, IpAddrPort ( steamAddr ( peerSteamId ), 0 ), Protocol::Steam, false ) + , _virtualPort ( virtualPort ) + , _peerSteamId ( peerSteamId ) +{ + freeBuffer(); + _state = State::Connecting; + + _conn = SteamManager::get().connectP2P ( peerSteamId, virtualPort ); + + if ( _conn ) + SteamManager::get().registerConnection ( _conn, this ); + else + LOG_STEAM_SOCKET ( this, "connectP2P failed" ); +} + +SteamSocket::SteamSocket ( ChildSocketEnum, Socket::Owner *owner, uint32_t conn, + uint64_t peerSteamId, int virtualPort ) + : Socket ( owner, IpAddrPort ( steamAddr ( peerSteamId ), 0 ), Protocol::Steam, false ) + , _virtualPort ( virtualPort ) + , _conn ( conn ) + , _peerSteamId ( peerSteamId ) +{ + freeBuffer(); + _state = State::Connecting; +} + +SteamSocket::~SteamSocket() +{ + disconnect(); +} + +void SteamSocket::disconnect() +{ + SteamManager& sm = SteamManager::get(); + + const uint32_t conn = _conn; + const uint32_t listen = _listenHandle; + + // Detach our children FIRST so their dtors don't try to reach back into us. + for ( auto& kv : _childSockets ) + asSteam ( kv.second )->_parentSocket = 0; + _childSockets.clear(); + _acceptedSocket.reset(); + + // Remove self from parent's child map. Hold a ref across the erase in case the parent + // map held the only reference to us (otherwise the rest of this function would run on a + // freed object). selfKeepAlive lives to the end of disconnect(). + SocketPtr selfKeepAlive; + if ( _parentSocket ) + { + const auto it = _parentSocket->_childSockets.find ( conn ); + if ( it != _parentSocket->_childSockets.end() ) + { + selfKeepAlive = it->second; + _parentSocket->_childSockets.erase ( it ); + } + _parentSocket = 0; + } + + if ( conn ) + { + sm.unregisterConnection ( conn ); + sm.closeConnection ( conn ); + _conn = 0; + } + + if ( listen ) + { + sm.unregisterListen ( listen ); + sm.closeListenSocket ( listen ); + _listenHandle = 0; + } + + Socket::disconnect(); +} + + +// ---- factories ---- + +SocketPtr SteamSocket::listen ( Socket::Owner *owner, int virtualPort ) +{ + return SocketPtr ( new SteamSocket ( owner, virtualPort ) ); +} + +SocketPtr SteamSocket::connect ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ) +{ + return SocketPtr ( new SteamSocket ( owner, peerSteamId, virtualPort ) ); +} + +SocketPtr SteamSocket::accept ( Socket::Owner *owner ) +{ + if ( ! _acceptedSocket ) + return 0; + + _acceptedSocket->owner = owner; + + SocketPtr ret; + _acceptedSocket.swap ( ret ); + return ret; +} + + +// ---- send ---- + +bool SteamSocket::sendEncoded ( const MsgPtr& msg, bool reliable ) +{ + if ( _conn == 0 || isDisconnected() ) + { + LOG_STEAM_SOCKET ( this, "Cannot send over disconnected socket" ); + return false; + } + + const string buffer = ::Protocol::encode ( msg ); + + if ( buffer.empty() ) + return false; + + return SteamManager::get().sendMessage ( _conn, &buffer[0], buffer.size(), reliable ); +} + +bool SteamSocket::send ( const char *buffer, size_t len ) +{ + // Raw byte sends are unused by the netplay path over Steam. + return false; +} + +bool SteamSocket::send ( const char *buffer, size_t len, const IpAddrPort& address ) +{ + return false; +} + +bool SteamSocket::send ( SerializableMessage *message, const IpAddrPort& address ) +{ + // Unreliable, like the UDP data path. + return sendEncoded ( MsgPtr ( message ), false ); +} + +bool SteamSocket::send ( SerializableSequence *message, const IpAddrPort& address ) +{ + // Reliable + ordered. + return sendEncoded ( MsgPtr ( message ), true ); +} + +bool SteamSocket::send ( const MsgPtr& msg, const IpAddrPort& address ) +{ + if ( ! msg.get() ) + return false; + + const bool reliable = ( msg->getBaseType().value == BaseType::SerializableSequence ); + return sendEncoded ( msg, reliable ); +} + + +// ---- routing callbacks from SteamManager ---- + +void SteamSocket::onIncomingConnection ( uint32_t conn, uint64_t peerSteamId ) +{ + LOG_STEAM_SOCKET ( this, "incoming conn=%u from peer=%llu", conn, ( unsigned long long ) peerSteamId ); + + if ( ! SteamManager::get().acceptConnection ( conn ) ) + { + LOG_STEAM_SOCKET ( this, "acceptConnection(%u) failed", conn ); + SteamManager::get().closeConnection ( conn ); + return; + } + + SteamSocket *child = new SteamSocket ( ChildSocket, 0, conn, peerSteamId, _virtualPort ); + child->_parentSocket = this; + + _childSockets[conn] = SocketPtr ( child ); + SteamManager::get().registerConnection ( conn, child ); + + // socketAccepted is fired once the child reaches the Connected state (onConnected). +} + +void SteamSocket::onConnected() +{ + _state = State::Connected; + _gotGoodRead = true; + + if ( _parentSocket ) + { + // Child (host side): surface to the server owner as a newly accepted socket. + const auto it = _parentSocket->_childSockets.find ( _conn ); + if ( it != _parentSocket->_childSockets.end() ) + _parentSocket->_acceptedSocket = it->second; + + LOG_STEAM_SOCKET ( this, "socketAccepted" ); + + if ( _parentSocket->owner ) + _parentSocket->owner->socketAccepted ( _parentSocket ); + } + else + { + // Client: connection established. + LOG_STEAM_SOCKET ( this, "socketConnected" ); + + if ( owner ) + owner->socketConnected ( this ); + } +} + +void SteamSocket::onClosed() +{ + LOG_STEAM_SOCKET ( this, "socketDisconnected" ); + + Socket::Owner *const ownerCopy = this->owner; + + disconnect(); + + if ( ownerCopy ) + ownerCopy->socketDisconnected ( this ); +} + +void SteamSocket::onReceive ( const MsgPtr& msg ) +{ + _gotGoodRead = true; + + if ( owner ) + owner->socketRead ( this, msg, getRemoteAddress() ); +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamSocket.hpp b/lib/SteamSocket.hpp new file mode 100644 index 00000000..45199d1c --- /dev/null +++ b/lib/SteamSocket.hpp @@ -0,0 +1,117 @@ +#pragma once + +#ifdef ENABLE_STEAM + +#include "Socket.hpp" + +#include +#include +#include + + +// Virtual ports for the two logical channels, mirroring CCCaster's TCP-control + UDP-data +// split: each is a separate Steam P2P connection. +#define STEAM_CTRL_VPORT ( 0 ) +#define STEAM_DATA_VPORT ( 1 ) + +// A Steam match is addressed by the peer's SteamID, but CCCaster threads an IpAddrPort +// "address" through its UI, launcher, and IPC. We encode the SteamID into that address as +// "steam:" so Steam matches reuse the same plumbing as IP matches. +std::string steamAddr ( uint64_t steamId ); // -> IpAddrPort addr string "steam:" +uint64_t steamIdFromAddr ( const IpAddrPort& address ); // parse "steam:"; 0 if not a steam addr + + +// A Socket implementation that tunnels CCCaster's protocol over a Steam Datagram Relay +// (SDR) P2P connection, addressed by SteamID instead of IP:port. This sidesteps CGNAT by +// routing through Valve's relay backbone. +// +// Structure mirrors UdpSocket's server/child/accepted model: +// - A "listen" SteamSocket (isServer) owns a Steam listen socket on a virtual port. +// When a peer connects, SteamManager routes the incoming connection here; we accept it, +// wrap it in a child SteamSocket, stash it as _acceptedSocket, and fire socketAccepted. +// - A "client" SteamSocket owns one outbound connection (ConnectP2P by SteamID). +// - A "child" SteamSocket wraps one accepted inbound connection on the host. +// +// No fd, so this never registers with SocketManager; SteamManager's pump timer drives it. +// All Steam SDK calls live in SteamManager (this file never includes Steam headers). +class SteamSocket : public Socket +{ +public: + + // Host: listen for P2P connections on the given virtual port. + static SocketPtr listen ( Socket::Owner *owner, int virtualPort ); + + // Client: connect to a host by SteamID64 on the given virtual port. + static SocketPtr connect ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ); + + ~SteamSocket() override; + + void disconnect() override; + + SocketPtr accept ( Socket::Owner *owner ) override; + + // The remote peer's SteamID64 (0 for a server socket). + uint64_t getPeerSteamId() const { return _peerSteamId; } + + // Raw byte sends are not used by the netplay path, but override to avoid the base + // winsock implementation (which would touch the null fd). + bool send ( const char *buffer, size_t len ); + bool send ( const char *buffer, size_t len, const IpAddrPort& address ); + + bool send ( SerializableMessage *message, const IpAddrPort& address = NullAddress ) override; + bool send ( SerializableSequence *message, const IpAddrPort& address = NullAddress ) override; + bool send ( const MsgPtr& message, const IpAddrPort& address = NullAddress ) override; + + // ---- called by SteamManager's routing/pump ---- + + // Incoming connection arrived on this server socket's listen handle. + void onIncomingConnection ( uint32_t conn, uint64_t peerSteamId ); + + // This connection reached the Connected state. + void onConnected(); + + // This connection closed or failed. + void onClosed(); + + // A decoded message was received on this connection. + void onReceive ( const MsgPtr& msg ); + + friend class SteamManager; + +private: + + enum ChildSocketEnum { ChildSocket }; + + // Virtual port (host listen port / client target port). + int _virtualPort = 0; + + // Steam listen handle (server) or connection handle (client/child); 0 == invalid. + uint32_t _listenHandle = 0; + uint32_t _conn = 0; + + // Remote peer SteamID64 (client/child only). + uint64_t _peerSteamId = 0; + + // Server: the just-accepted child waiting to be accept()'d out, and live children. + SocketPtr _acceptedSocket; + std::unordered_map _childSockets; + + // Child: the server socket that accepted this connection (0 for server/client). + SteamSocket *_parentSocket = 0; + + // Server constructor (listen on a virtual port). + SteamSocket ( Socket::Owner *owner, int virtualPort ); + + // Client constructor (connect to peer SteamID). + SteamSocket ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ); + + // Child constructor (one accepted inbound connection on the host). + SteamSocket ( ChildSocketEnum, Socket::Owner *owner, uint32_t conn, uint64_t peerSteamId, int virtualPort ); + + bool sendEncoded ( const MsgPtr& msg, bool reliable ); + + // Unused base callback (Steam delivers whole messages via onReceive). + void socketRead ( const MsgPtr& msg, const IpAddrPort& address ) override {} +}; + +#endif // ENABLE_STEAM diff --git a/netplay/Messages.hpp b/netplay/Messages.hpp index 12c8ab7f..34001e6a 100644 --- a/netplay/Messages.hpp +++ b/netplay/Messages.hpp @@ -33,7 +33,7 @@ struct ClientMode : public SerializableSequence { ENUM_BOILERPLATE ( ClientMode, Host, Client, SpectateNetplay, SpectateBroadcast, Broadcast, Offline ) - enum { Training = 0x01, GameStarted = 0x02, UdpTunnel = 0x04, IsWine = 0x08, VersusCPU = 0x10, Replay = 0x20, Trial = 0x40 }; + enum { Training = 0x01, GameStarted = 0x02, UdpTunnel = 0x04, IsWine = 0x08, VersusCPU = 0x10, Replay = 0x20, Trial = 0x40, IsSteam = 0x80 }; uint8_t flags = 0; @@ -62,6 +62,7 @@ struct ClientMode : public SerializableSequence bool isTrial() const { return ( flags & Trial ); } bool isGameStarted() const { return ( flags & GameStarted ); } bool isUdpTunnel() const { return ( flags & UdpTunnel ); } + bool isSteam() const { return ( flags & IsSteam ); } bool isWine() const { return ( flags & IsWine ); } bool isSinglePlayer() const { return ( isNetplay() || isVersusCPU() ); } @@ -81,6 +82,9 @@ struct ClientMode : public SerializableSequence if ( flags & UdpTunnel ) str += std::string ( str.empty() ? "" : ", " ) + "UdpTunnel"; + if ( flags & IsSteam ) + str += std::string ( str.empty() ? "" : ", " ) + "IsSteam"; + if ( flags & IsWine ) str += std::string ( str.empty() ? "" : ", " ) + "IsWine"; diff --git a/targets/DllMain.cpp b/targets/DllMain.cpp index 623deb2a..efa29459 100644 --- a/targets/DllMain.cpp +++ b/targets/DllMain.cpp @@ -6,6 +6,10 @@ #include "ChangeMonitor.hpp" #include "SmartSocket.hpp" #include "UdpSocket.hpp" +#ifdef ENABLE_STEAM +#include "SteamSocket.hpp" +#include "SteamManager.hpp" +#endif #include "Exceptions.hpp" #include "Enum.hpp" #include "ErrorStringsExt.hpp" @@ -1823,20 +1827,47 @@ struct DllMain netMan.setRemotePlayer ( remotePlayer ); +#ifdef ENABLE_STEAM + // Launcher shut down its Steam instance before spawning the game; bring + // Steam up in-process so we can re-establish the P2P connection by SteamID. + if ( clientMode.isSteam() ) + SteamManager::get().ref(); +#endif + if ( clientMode.isHost() ) { - serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); - LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverCtrlSocket = SteamSocket::listen ( this, STEAM_CTRL_VPORT ); + serverDataSocket = SteamSocket::listen ( this, STEAM_DATA_VPORT ); + } + else +#endif + { + serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); + serverDataSocket = SmartSocket::listenUDP ( this, address.port ); + } - serverDataSocket = SmartSocket::listenUDP ( this, address.port ); + LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); LOG ( "serverDataSocket=%08x", serverDataSocket.get() ); } else if ( clientMode.isClient() ) { - serverCtrlSocket = SmartSocket::listenTCP ( this, 0 ); - LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverCtrlSocket = SteamSocket::listen ( this, STEAM_CTRL_VPORT ); + dataSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_DATA_VPORT ); + } + else +#endif + { + serverCtrlSocket = SmartSocket::listenTCP ( this, 0 ); + dataSocket = SmartSocket::connectUDP ( this, address, clientMode.isUdpTunnel() ); + } - dataSocket = SmartSocket::connectUDP ( this, address, clientMode.isUdpTunnel() ); + LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); LOG ( "dataSocket=%08x", dataSocket.get() ); } diff --git a/targets/MainApp.cpp b/targets/MainApp.cpp index 34410679..6bd57562 100644 --- a/targets/MainApp.cpp +++ b/targets/MainApp.cpp @@ -4,6 +4,10 @@ #include "ExternalIpAddress.hpp" #include "SmartSocket.hpp" #include "UdpSocket.hpp" +#ifdef ENABLE_STEAM +#include "SteamSocket.hpp" +#include "SteamManager.hpp" +#endif #include "Constants.hpp" #include "Exceptions.hpp" #include "Algorithms.hpp" @@ -274,15 +278,30 @@ struct MainApp if ( clientMode.isHost() ) { - serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); - address.port = serverCtrlSocket->address.port; // Update port in case it was initially 0 - address.invalidate(); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverCtrlSocket = SteamSocket::listen ( this, STEAM_CTRL_VPORT ); + } + else +#endif + { + serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); + address.port = serverCtrlSocket->address.port; // Update port in case it was initially 0 + address.invalidate(); + } LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); } else { - ctrlSocket = SmartSocket::connectTCP ( this, address, options[Options::Tunnel] ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + ctrlSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_CTRL_VPORT ); + else +#endif + ctrlSocket = SmartSocket::connectTCP ( this, address, options[Options::Tunnel] ); + LOG ( "ctrlSocket=%08x", ctrlSocket.get() ); stopTimer.reset ( new Timer ( this ) ); @@ -436,10 +455,20 @@ struct MainApp ASSERT ( ctrlSocket.get() != 0 ); ASSERT ( ctrlSocket->isConnected() == true ); - try { serverDataSocket = SmartSocket::listenUDP ( this, address.port ); } - catch ( ... ) { serverDataSocket = SmartSocket::listenUDP ( this, 0 ); } +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverDataSocket = SteamSocket::listen ( this, STEAM_DATA_VPORT ); + initialConfig.dataPort = 0; // unused for Steam (vports, not OS ports) + } + else +#endif + { + try { serverDataSocket = SmartSocket::listenUDP ( this, address.port ); } + catch ( ... ) { serverDataSocket = SmartSocket::listenUDP ( this, 0 ); } - initialConfig.dataPort = serverDataSocket->address.port; + initialConfig.dataPort = serverDataSocket->address.port; + } LOG ( "serverDataSocket=%08x", serverDataSocket.get() ); } @@ -484,8 +513,13 @@ struct MainApp ASSERT ( ctrlSocket.get() != 0 ); ASSERT ( ctrlSocket->isConnected() == true ); - dataSocket = SmartSocket::connectUDP ( this, { address.addr, this->initialConfig.dataPort }, - ctrlSocket->getAsSmart().isTunnel() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + dataSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_DATA_VPORT ); + else +#endif + dataSocket = SmartSocket::connectUDP ( this, { address.addr, this->initialConfig.dataPort }, + ctrlSocket->getAsSmart().isTunnel() ); LOG ( "dataSocket=%08x", dataSocket.get() ); ui.display ( @@ -909,7 +943,12 @@ struct MainApp // Only connect the dataSocket if isClient if ( clientMode.isClient() ) { - dataSocket = SmartSocket::connectUDP ( this, address, ctrlSocket->getAsSmart().isTunnel() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + dataSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_DATA_VPORT ); + else +#endif + dataSocket = SmartSocket::connectUDP ( this, address, ctrlSocket->getAsSmart().isTunnel() ); LOG ( "dataSocket=%08x", dataSocket.get() ); } @@ -1178,7 +1217,12 @@ struct MainApp procMan.ipcSend ( options ); procMan.ipcSend ( ControllerManager::get().getMappings() ); procMan.ipcSend ( clientMode ); - procMan.ipcSend ( new IpAddrPort ( address.getAddrInfo()->ai_addr ) ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + procMan.ipcSend ( new IpAddrPort ( address ) ); // raw "steam:"; do NOT DNS-resolve + else +#endif + procMan.ipcSend ( new IpAddrPort ( address.getAddrInfo()->ai_addr ) ); if ( clientMode.isSpectate() ) { @@ -1272,6 +1316,15 @@ struct MainApp serverDataSocket.reset(); ctrlSocket.reset(); serverCtrlSocket.reset(); + +#ifdef ENABLE_STEAM + // Shut Steam down in the launcher BEFORE spawning the game, so the injected + // DLL can SteamAPI_Init under App ID 411370 without two processes holding it + // at once. Releases the ref taken by MainUi::steam(); the in-game leg + // re-establishes the P2P connection by SteamID. + if ( clientMode.isSteam() ) + SteamManager::get().deref(); +#endif } DWORD val = GetFileAttributes ( ( ProcessManager::appDir + "framestep.dll" ).c_str() ); @@ -1448,6 +1501,21 @@ struct MainApp if ( clientMode.isBroadcast() && !isBroadcastPortReady ) return; +#ifdef ENABLE_STEAM + // Steam matches have no IP/port to share - show the lobby join code instead. + if ( clientMode.isSteam() ) + { + if ( clientMode.isHost() ) + { + ui.display ( format ( "Hosting via Steam%s\n\nJoin code: %s\n\nWaiting for opponent...", + ( clientMode.isTraining() ? " (training mode)" : "" ), + SteamManager::get().lobbyCode().c_str() ) ); + ui.hostReady(); + } + return; + } +#endif + const uint16_t port = ( clientMode.isBroadcast() ? netplayConfig.broadcastPort : address.port ); if ( ui.isServer() ) { ui.display ( format ( "%s at server%s\n", diff --git a/targets/MainUi.cpp b/targets/MainUi.cpp index 1b1440a9..38d19ced 100644 --- a/targets/MainUi.cpp +++ b/targets/MainUi.cpp @@ -7,6 +7,11 @@ #include "CharacterSelect.hpp" #include "StringUtils.hpp" #include "NetplayStates.hpp" +#include "EventManager.hpp" +#ifdef ENABLE_STEAM +#include "SteamManager.hpp" +#include "SteamSocket.hpp" +#endif #include #include @@ -123,6 +128,119 @@ void MainUi::netplay ( RunFuncPtr run ) _ui->pop(); } +void MainUi::steam ( RunFuncPtr run ) +{ +#ifndef ENABLE_STEAM + _ui->pushRight ( new ConsoleUi::TextBox ( "This build was compiled without Steam support." ), { 1, 0 } ); + _ui->popUntilUserInput(); + _ui->pop(); +#else + _ui->pushRight ( new ConsoleUi::Menu ( "Steam", { "Host (create code)", "Join (enter code)" }, "Cancel" ) ); + const int choice = _ui->popUntilUserInput ( true )->resultInt; + _ui->pop(); + + if ( choice < 0 || choice > 1 ) + return; + + // AutoManager (managers initialized) BEFORE ref(), so the pump timer created inside + // ref() lives in an initialized TimerManager. + AutoManager _; + + if ( ! SteamManager::get().ref() ) + { + _ui->pushBelow ( new ConsoleUi::TextBox ( + "Could not initialize Steam.\n" + "Is Steam running, and do you own Melty Blood on this account?" ), { 1, 0 } ); + _ui->popUntilUserInput(); + _ui->pop(); + return; + } + + // The lobby is async. There is no EventManager loop running in the menu, so pump + // SteamManager directly (manual dispatch needs no event loop) until it resolves. + // ~5s timeout (500 * 10ms). The in-game session pumps via the timer once MainApp runs + // the EventManager loop. + auto pumpUntilDone = [] () + { + for ( int i = 0; i < 500 && SteamManager::get().lobbyState() == SteamManager::LobbyState::Working; ++i ) + { + SteamManager::get().pump(); + Sleep ( 10 ); + } + }; + + bool proceed = false; + + if ( choice == 0 ) // Host + { + SteamManager::get().hostLobby(); + display ( "Creating Steam lobby..." ); + pumpUntilDone(); + + if ( SteamManager::get().lobbyState() == SteamManager::LobbyState::Ready ) + { + if ( gameMode ( true ) ) + { + initialConfig.mode.value = ClientMode::Host; + initialConfig.mode.flags |= ClientMode::IsSteam; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + display ( "Steam join code: " + SteamManager::get().lobbyCode() + + "\n\nWaiting for opponent to join..." ); + proceed = true; + } + // else: user cancelled mode select -> just back out, no error. + } + else + { + sessionError = "Failed to create Steam lobby"; + } + } + else // Join + { + ConsoleUi::Prompt *menu = new ConsoleUi::Prompt ( ConsoleUi::Prompt::String, "Enter Steam join code:" ); + _ui->pushRight ( menu, { 1, 0 } ); // Expand width + _ui->popUntilUserInput(); + const string code = trimmed ( menu->resultStr ); + _ui->pop(); + + if ( ! code.empty() ) + { + SteamManager::get().joinLobbyByCode ( code ); + display ( "Searching for Steam lobby '" + code + "'..." ); + pumpUntilDone(); + + if ( SteamManager::get().lobbyState() == SteamManager::LobbyState::Ready ) + { + initialConfig.mode.value = ClientMode::Client; + initialConfig.mode.flags |= ClientMode::IsSteam; + _address = IpAddrPort ( steamAddr ( SteamManager::get().lobbyPeerId() ), ( uint16_t ) 0 ); + proceed = true; + } + else + { + sessionError = "Could not find a Steam lobby with that code"; + } + } + } + + if ( proceed ) + { + _netplayConfig.clear(); + RUN ( _address, initialConfig ); + _ui->popNonUserInput(); + } + + // Release our ref (guarded no-op if MainApp already shut Steam down before launching). + SteamManager::get().deref(); + + if ( ! sessionError.empty() ) + { + _ui->pushBelow ( new ConsoleUi::TextBox ( sessionError ), { 1, 0 } ); // Expand width + sessionError.clear(); + } +#endif +} + void MainUi::server ( RunFuncPtr run ) { serverMode = true; @@ -1652,6 +1770,7 @@ void MainUi::main ( RunFuncPtr run ) const vector options = { "Netplay", + "Steam", "Spectate", "Broadcast", "Offline", @@ -1709,7 +1828,7 @@ void MainUi::main ( RunFuncPtr run ) _ui->clearRight(); - if ( mainSelection >= 0 && mainSelection <= 4 ) + if ( mainSelection >= 0 && mainSelection <= 5 ) { _config.setInteger ( "lastMainMenuPosition", mainSelection + 1 ); saveConfig(); @@ -1722,34 +1841,38 @@ void MainUi::main ( RunFuncPtr run ) break; case 1: - spectate ( run ); + steam ( run ); break; case 2: - broadcast ( run ); + spectate ( run ); break; case 3: - offline ( run ); + broadcast ( run ); break; case 4: - server( run ); + offline ( run ); break; case 5: - controls(); + server( run ); break; case 6: - settings(); + controls(); break; case 7: - update(); + settings(); break; case 8: + update(); + break; + + case 9: results(); break; diff --git a/targets/MainUi.hpp b/targets/MainUi.hpp index 85c1a514..d992ece5 100644 --- a/targets/MainUi.hpp +++ b/targets/MainUi.hpp @@ -104,6 +104,7 @@ class MainUi bool isMatchmaking = false; void netplay ( RunFuncPtr run ); + void steam ( RunFuncPtr run ); void server ( RunFuncPtr run ); void lobby ( RunFuncPtr run ); void matchmaking ( RunFuncPtr run ); From a78afdfb684774825e4dcd6970377e48e8f2fb16 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 14:45:59 -0300 Subject: [PATCH 02/25] Use Steamworks for lobbies and matchmaking --- lib/ILobbyBackend.hpp | 101 ++++++++++ lib/IMatchmakingBackend.hpp | 48 +++++ lib/Lobby.cpp | 4 +- lib/Lobby.hpp | 78 +++----- lib/MatchmakingManager.cpp | 5 +- lib/MatchmakingManager.hpp | 27 ++- lib/SteamLobby.cpp | 325 +++++++++++++++++++++++++++++++ lib/SteamLobby.hpp | 66 +++++++ lib/SteamManager.cpp | 374 +++++++++++++++++++++++++++++++++--- lib/SteamManager.hpp | 93 +++++++-- lib/SteamMatchmaking.cpp | 114 +++++++++++ lib/SteamMatchmaking.hpp | 36 ++++ targets/MainUi.cpp | 317 +++++++++++++++--------------- targets/MainUi.hpp | 25 ++- 14 files changed, 1333 insertions(+), 280 deletions(-) create mode 100644 lib/ILobbyBackend.hpp create mode 100644 lib/IMatchmakingBackend.hpp create mode 100644 lib/SteamLobby.cpp create mode 100644 lib/SteamLobby.hpp create mode 100644 lib/SteamMatchmaking.cpp create mode 100644 lib/SteamMatchmaking.hpp diff --git a/lib/ILobbyBackend.hpp b/lib/ILobbyBackend.hpp new file mode 100644 index 00000000..8ba75714 --- /dev/null +++ b/lib/ILobbyBackend.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include "IpAddrPort.hpp" +#include "Thread.hpp" + +#include +#include + + +// Game types advertised by a lobby (server-backed Concerto lobbies use these). +enum GameType +{ + FFA, + WinnerStaysOn, +}; + +// The lobby UI in MainUi::lobby() is a small state machine over these modes. Both the +// server-backed (ServerLobby) and Steam-backed (SteamLobby) implementations populate the +// same menu/ip/id vectors per mode, so the UI loop is backend-agnostic. +enum LobbyMode +{ + MENU, + CONCERTO_BROWSE, + CONCERTO_LOBBY, + DEFAULT_LOBBY, +}; + + +// Backend interface for the "Server -> Lobby" menu. MainUi drives this surface; a concrete +// backend is either the relay/Concerto server (ServerLobby, a Thread+Socket) or Steam lobbies +// (SteamLobby, driven by SteamManager's pump). The seam virtuals (isSteam/needsEventManager/ +// refresh/pumpUntilSettled) let the single UI loop in MainUi::lobby() serve both: the server +// backend signals MainUi's uiCondVar from its own thread, while the Steam backend has no thread +// and must be pumped from the UI thread instead. +struct ILobbyBackend +{ + struct Owner + { + virtual void connectionFailed ( ILobbyBackend *lobby ) = 0; + virtual void unlock ( ILobbyBackend *lobby ) = 0; + }; + + Owner *owner = 0; + + // ---- shared observable state (read by MainUi, some under entryMutex) ---- + IpAddrPort _address; // host port ("46318") on the server host path; unused for Steam + Mutex entryMutex; // guards the menu/ip/id vectors during refresh + LobbyMode mode = MENU; + int numEntries = 0; + bool hostSuccess = false; + bool connectionSuccess = false; + std::string lobbyError; + std::string lobbyMsg; // room code shown after create + + virtual ~ILobbyBackend() {} + + // ---- lifecycle ---- + virtual void start() = 0; + virtual bool isRunning() = 0; + virtual void stop() = 0; + + // ---- menu data (per mode) ---- + virtual std::vector getMenu() = 0; + virtual std::vector getIps() = 0; + virtual std::vector getIds() = 0; + + // ---- browse / create / join ---- + virtual void fetchPublicLobby() = 0; + virtual void create ( std::string name, std::string type ) = 0; // type: "Public" / "Private" + virtual std::string join ( std::string name, int selection ) = 0; // browse-index join, returns code + virtual void join ( std::string name, std::string code ) = 0; // code join + virtual bool checkLobbyCode ( std::string code ) = 0; + + // ---- in-lobby challenge handshake ---- + virtual void challenge ( std::string target, IpAddrPort port ) = 0; // becomes Host + virtual void preaccept ( std::string id ) = 0; // becomes Client + virtual void accept() = 0; // confirm connection + virtual void unhost() = 0; + virtual void end() = 0; + virtual void host ( std::string name, IpAddrPort port ) = 0; // DEFAULT_LOBBY only + + // ---- backend seam (server vs Steam) ---- + + // True for the Steam backend. MainUi uses this to gate the OR-in of ClientMode::IsSteam + + // a steam: address at the RUN sites, and to choose pump-vs-condvar at every wait point. + virtual bool isSteam() const { return false; } + + // The server backend runs its own EventManager loop on a thread; the Steam backend does not. + // MainUi gates its EventManager::isRunning() guards on this. + virtual bool needsEventManager() const { return true; } + + // Re-read the current mode's menu/ip/id vectors (under entryMutex). The server backend keeps + // these current via its background thread + poll timer, so this is a no-op there; the Steam + // backend re-enumerates lobby members / public lobbies here, called on the UI's periodic tick. + virtual void refresh() {} + + // Block (pumping Steam) until the in-flight async op settles. The server backend signals + // MainUi's uiCondVar from its thread instead, so this is a no-op there; the Steam backend + // runs a bounded pump loop until its pending op reaches a terminal state. + virtual void pumpUntilSettled() {} +}; diff --git a/lib/IMatchmakingBackend.hpp b/lib/IMatchmakingBackend.hpp new file mode 100644 index 00000000..20f8095a --- /dev/null +++ b/lib/IMatchmakingBackend.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "IpAddrPort.hpp" +#include "KeyboardManager.hpp" + +#include + + +// Backend interface for the "Server -> Matchmaking" menu. The concrete backend is either the +// relay server (ServerMatchmaking, a Thread+Socket that exchanges MMSTART/HOST/CLIENT) or Steam +// (SteamMatchmaking, a region-filtered create-or-join over Steam lobbies). Inherits +// KeyboardManager::Owner so AutoManager can hook ESC-to-cancel for either backend (the hook is +// only active under RELEASE, but the type must satisfy it). The waitConnected/waitMatched seam +// mirrors ILobbyBackend's pumpUntilSettled: the server backend signals MainUi's uiCondVar from +// its thread, while the Steam backend pumps from the UI thread. +struct IMatchmakingBackend : public KeyboardManager::Owner +{ + struct Owner + { + virtual void connectionFailed ( IMatchmakingBackend *mmm ) = 0; + virtual void setAddr ( IMatchmakingBackend *mmm, std::string addr ) = 0; + virtual void setMode ( IMatchmakingBackend *mmm, std::string mode ) = 0; + virtual void unlock ( IMatchmakingBackend *mmm ) = 0; + }; + + Owner *owner = 0; + + bool connectionSuccess = false; + bool matchSuccess = false; + bool ignoreKb = false; + + virtual ~IMatchmakingBackend() {} + + virtual void start() = 0; + virtual bool isRunning() = 0; + virtual void stop() = 0; + + virtual void sendHostReady() = 0; + + // ---- backend seam (server vs Steam) ---- + virtual bool isSteam() const { return false; } + virtual bool needsEventManager() const { return true; } + + // Block until connected to the pool (server) / the lobby search settles (Steam). + virtual void waitConnected() {} + // Block until a Host/Client role is decided (setMode/setAddr fired). + virtual void waitMatched() {} +}; diff --git a/lib/Lobby.cpp b/lib/Lobby.cpp index 58c0563d..88c813c6 100644 --- a/lib/Lobby.cpp +++ b/lib/Lobby.cpp @@ -32,9 +32,9 @@ vector Lobby::getIds(){ return lobbyids; } -Lobby::Lobby( Owner* owner ) - : owner( owner ) +Lobby::Lobby( ILobbyBackend::Owner* owner ) { + this->owner = owner; timeout = 5000; numEntries = 0; blankEntry = ""; diff --git a/lib/Lobby.hpp b/lib/Lobby.hpp index b4275bef..7c122e43 100644 --- a/lib/Lobby.hpp +++ b/lib/Lobby.hpp @@ -1,76 +1,54 @@ #pragma once +#include "ILobbyBackend.hpp" #include "ConsoleUi.hpp" #include "Socket.hpp" #include "Timer.hpp" #define DEFAULT_GET_TIMEOUT ( 5000 ) -enum GameType -{ - FFA, - WinnerStaysOn, -}; - -enum LobbyMode -{ - MENU, - CONCERTO_BROWSE, - CONCERTO_LOBBY, - DEFAULT_LOBBY, -}; - +// Relay/Concerto server-backed lobby (the "fallback" backend). A background Thread runs an +// EventManager loop over a TcpSocket to the lobby server and signals MainUi's uiCondVar via the +// ILobbyBackend::Owner callbacks. See SteamLobby for the Steam-backed implementation of the same +// interface. class Lobby : Socket::Owner , Timer::Owner , public Thread + , public ILobbyBackend { public: - struct Owner - { - virtual void connectionFailed( Lobby* lobby ) = 0; - virtual void unlock( Lobby* lobby ) = 0; - }; - - Owner *owner = 0; - std::vector getMenu(); - std::vector getIps(); - std::vector getIds(); + std::vector getMenu() override; + std::vector getIps() override; + std::vector getIds() override; - Lobby ( Owner* owner ); + Lobby ( ILobbyBackend::Owner* owner ); bool connect( std::string url ); void disconnect(); - void host( std::string name, IpAddrPort port ); - void unhost(); - std::string join( std::string name, int selection ); - void join( std::string name, std::string code ); - void challenge( std::string target, IpAddrPort port ); - void create( std::string name, std::string type ); - void preaccept( std::string id ); - void accept(); - void end(); - void fetchPublicLobby(); - bool checkLobbyCode( std::string code ); - - void init( Owner* owner ); - - void stop(); + void host( std::string name, IpAddrPort port ) override; + void unhost() override; + std::string join( std::string name, int selection ) override; + void join( std::string name, std::string code ) override; + void challenge( std::string target, IpAddrPort port ) override; + void create( std::string name, std::string type ) override; + void preaccept( std::string id ) override; + void accept() override; + void end() override; + void fetchPublicLobby() override; + bool checkLobbyCode( std::string code ) override; + + void init( ILobbyBackend::Owner* owner ); + + // ILobbyBackend lifecycle (unify with Thread's start/isRunning) + void start() override { Thread::start(); } + bool isRunning() override { return Thread::isRunning(); } + void stop() override; uint64_t timeout; - IpAddrPort _address; - bool connectionSuccess; bool newRequestSuccess; - int numEntries; - bool hostSuccess; - Mutex entryMutex; - LobbyMode mode; - - std::string lobbyError; - std::string lobbyMsg; - private: SocketPtr _socket; diff --git a/lib/MatchmakingManager.cpp b/lib/MatchmakingManager.cpp index bb93307b..ddc558a3 100644 --- a/lib/MatchmakingManager.cpp +++ b/lib/MatchmakingManager.cpp @@ -7,9 +7,10 @@ using namespace std; -MatchmakingManager::MatchmakingManager( Owner* owner, IpAddrPort _address, string region ) - : owner( owner ), _address( _address ), region( region ) +MatchmakingManager::MatchmakingManager( IMatchmakingBackend::Owner* owner, IpAddrPort _address, string region ) + : _address( _address ), region( region ) { + this->owner = owner; timeout = 1000; connectionSuccess = false; ignoreKb = false; diff --git a/lib/MatchmakingManager.hpp b/lib/MatchmakingManager.hpp index e5ea03ef..f0bce81e 100644 --- a/lib/MatchmakingManager.hpp +++ b/lib/MatchmakingManager.hpp @@ -1,5 +1,6 @@ #pragma once +#include "IMatchmakingBackend.hpp" #include "ConsoleUi.hpp" #include "Socket.hpp" #include "Timer.hpp" @@ -8,37 +9,31 @@ #define DEFAULT_GET_TIMEOUT ( 5000 ) +// Relay server-backed matchmaking (the "fallback" backend). A background Thread connects to the +// matchmaking server (MMSTART,region) and is told HOST or CLIENT,, signalling MainUi's +// uiCondVar via the IMatchmakingBackend::Owner callbacks. See SteamMatchmaking for the +// Steam-backed implementation of the same interface. class MatchmakingManager : Socket::Owner , Timer::Owner - , public KeyboardManager::Owner , public Thread + , public IMatchmakingBackend { public: - struct Owner - { - virtual void connectionFailed( MatchmakingManager* lobby ) = 0; - virtual void setAddr( MatchmakingManager* lobby, std::string addr ) = 0; - virtual void setMode( MatchmakingManager* lobby, std::string mode ) = 0; - virtual void unlock( MatchmakingManager* lobby ) = 0; - }; - Owner *owner = 0; + MatchmakingManager ( IMatchmakingBackend::Owner* owner, IpAddrPort _address, std::string region ); - MatchmakingManager ( Owner* owner, IpAddrPort _address, std::string region ); - - void stop(); + void start() override { Thread::start(); } + bool isRunning() override { return Thread::isRunning(); } + void stop() override; void connect(); void disconnect(); - void sendHostReady(); + void sendHostReady() override; uint64_t timeout; IpAddrPort _address; - bool connectionSuccess; - bool matchSuccess; - bool ignoreKb; Mutex hostMutex; CondVar hostCondVar; diff --git a/lib/SteamLobby.cpp b/lib/SteamLobby.cpp new file mode 100644 index 00000000..4109cbac --- /dev/null +++ b/lib/SteamLobby.cpp @@ -0,0 +1,325 @@ +#ifdef ENABLE_STEAM + +#include "SteamLobby.hpp" +#include "SteamManager.hpp" +#include "SteamSocket.hpp" // steamAddr() +#include "Logger.hpp" + +#include // Sleep +#include + +using namespace std; + + +// Bounded pump loop: ~5s like the standalone steam() UI used. There is no EventManager loop +// during the lobby menu, so we drive SteamManager's manual dispatch directly here. +#define SETTLE_TRIES ( 500 ) +#define SETTLE_SLEEP_MS ( 10 ) + + +SteamLobby::SteamLobby ( ILobbyBackend::Owner* owner ) +{ + this->owner = owner; + mode = MENU; +} + +SteamLobby::~SteamLobby() +{ + SteamManager::get().leaveLobby(); + // Release the ref taken by the MainUi backend factory. Guarded no-op if MainApp already + // shut Steam down before launching the game (the match path). + SteamManager::get().deref(); +} + +void SteamLobby::start() +{ + // Steam was already ref()'d by the MainUi factory. Nothing async to start; the UI opens at + // the top-level lobby MENU. + mode = MENU; + connectionSuccess = true; +} + +bool SteamLobby::isRunning() +{ + // Steam may be shut down mid-session by MainApp when a match launches; that ends the lobby. + return SteamManager::get().isInitialized(); +} + +void SteamLobby::stop() +{ + SteamManager::get().leaveLobby(); +} + + +// ---- menu data (called by MainUi while it holds entryMutex; must NOT re-lock) ---- + +vector SteamLobby::getMenu() +{ + switch ( mode ) + { + case MENU: + // No "Default Lobby" entry for Steam (that relay-only mode has no Steam analog). + return { "Public Lobbies", "Create Lobby", "Enter Lobby Code" }; + case CONCERTO_BROWSE: + return publiclobbies; + case CONCERTO_LOBBY: + return lobbyentries; + default: + return {}; + } +} + +vector SteamLobby::getIps() +{ + if ( mode == CONCERTO_LOBBY ) + return lobbyips; + return {}; +} + +vector SteamLobby::getIds() +{ + return lobbyids; +} + + +// ---- browse / create / join ---- + +void SteamLobby::fetchPublicLobby() +{ + SteamManager::get().requestPublicLobbies(); + _pending = P_BROWSE; +} + +void SteamLobby::create ( string name, string type ) +{ + SteamManager::get().setLobbyMemberName ( name ); + SteamManager::get().createLobby ( type == "Public" ); + _pending = P_CREATE; +} + +string SteamLobby::join ( string name, int selection ) +{ + SteamManager& sm = SteamManager::get(); + const vector& lobbies = sm.publicLobbies(); + + string code; + if ( selection >= 0 && selection < ( int ) lobbies.size() ) + { + sm.setLobbyMemberName ( name ); + sm.joinLobbyById ( lobbies[selection].lobbyId ); + code = lobbies[selection].code; + _pending = P_JOIN; + } + return code; +} + +void SteamLobby::join ( string name, string code ) +{ + SteamManager::get().setLobbyMemberName ( name ); + SteamManager::get().joinLobbyByCode ( code ); + _pending = P_JOIN; +} + +bool SteamLobby::checkLobbyCode ( string code ) +{ + if ( code.size() < 2 || code.size() > 8 ) + return false; + for ( size_t i = 0; i < code.size(); ++i ) + if ( ! isalnum ( ( unsigned char ) code[i] ) ) + return false; + return true; +} + + +// ---- challenge handshake ---- + +void SteamLobby::challenge ( string target, IpAddrPort port ) +{ + SteamManager& sm = SteamManager::get(); + const uint64_t targetId = strtoull ( target.c_str(), nullptr, 10 ); + const uint64_t myId = sm.getSteamID(); + + // Advertise the challenge (also publishes our SteamID as host_id). + sm.setChallenge ( targetId ); + + // Let the peer's member-data arrive, then re-read for the simultaneous-challenge tie-break. + for ( int i = 0; i < 40; ++i ) + { + sm.pump(); + Sleep ( 5 ); + } + + bool peerChallengingMe = false; + for ( const SteamManager::MemberInfo& m : sm.lobbyMembers() ) + if ( m.steamId == targetId && m.challengingTarget == myId ) + peerChallengingMe = true; + + // Tie-break: if we both challenged each other, the LOWER SteamID hosts. The higher SteamID + // backs off (clears its challenge) and reconnects as Client - the peer's host row appears on + // the next refresh, so selecting it again takes the (now non-"None") client branch. + if ( peerChallengingMe && myId > targetId ) + { + sm.clearChallenge(); + hostSuccess = false; + refresh(); + return; + } + + hostSuccess = true; +} + +void SteamLobby::preaccept ( string id ) +{ + // Client side: nothing to negotiate over Steam - the caller connects directly to the host's + // SteamID (already resolved into the address by MainUi from the member's host row). +} + +void SteamLobby::accept() +{ + // Server-backed lobby notifies the relay that the connection is up; over Steam the P2P + // connection is direct, so there is nothing to confirm. +} + +void SteamLobby::unhost() +{ + SteamManager::get().clearChallenge(); + hostSuccess = false; +} + +void SteamLobby::end() +{ + // Stay in the lobby; just make sure our challenge advertisement is withdrawn. + SteamManager::get().clearChallenge(); +} + +void SteamLobby::host ( string name, IpAddrPort port ) +{ + // DEFAULT_LOBBY (relay-only peer list) has no Steam analog and is not reachable from the + // Steam menu (getMenu() omits it). No-op. +} + + +// ---- backend seam ---- + +void SteamLobby::refresh() +{ + SteamManager& sm = SteamManager::get(); + + // Process any pending lobby-data / chat callbacks so the member cache is current. + for ( int i = 0; i < 3; ++i ) + sm.pump(); + + LOCK ( entryMutex ); + + if ( mode == CONCERTO_LOBBY ) + { + const uint64_t myId = sm.getSteamID(); + const vector members = sm.lobbyMembers(); + + lobbyentries.clear(); + lobbyips.clear(); + lobbyids.clear(); + + for ( const SteamManager::MemberInfo& m : members ) + { + if ( m.steamId == myId ) + continue; // don't list / challenge ourselves + + char idStr[32]; + snprintf ( idStr, sizeof ( idStr ), "%llu", ( unsigned long long ) m.steamId ); + + string disp = m.name.empty() ? string ( idStr ) : m.name; + string ip = "None"; + + if ( m.challengingTarget == myId ) + { + // This member is challenging us and is hosting -> we connect to them as Client. + ip = steamAddr ( m.hostId ? m.hostId : m.steamId ); + } + else if ( m.challengingTarget != 0 ) + { + disp += " (busy)"; + } + + lobbyentries.push_back ( disp ); + lobbyids.push_back ( idStr ); + lobbyips.push_back ( ip ); + } + + numEntries = ( int ) lobbyentries.size(); + } + else if ( mode == CONCERTO_BROWSE ) + { + publiclobbies.clear(); + roomcodes.clear(); + + for ( const SteamManager::LobbyInfo& li : sm.publicLobbies() ) + { + roomcodes.push_back ( li.code ); + publiclobbies.push_back ( li.code + " " + to_string ( li.memberCount ) ); + } + + numEntries = ( int ) publiclobbies.size(); + } +} + +void SteamLobby::pumpUntilSettled() +{ + SteamManager& sm = SteamManager::get(); + + if ( _pending == P_NONE ) + return; + + if ( _pending == P_BROWSE ) + { + for ( int i = 0; i < SETTLE_TRIES && sm.browseState() == SteamManager::LobbyState::Working; ++i ) + { + sm.pump(); + Sleep ( SETTLE_SLEEP_MS ); + } + } + else + { + for ( int i = 0; i < SETTLE_TRIES && sm.lobbyState() == SteamManager::LobbyState::Working; ++i ) + { + sm.pump(); + Sleep ( SETTLE_SLEEP_MS ); + } + } + + finishPending(); +} + +void SteamLobby::finishPending() +{ + SteamManager& sm = SteamManager::get(); + const Pending p = _pending; + _pending = P_NONE; + + switch ( p ) + { + case P_CREATE: + case P_JOIN: + if ( sm.lobbyState() == SteamManager::LobbyState::Ready ) + { + mode = CONCERTO_LOBBY; + lobbyMsg = sm.lobbyCode(); // host's code (empty for a joiner, which is fine) + refresh(); + } + else + { + lobbyError = "Failed"; + } + break; + + case P_BROWSE: + if ( sm.browseState() == SteamManager::LobbyState::Ready ) + refresh(); + break; + + default: + break; + } +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamLobby.hpp b/lib/SteamLobby.hpp new file mode 100644 index 00000000..40948f35 --- /dev/null +++ b/lib/SteamLobby.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "ILobbyBackend.hpp" + +#include +#include + + +// Steam-backed lobby (implements ILobbyBackend). Backs the existing "Server -> Lobby" UI with +// Steam lobbies via SteamManager. Unlike the relay ServerLobby it has NO background thread and NO +// socket: it is pumped from the UI thread (refresh() on the periodic tick, pumpUntilSettled() at +// each async wait point). Only meaningful when built with ENABLE_STEAM (the .cpp body is guarded). +class SteamLobby : public ILobbyBackend +{ +public: + + SteamLobby ( ILobbyBackend::Owner* owner ); + ~SteamLobby(); + + // lifecycle + void start() override; + bool isRunning() override; + void stop() override; + + // menu data (per mode) + std::vector getMenu() override; + std::vector getIps() override; + std::vector getIds() override; + + // browse / create / join + void fetchPublicLobby() override; + void create ( std::string name, std::string type ) override; + std::string join ( std::string name, int selection ) override; + void join ( std::string name, std::string code ) override; + bool checkLobbyCode ( std::string code ) override; + + // challenge handshake + void challenge ( std::string target, IpAddrPort port ) override; + void preaccept ( std::string id ) override; + void accept() override; + void unhost() override; + void end() override; + void host ( std::string name, IpAddrPort port ) override; + + // backend seam + bool isSteam() const override { return true; } + bool needsEventManager() const override { return false; } + void refresh() override; + void pumpUntilSettled() override; + +private: + + // Which async Steam op is in flight, so pumpUntilSettled() knows what to wait on + apply. + enum Pending { P_NONE, P_CREATE, P_JOIN, P_BROWSE }; + Pending _pending = P_NONE; + + // Per-mode menu vectors (rebuilt in refresh(); read by getMenu/getIps/getIds while MainUi + // holds entryMutex, so those readers must not re-lock). + std::vector publiclobbies; // CONCERTO_BROWSE + std::vector roomcodes; // browse-index -> code + std::vector lobbyentries; // CONCERTO_LOBBY member display names + std::vector lobbyips; // CONCERTO_LOBBY: "None" or steam: + std::vector lobbyids; // CONCERTO_LOBBY member SteamID64 strings + + void finishPending(); +}; diff --git a/lib/SteamManager.cpp b/lib/SteamManager.cpp index 94b3474b..a6f09b28 100644 --- a/lib/SteamManager.cpp +++ b/lib/SteamManager.cpp @@ -26,9 +26,17 @@ using namespace std; // Max messages drained per connection per pump (leftovers come next pump). #define RECV_BATCH ( 32 ) -// Lobby metadata keys: the shareable join code, and the host's SteamID64 -static const char *LOBBY_CODE_KEY = "cccaster_code"; -static const char *LOBBY_HOST_KEY = "host_id"; +// Lobby-level metadata keys. +static const char *LOBBY_CODE_KEY = "cccaster_code"; // shareable join code +static const char *LOBBY_HOST_KEY = "host_id"; // lobby owner's SteamID64 +static const char *LOBBY_TYPE_KEY = "cccaster_type"; // "lobby" (browsable) / "private" / "mm" +static const char *LOBBY_REGION_KEY = "cccaster_region"; // matchmaking region +static const char *LOBBY_STATE_KEY = "cccaster_state"; // matchmaking: "waiting" / "matched" + +// Per-member metadata keys. +static const char *MEMBER_NAME_KEY = "name"; // display name +static const char *MEMBER_CHAL_KEY = "challenging"; // SteamID64 this member is challenging +static const char *MEMBER_HOST_KEY = "host_id"; // challenger's own SteamID64 (Host side) // Route Steam diagnostics into our log file static void S_CALLTYPE steamNetDebugOutput ( ESteamNetworkingSocketsDebugOutputType type, const char *msg ) @@ -118,7 +126,8 @@ void SteamManager::deref() SteamAPI_Shutdown(); _inited = false; _pipe = 0; - _lobbyCreateCall = _lobbyListCall = 0; + _lobbyCreateCall = _lobbyListCall = _lobbyJoinCall = _browseListCall = 0; + _mmListCall = _mmCreateCall = _mmJoinCall = 0; LOG ( "Steam shut down" ); } @@ -131,6 +140,15 @@ uint64_t SteamManager::getSteamID() const return SteamAPI_ISteamUser_GetSteamID ( SteamUser() ); } +std::string SteamManager::getPersonaName() const +{ + if ( ! _inited ) + return ""; + + const char *n = SteamAPI_ISteamFriends_GetPersonaName ( SteamFriends() ); + return n ? n : ""; +} + bool SteamManager::isRelayNetworkReady() const { if ( ! _inited ) @@ -236,9 +254,23 @@ void SteamManager::unregisterListen ( uint32_t listenHandle ) } -// ---- lobby / matchmaking ---- +// ---- lobby ---- + +void SteamManager::tagLobby ( uint64_t lobbyId, const char *type ) +{ + char selfId[32]; + snprintf ( selfId, sizeof ( selfId ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_TYPE_KEY, type ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_CODE_KEY, _lobbyCode.c_str() ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY, selfId ); + + if ( ! _memberName.empty() ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), lobbyId, MEMBER_NAME_KEY, + _memberName.c_str() ); +} -void SteamManager::hostLobby() +void SteamManager::createLobby ( bool isPublic ) { if ( ! _inited ) { @@ -247,12 +279,15 @@ void SteamManager::hostLobby() } _lobbyIsHost = true; + _lobbyIsPublic = isPublic; _lobbyState = LobbyState::Working; _lobbyCode.clear(); _lobbyId = 0; + _myChallenge = 0; - // Result matched by id in pump() - _lobbyCreateCall = SteamAPI_ISteamMatchmaking_CreateLobby ( SteamMatchmaking(), k_ELobbyTypePublic, 2 ); + // Both public and "private" lobbies are Steam-level public so that exact-code RequestLobbyList + // can find them; privacy is enforced by the cccaster_type tag (browse only asks for "lobby"). + _lobbyCreateCall = SteamAPI_ISteamMatchmaking_CreateLobby ( SteamMatchmaking(), k_ELobbyTypePublic, 8 ); } void SteamManager::onLobbyCreatedResult ( bool ok, uint64_t lobbyId ) @@ -269,14 +304,10 @@ void SteamManager::onLobbyCreatedResult ( bool ok, uint64_t lobbyId ) if ( _lobbyCode.empty() ) _lobbyCode = makeLobbyCode(); - char hostId[32]; - snprintf ( hostId, sizeof ( hostId ), "%llu", ( unsigned long long ) getSteamID() ); - - SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_CODE_KEY, _lobbyCode.c_str() ); - SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY, hostId ); + tagLobby ( lobbyId, _lobbyIsPublic ? "lobby" : "private" ); _lobbyState = LobbyState::Ready; - LOG ( "Lobby ready; code=%s", _lobbyCode.c_str() ); + LOG ( "Lobby ready; code=%s public=%d", _lobbyCode.c_str(), ( int ) _lobbyIsPublic ); } void SteamManager::joinLobbyByCode ( const std::string& code ) @@ -290,6 +321,8 @@ void SteamManager::joinLobbyByCode ( const std::string& code ) _lobbyIsHost = false; _joinCode = code; _lobbyPeerId = 0; + _lobbyId = 0; + _myChallenge = 0; _lobbyState = LobbyState::Working; SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( @@ -309,20 +342,153 @@ void SteamManager::onLobbyListResult ( bool found, uint64_t lobbyId ) return; } - _lobbyId = lobbyId; + // Found the lobby advertising the code; join it for membership (the lobby UI lists members + // and issues challenges; it does not connect P2P directly here). + joinLobbyById ( lobbyId ); +} + +void SteamManager::joinLobbyById ( uint64_t lobbyId ) +{ + if ( ! _inited || ! lobbyId ) + { + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyIsHost = false; + _myChallenge = 0; + _lobbyState = LobbyState::Working; - const char *hostId = SteamAPI_ISteamMatchmaking_GetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY ); - _lobbyPeerId = ( hostId && hostId[0] ) ? strtoull ( hostId, nullptr, 10 ) : 0; + _lobbyJoinCall = SteamAPI_ISteamMatchmaking_JoinLobby ( SteamMatchmaking(), lobbyId ); +} - if ( _lobbyPeerId == 0 ) +void SteamManager::onLobbyEntered ( uint64_t lobbyId, bool ok ) +{ + if ( ! ok ) { - LOG ( "Lobby found but host_id missing" ); + LOG ( "JoinLobby failed" ); _lobbyState = LobbyState::Failed; return; } + _lobbyId = lobbyId; + + if ( ! _memberName.empty() ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), lobbyId, MEMBER_NAME_KEY, + _memberName.c_str() ); + _lobbyState = LobbyState::Ready; - LOG ( "Joined lobby; host=%llu", ( unsigned long long ) _lobbyPeerId ); + LOG ( "Entered lobby %llu", ( unsigned long long ) lobbyId ); +} + +void SteamManager::requestPublicLobbies() +{ + if ( ! _inited ) + { + _browseState = LobbyState::Failed; + return; + } + + _browseState = LobbyState::Working; + _publicLobbies.clear(); + + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_TYPE_KEY, "lobby", k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListDistanceFilter ( + SteamMatchmaking(), k_ELobbyDistanceFilterWorldwide ); + + _browseListCall = SteamAPI_ISteamMatchmaking_RequestLobbyList ( SteamMatchmaking() ); +} + +void SteamManager::onBrowseListResult ( int count ) +{ + _publicLobbies.clear(); + + for ( int i = 0; i < count; ++i ) + { + const uint64_t id = SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), i ); + const char *code = SteamAPI_ISteamMatchmaking_GetLobbyData ( SteamMatchmaking(), id, LOBBY_CODE_KEY ); + const int cnt = SteamAPI_ISteamMatchmaking_GetNumLobbyMembers ( SteamMatchmaking(), id ); + _publicLobbies.push_back ( { id, code ? code : "", cnt } ); + } + + _browseState = LobbyState::Ready; + LOG ( "Browse: %d public lobbies", ( int ) _publicLobbies.size() ); +} + +uint64_t SteamManager::lobbyOwnerId() const +{ + if ( ! _inited || ! _lobbyId ) + return 0; + + return SteamAPI_ISteamMatchmaking_GetLobbyOwner ( SteamMatchmaking(), _lobbyId ); +} + +std::vector SteamManager::lobbyMembers() +{ + std::vector out; + + if ( ! _inited || ! _lobbyId ) + return out; + + const int n = SteamAPI_ISteamMatchmaking_GetNumLobbyMembers ( SteamMatchmaking(), _lobbyId ); + + for ( int i = 0; i < n; ++i ) + { + const uint64_t mid = SteamAPI_ISteamMatchmaking_GetLobbyMemberByIndex ( SteamMatchmaking(), _lobbyId, i ); + + const char *nm = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, mid, MEMBER_NAME_KEY ); + const char *chl = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, mid, MEMBER_CHAL_KEY ); + const char *hid = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, mid, MEMBER_HOST_KEY ); + + std::string name = ( nm && nm[0] ) ? nm : ""; + if ( name.empty() ) + { + const char *pn = SteamAPI_ISteamFriends_GetFriendPersonaName ( SteamFriends(), mid ); + name = ( pn ? pn : "" ); + } + + const uint64_t chalTarget = ( chl && chl[0] ) ? strtoull ( chl, nullptr, 10 ) : 0; + const uint64_t hostId = ( hid && hid[0] ) ? strtoull ( hid, nullptr, 10 ) : 0; + + out.push_back ( { mid, name, chalTarget, hostId } ); + } + + return out; +} + +void SteamManager::setLobbyMemberName ( const std::string& name ) +{ + _memberName = name; + + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_NAME_KEY, name.c_str() ); +} + +void SteamManager::setChallenge ( uint64_t targetId ) +{ + if ( ! _inited || ! _lobbyId ) + return; + + _myChallenge = targetId; + + char target[32], self[32]; + snprintf ( target, sizeof ( target ), "%llu", ( unsigned long long ) targetId ); + snprintf ( self, sizeof ( self ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_CHAL_KEY, target ); + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_HOST_KEY, self ); +} + +void SteamManager::clearChallenge() +{ + _myChallenge = 0; + + if ( ! _inited || ! _lobbyId ) + return; + + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_CHAL_KEY, "" ); + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_HOST_KEY, "" ); } void SteamManager::leaveLobby() @@ -334,6 +500,119 @@ void SteamManager::leaveLobby() _lobbyPeerId = 0; _lobbyCode.clear(); _lobbyState = LobbyState::Idle; + _browseState = LobbyState::Idle; + _publicLobbies.clear(); + _myChallenge = 0; +} + + +// ---- region matchmaking (create-or-join) ---- + +void SteamManager::matchmakeRegion ( const std::string& region ) +{ + if ( ! _inited ) + { + _mmState = MMState::Failed; + return; + } + + _mmRegion = region; + _mmPeerId = 0; + _mmState = MMState::Working; + _lobbyId = 0; + + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_TYPE_KEY, "mm", k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_REGION_KEY, region.c_str(), k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_STATE_KEY, "waiting", k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListResultCountFilter ( SteamMatchmaking(), 1 ); + + _mmListCall = SteamAPI_ISteamMatchmaking_RequestLobbyList ( SteamMatchmaking() ); +} + +void SteamManager::onMatchmakeListResult ( int count ) +{ + if ( count > 0 ) + { + // A peer is already waiting in this region; join as Client. + const uint64_t id = SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), 0 ); + _mmJoinCall = SteamAPI_ISteamMatchmaking_JoinLobby ( SteamMatchmaking(), id ); + } + else + { + // Nobody waiting; create a waiting lobby and become Host. + _mmCreateCall = SteamAPI_ISteamMatchmaking_CreateLobby ( SteamMatchmaking(), k_ELobbyTypePublic, 2 ); + } +} + +void SteamManager::onMatchmakeCreated ( bool ok, uint64_t lobbyId ) +{ + if ( ! ok ) + { + _mmState = MMState::Failed; + return; + } + + _lobbyId = lobbyId; + + char selfId[32]; + snprintf ( selfId, sizeof ( selfId ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_TYPE_KEY, "mm" ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_REGION_KEY, _mmRegion.c_str() ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_STATE_KEY, "waiting" ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY, selfId ); + + // Stay Working; a joining peer triggers onMemberJoined -> BecameHost. + LOG ( "Matchmaking: created waiting lobby in region %s", _mmRegion.c_str() ); +} + +void SteamManager::onMatchmakeEntered ( uint64_t lobbyId, bool ok ) +{ + if ( ! ok ) + { + _mmState = MMState::Failed; + return; + } + + _lobbyId = lobbyId; + _mmPeerId = SteamAPI_ISteamMatchmaking_GetLobbyOwner ( SteamMatchmaking(), lobbyId ); + + if ( _mmPeerId == 0 ) + { + _mmState = MMState::Failed; + return; + } + + _mmState = MMState::BecameClient; + LOG ( "Matchmaking: joined region %s; host=%llu", _mmRegion.c_str(), ( unsigned long long ) _mmPeerId ); +} + +void SteamManager::onMemberJoined ( uint64_t lobbyId, uint64_t memberId ) +{ + if ( lobbyId != _lobbyId ) + return; + + // Matchmaking host: a peer joined our waiting lobby -> we are matched as Host. + if ( _mmState == MMState::Working && memberId != 0 && memberId != getSteamID() ) + { + _mmPeerId = memberId; + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_STATE_KEY, "matched" ); + _mmState = MMState::BecameHost; + LOG ( "Matchmaking: peer %llu joined; we host", ( unsigned long long ) memberId ); + } +} + +void SteamManager::cancelMatchmaking() +{ + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_LeaveLobby ( SteamMatchmaking(), _lobbyId ); + + _lobbyId = 0; + _mmState = MMState::Idle; + _mmPeerId = 0; } @@ -420,7 +699,7 @@ void SteamManager::pump() { if ( cb.m_iCallback == SteamAPICallCompleted_t::k_iCallback ) { - // Async call result (CreateLobby / RequestLobbyList) + // Async call result (CreateLobby / RequestLobbyList / JoinLobby), matched by call id. const SteamAPICallCompleted_t *cc = ( const SteamAPICallCompleted_t * ) cb.m_pubParam; void *result = malloc ( cc->m_cubParam ); bool failed = false; @@ -428,20 +707,54 @@ void SteamManager::pump() if ( result && SteamAPI_ManualDispatch_GetAPICallResult ( _pipe, cc->m_hAsyncCall, result, cc->m_cubParam, cc->m_iCallback, &failed ) ) { - if ( _lobbyCreateCall && cc->m_hAsyncCall == _lobbyCreateCall ) + const uint64_t call = cc->m_hAsyncCall; + + if ( _lobbyCreateCall && call == _lobbyCreateCall ) { const LobbyCreated_t *r = ( const LobbyCreated_t * ) result; _lobbyCreateCall = 0; onLobbyCreatedResult ( ( ! failed && r->m_eResult == k_EResultOK ), r->m_ulSteamIDLobby ); } - else if ( _lobbyListCall && cc->m_hAsyncCall == _lobbyListCall ) + else if ( _lobbyListCall && call == _lobbyListCall ) { const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; _lobbyListCall = 0; const bool found = ( ! failed && r->m_nLobbiesMatching > 0 ); - const uint64 lobbyId = + const uint64_t id = found ? SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), 0 ) : 0; - onLobbyListResult ( found, lobbyId ); + onLobbyListResult ( found, id ); + } + else if ( _browseListCall && call == _browseListCall ) + { + const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; + _browseListCall = 0; + onBrowseListResult ( failed ? 0 : ( int ) r->m_nLobbiesMatching ); + } + else if ( _lobbyJoinCall && call == _lobbyJoinCall ) + { + const LobbyEnter_t *r = ( const LobbyEnter_t * ) result; + _lobbyJoinCall = 0; + onLobbyEntered ( r->m_ulSteamIDLobby, + ( ! failed && r->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess ) ); + } + else if ( _mmListCall && call == _mmListCall ) + { + const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; + _mmListCall = 0; + onMatchmakeListResult ( failed ? 0 : ( int ) r->m_nLobbiesMatching ); + } + else if ( _mmCreateCall && call == _mmCreateCall ) + { + const LobbyCreated_t *r = ( const LobbyCreated_t * ) result; + _mmCreateCall = 0; + onMatchmakeCreated ( ( ! failed && r->m_eResult == k_EResultOK ), r->m_ulSteamIDLobby ); + } + else if ( _mmJoinCall && call == _mmJoinCall ) + { + const LobbyEnter_t *r = ( const LobbyEnter_t * ) result; + _mmJoinCall = 0; + onMatchmakeEntered ( r->m_ulSteamIDLobby, + ( ! failed && r->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess ) ); } } @@ -455,6 +768,17 @@ void SteamManager::pump() ( int ) p->m_info.m_eState, p->m_info.m_identityRemote.GetSteamID64() ); } + else if ( cb.m_iCallback == LobbyChatUpdate_t::k_iCallback ) + { + // Member entered/left the lobby. Used to detect a peer joining a matchmaking lobby + // (host side); the lobby member list re-reads on the UI's periodic refresh anyway. + const LobbyChatUpdate_t *p = ( const LobbyChatUpdate_t * ) cb.m_pubParam; + if ( p->m_rgfChatMemberStateChange & k_EChatMemberStateChangeEntered ) + onMemberJoined ( p->m_ulSteamIDLobby, p->m_ulSteamIDUserChanged ); + } + // LobbyDataUpdate_t / LobbyChatMsg_t: Steam keeps the member-data cache current + // automatically, so the periodic lobbyMembers() refresh sees the latest values; no + // explicit handling needed. SteamAPI_ManualDispatch_FreeLastCallback ( _pipe ); } diff --git a/lib/SteamManager.hpp b/lib/SteamManager.hpp index 9a5156c1..edd5a819 100644 --- a/lib/SteamManager.hpp +++ b/lib/SteamManager.hpp @@ -7,6 +7,7 @@ #include #include +#include #include @@ -37,6 +38,9 @@ class SteamManager : private Timer::Owner // Local user's SteamID64 (0 if not initialized). uint64_t getSteamID() const; + // Local user's Steam persona (display) name; "" if not initialized. + std::string getPersonaName() const; + // Whether the SDR relay network is ready (ping data current). ConnectP2P before this is // ready tends to fail, so callers should gate on it. bool isRelayNetworkReady() const; @@ -60,33 +64,66 @@ class SteamManager : private Timer::Owner uint64_t getConnectionPeer ( uint32_t conn ) const; // ---- lobby / matchmaking (driven by the launcher UI) ---- - // Asynchronous; poll lobbyState() until Ready/Failed. SteamManager's pump runs the + // Asynchronous; poll the relevant *State() until Ready/Failed. SteamManager's pump runs the // underlying Steam callbacks, so the caller just needs to keep pumping the event loop. enum class LobbyState { Idle, Working, Ready, Failed }; - // Host: create a searchable lobby and advertise a short join code + our SteamID. - // On Ready, lobbyCode() returns the code to share. - void hostLobby(); + // Create a lobby and advertise a short join code + our SteamID. isPublic lobbies are returned + // by requestPublicLobbies(); non-public ("private") lobbies are excluded from browse but are + // still joinable by exact code. On Ready, lobbyCode() returns the code to share. + void createLobby ( bool isPublic ); - // Client: find the lobby advertising the given code and resolve the host's SteamID. - // On Ready, lobbyPeerId() returns the host SteamID64 to ConnectP2P to. + // Client: find the lobby advertising the given code and JOIN it (membership). On Ready, + // joinedLobbyId() is valid and lobbyMembers() lists the occupants. void joinLobbyByCode ( const std::string& code ); + // Join a specific lobby by id (e.g. one picked from the public browse list). + void joinLobbyById ( uint64_t lobbyId ); + void leaveLobby(); LobbyState lobbyState() const { return _lobbyState; } const std::string& lobbyCode() const { return _lobbyCode; } uint64_t lobbyPeerId() const { return _lobbyPeerId; } + uint64_t joinedLobbyId() const { return _lobbyId; } + uint64_t lobbyOwnerId() const; + + // ---- public lobby browse ---- + struct LobbyInfo { uint64_t lobbyId; std::string code; int memberCount; }; + + void requestPublicLobbies(); + LobbyState browseState() const { return _browseState; } + const std::vector& publicLobbies() const { return _publicLobbies; } + + // ---- members of the joined lobby ---- + struct MemberInfo { uint64_t steamId; std::string name; uint64_t challengingTarget; uint64_t hostId; }; + + // Re-enumerate the current lobby's members (cheap; reads Steam's cached member data). + std::vector lobbyMembers(); + + // Advertise our display name to the lobby (so peers can list us by name). + void setLobbyMemberName ( const std::string& name ); + + // ---- challenge handshake (member-data based, no relay) ---- + // Challenger advertises {challenging:, host_id:}; the target sees it on its next + // member refresh and connects as Client to our SteamID. clearChallenge() withdraws it. + void setChallenge ( uint64_t targetId ); + void clearChallenge(); + uint64_t myChallengeTarget() const { return _myChallenge; } + + // ---- region matchmaking (create-or-join) ---- + enum class MMState { Idle, Working, BecameHost, BecameClient, Failed }; + + void matchmakeRegion ( const std::string& region ); + void cancelMatchmaking(); + MMState mmState() const { return _mmState; } + uint64_t mmPeerId() const { return _mmPeerId; } // Run one manual-dispatch + message-drain cycle. Normally driven by the internal pump // timer once the EventManager loop is running, but callers (e.g. the lobby UI) can call // it directly in a wait loop when no EventManager loop is pumping yet. void pump(); - // Called by the internal Steam callback shim (defined in the .cpp). - void onLobbyCreatedResult ( bool ok, uint64_t lobbyId ); - void onLobbyListResult ( bool found, uint64_t lobbyId ); - // ---- routing registries (used by SteamSocket) ---- void registerConnection ( uint32_t conn, SteamSocket *socket ); @@ -109,8 +146,13 @@ class SteamManager : private Timer::Owner int _pipe = 0; // Pending async call-result ids (SteamAPICall_t) matched in the manual-dispatch pump. - uint64_t _lobbyCreateCall = 0; - uint64_t _lobbyListCall = 0; + uint64_t _lobbyCreateCall = 0; // createLobby() + uint64_t _lobbyListCall = 0; // joinLobbyByCode() code search + uint64_t _lobbyJoinCall = 0; // JoinLobby() for the lobby path + uint64_t _browseListCall = 0; // requestPublicLobbies() + uint64_t _mmListCall = 0; // matchmakeRegion() search + uint64_t _mmCreateCall = 0; // matchmakeRegion() create (host) + uint64_t _mmJoinCall = 0; // matchmakeRegion() join (client) TimerPtr _pumpTimer; @@ -119,8 +161,20 @@ class SteamManager : private Timer::Owner std::string _lobbyCode; // host's advertised code (valid when host + Ready) std::string _joinCode; // code the client is searching for uint64_t _lobbyId = 0; // current lobby (host or joined) - uint64_t _lobbyPeerId = 0; // resolved host SteamID (valid when client + Ready) + uint64_t _lobbyPeerId = 0; // resolved host SteamID (legacy 1v1; unused by the lobby path) bool _lobbyIsHost = false; + bool _lobbyIsPublic = true; + std::string _memberName; // our display name, advertised on create/join + uint64_t _myChallenge = 0; // who we are currently challenging (0 == none) + + // Browse state + LobbyState _browseState = LobbyState::Idle; + std::vector _publicLobbies; + + // Matchmaking state + MMState _mmState = MMState::Idle; + std::string _mmRegion; + uint64_t _mmPeerId = 0; // conn handle -> the SteamSocket that owns it (client or accepted child) std::unordered_map _connections; @@ -128,6 +182,19 @@ class SteamManager : private Timer::Owner // listen handle -> the server SteamSocket that owns it std::unordered_map _listeners; + // --- internal callback handlers (called from pump's manual dispatch) --- + void onLobbyCreatedResult ( bool ok, uint64_t lobbyId ); + void onLobbyListResult ( bool found, uint64_t lobbyId ); + void onBrowseListResult ( int count ); + void onLobbyEntered ( uint64_t lobbyId, bool ok ); + void onMatchmakeListResult ( int count ); + void onMatchmakeCreated ( bool ok, uint64_t lobbyId ); + void onMatchmakeEntered ( uint64_t lobbyId, bool ok ); + void onMemberJoined ( uint64_t lobbyId, uint64_t memberId ); + + // Apply the lobby-data tags + our member name for a lobby we just created. + void tagLobby ( uint64_t lobbyId, const char *type ); + void startPump(); void stopPump(); diff --git a/lib/SteamMatchmaking.cpp b/lib/SteamMatchmaking.cpp new file mode 100644 index 00000000..27116a18 --- /dev/null +++ b/lib/SteamMatchmaking.cpp @@ -0,0 +1,114 @@ +#ifdef ENABLE_STEAM + +#include "SteamMatchmaking.hpp" +#include "SteamManager.hpp" +#include "SteamSocket.hpp" // steamAddr() +#include "Logger.hpp" + +#include // Sleep, VK_ESCAPE + +using namespace std; + + +// Bounded relay-readiness wait (~5s). The matched-wait below is intentionally unbounded: queueing +// for an opponent can take arbitrarily long (cancel with ESC under RELEASE, like the server path). +#define READY_TRIES ( 500 ) +#define READY_SLEEP_MS ( 10 ) +#define MATCH_SLEEP_MS ( 20 ) + + +SteamMatchmaking::SteamMatchmaking ( IMatchmakingBackend::Owner* owner, string region ) + : _region ( region ) +{ + this->owner = owner; +} + +SteamMatchmaking::~SteamMatchmaking() +{ + SteamManager::get().cancelMatchmaking(); + // Release the ref taken by the MainUi backend factory. Guarded no-op if MainApp already shut + // Steam down when the match launched. + SteamManager::get().deref(); +} + +void SteamMatchmaking::start() +{ + // Steam was already ref()'d by the MainUi factory; the actual search begins in waitConnected(). + _cancel = false; +} + +bool SteamMatchmaking::isRunning() +{ + return SteamManager::get().isInitialized(); +} + +void SteamMatchmaking::stop() +{ + _cancel = true; + SteamManager::get().cancelMatchmaking(); +} + +void SteamMatchmaking::sendHostReady() +{ + // No-op for Steam: the client already resolved the host's SteamID from the lobby, so there is + // no "host is ready" round-trip to make (that exists only in the relay protocol). +} + +void SteamMatchmaking::waitConnected() +{ + SteamManager& sm = SteamManager::get(); + + // Wait for the SDR relay network so the later ConnectP2P succeeds, then kick the search. + for ( int i = 0; i < READY_TRIES && ! sm.isRelayNetworkReady(); ++i ) + { + sm.pump(); + Sleep ( READY_SLEEP_MS ); + } + + sm.matchmakeRegion ( _region ); + connectionSuccess = true; +} + +void SteamMatchmaking::waitMatched() +{ + SteamManager& sm = SteamManager::get(); + + // Queue until a peer is found (host) or we joined someone (client), or the user cancels. + while ( ! _cancel && sm.mmState() == SteamManager::MMState::Working ) + { + sm.pump(); + Sleep ( MATCH_SLEEP_MS ); + } + + const SteamManager::MMState st = sm.mmState(); + + if ( st == SteamManager::MMState::BecameHost ) + { + matchSuccess = true; + if ( owner ) + owner->setMode ( this, "Host" ); + } + else if ( st == SteamManager::MMState::BecameClient ) + { + matchSuccess = true; + if ( owner ) + { + owner->setMode ( this, "Client" ); + owner->setAddr ( this, steamAddr ( sm.mmPeerId() ) ); + } + } + else + { + // Cancelled or failed. + if ( owner ) + owner->setMode ( this, "Offline" ); + } +} + +void SteamMatchmaking::keyboardEvent ( uint32_t vkCode, uint32_t scanCode, bool isExtended, bool isDown ) +{ + if ( vkCode == VK_ESCAPE && ! ignoreKb ) + stop(); +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamMatchmaking.hpp b/lib/SteamMatchmaking.hpp new file mode 100644 index 00000000..ddb1724d --- /dev/null +++ b/lib/SteamMatchmaking.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "IMatchmakingBackend.hpp" + +#include + + +// Steam-backed matchmaking (implements IMatchmakingBackend). Backs the existing "Server -> +// Matchmaking" UI with a region-filtered create-or-join over Steam lobbies via SteamManager. No +// background thread or socket: pumped from the UI thread (waitConnected/waitMatched). Only +// meaningful when built with ENABLE_STEAM (the .cpp body is guarded). +class SteamMatchmaking : public IMatchmakingBackend +{ +public: + + SteamMatchmaking ( IMatchmakingBackend::Owner* owner, std::string region ); + ~SteamMatchmaking(); + + void start() override; + bool isRunning() override; + void stop() override; + + void sendHostReady() override; + + bool isSteam() const override { return true; } + bool needsEventManager() const override { return false; } + void waitConnected() override; + void waitMatched() override; + + void keyboardEvent ( uint32_t vkCode, uint32_t scanCode, bool isExtended, bool isDown ) override; + +private: + + std::string _region; + bool _cancel = false; // set by ESC / stop() to break the matched-wait loop +}; diff --git a/targets/MainUi.cpp b/targets/MainUi.cpp index 38d19ced..5d0011fb 100644 --- a/targets/MainUi.cpp +++ b/targets/MainUi.cpp @@ -8,9 +8,13 @@ #include "StringUtils.hpp" #include "NetplayStates.hpp" #include "EventManager.hpp" +#include "Lobby.hpp" +#include "MatchmakingManager.hpp" #ifdef ENABLE_STEAM #include "SteamManager.hpp" #include "SteamSocket.hpp" +#include "SteamLobby.hpp" +#include "SteamMatchmaking.hpp" #endif #include @@ -128,119 +132,6 @@ void MainUi::netplay ( RunFuncPtr run ) _ui->pop(); } -void MainUi::steam ( RunFuncPtr run ) -{ -#ifndef ENABLE_STEAM - _ui->pushRight ( new ConsoleUi::TextBox ( "This build was compiled without Steam support." ), { 1, 0 } ); - _ui->popUntilUserInput(); - _ui->pop(); -#else - _ui->pushRight ( new ConsoleUi::Menu ( "Steam", { "Host (create code)", "Join (enter code)" }, "Cancel" ) ); - const int choice = _ui->popUntilUserInput ( true )->resultInt; - _ui->pop(); - - if ( choice < 0 || choice > 1 ) - return; - - // AutoManager (managers initialized) BEFORE ref(), so the pump timer created inside - // ref() lives in an initialized TimerManager. - AutoManager _; - - if ( ! SteamManager::get().ref() ) - { - _ui->pushBelow ( new ConsoleUi::TextBox ( - "Could not initialize Steam.\n" - "Is Steam running, and do you own Melty Blood on this account?" ), { 1, 0 } ); - _ui->popUntilUserInput(); - _ui->pop(); - return; - } - - // The lobby is async. There is no EventManager loop running in the menu, so pump - // SteamManager directly (manual dispatch needs no event loop) until it resolves. - // ~5s timeout (500 * 10ms). The in-game session pumps via the timer once MainApp runs - // the EventManager loop. - auto pumpUntilDone = [] () - { - for ( int i = 0; i < 500 && SteamManager::get().lobbyState() == SteamManager::LobbyState::Working; ++i ) - { - SteamManager::get().pump(); - Sleep ( 10 ); - } - }; - - bool proceed = false; - - if ( choice == 0 ) // Host - { - SteamManager::get().hostLobby(); - display ( "Creating Steam lobby..." ); - pumpUntilDone(); - - if ( SteamManager::get().lobbyState() == SteamManager::LobbyState::Ready ) - { - if ( gameMode ( true ) ) - { - initialConfig.mode.value = ClientMode::Host; - initialConfig.mode.flags |= ClientMode::IsSteam; - _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); - display ( "Steam join code: " + SteamManager::get().lobbyCode() - + "\n\nWaiting for opponent to join..." ); - proceed = true; - } - // else: user cancelled mode select -> just back out, no error. - } - else - { - sessionError = "Failed to create Steam lobby"; - } - } - else // Join - { - ConsoleUi::Prompt *menu = new ConsoleUi::Prompt ( ConsoleUi::Prompt::String, "Enter Steam join code:" ); - _ui->pushRight ( menu, { 1, 0 } ); // Expand width - _ui->popUntilUserInput(); - const string code = trimmed ( menu->resultStr ); - _ui->pop(); - - if ( ! code.empty() ) - { - SteamManager::get().joinLobbyByCode ( code ); - display ( "Searching for Steam lobby '" + code + "'..." ); - pumpUntilDone(); - - if ( SteamManager::get().lobbyState() == SteamManager::LobbyState::Ready ) - { - initialConfig.mode.value = ClientMode::Client; - initialConfig.mode.flags |= ClientMode::IsSteam; - _address = IpAddrPort ( steamAddr ( SteamManager::get().lobbyPeerId() ), ( uint16_t ) 0 ); - proceed = true; - } - else - { - sessionError = "Could not find a Steam lobby with that code"; - } - } - } - - if ( proceed ) - { - _netplayConfig.clear(); - RUN ( _address, initialConfig ); - _ui->popNonUserInput(); - } - - // Release our ref (guarded no-op if MainApp already shut Steam down before launching). - SteamManager::get().deref(); - - if ( ! sessionError.empty() ) - { - _ui->pushBelow ( new ConsoleUi::TextBox ( sessionError ), { 1, 0 } ); // Expand width - sessionError.clear(); - } -#endif -} - void MainUi::server ( RunFuncPtr run ) { serverMode = true; @@ -284,6 +175,13 @@ void MainUi::server ( RunFuncPtr run ) } void MainUi::wait() { +#ifdef ENABLE_STEAM + // The Steam lobby has no background thread to signal uiCondVar; it is pumped on this thread. + if ( _lobby && _lobby->isSteam() ) { + _lobby->pumpUntilSettled(); + return; + } +#endif while ( uiCondVar.wait ( uiMutex, 2500 ) ) { if ( ! EventManager::get().isRunning() ) { break; @@ -293,18 +191,31 @@ void MainUi::wait() { void MainUi::lobby( RunFuncPtr run ) { - _lobby.reset( new Lobby( this ) ); ifstream infile; - _lobby->_address = *serverList.cbegin(); + // AutoManager (managers initialized) BEFORE SteamManager::ref(), so the pump timer created + // inside ref() lives in an initialized TimerManager. AutoManager _; +#ifdef ENABLE_STEAM + // Prefer Steam lobbies when enabled + available; otherwise fall back to the relay server. + if ( _config.getInteger ( "useSteamLobby" ) && SteamManager::get().ref() ) + { + _lobby.reset ( new SteamLobby ( this ) ); + } + else +#endif + { + _lobby.reset ( new Lobby ( this ) ); + _lobby->_address = *serverList.cbegin(); + } + _lobby->start(); display ( "Connecting to server..." ); LOCK ( uiMutex ); wait(); _ui->pop(); - if ( ! EventManager::get().isRunning() ) { + if ( _lobby->needsEventManager() && ! EventManager::get().isRunning() ) { sessionError = "Failed to connect"; return; } @@ -326,11 +237,13 @@ void MainUi::lobby( RunFuncPtr run ) string name = _config.getString ( "displayName" ); for ( ;; ) { - if ( ! _lobby->isRunning() || ! EventManager::get().isRunning() ) { + if ( ! _lobby->isRunning() || ( _lobby->needsEventManager() && ! EventManager::get().isRunning() ) ) { LOG( "Lobby stopped" ); sessionError = "Disconnected!"; break; } + // Steam backend has no background thread; pull fresh lobby/member state on this thread. + _lobby->refresh(); // Update ui LOG("Getting lobby mutex"); _lobby->entryMutex.lock(); @@ -434,6 +347,12 @@ void MainUi::lobby( RunFuncPtr run ) wait(); if ( _lobby->hostSuccess ) { initialConfig.mode.value = ClientMode::Host; +#ifdef ENABLE_STEAM + if ( _lobby->isSteam() ) { + initialConfig.mode.flags |= ClientMode::IsSteam; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + } +#endif addressSelected = true; LOG( "Lobby host Prerun"); RUN ( _address, initialConfig ); @@ -458,7 +377,14 @@ void MainUi::lobby( RunFuncPtr run ) _lobby->preaccept( lobbyIds[mode] ); initialConfig.mode.value = ClientMode::Client; _netplayConfig.clear(); - _address = lobbyIps[ mode ]; +#ifdef ENABLE_STEAM + if ( _lobby->isSteam() ) { + initialConfig.mode.flags |= ClientMode::IsSteam; + // lobbyIps[mode] is a "steam:" string; store it verbatim. + _address = IpAddrPort ( lobbyIps[ mode ], ( uint16_t ) 0 ); + } else +#endif + _address = lobbyIps[ mode ]; addressSelected = true; LOG( "Lobby join Prerun"); RUN ( _address, initialConfig ); @@ -518,16 +444,26 @@ void MainUi::lobby( RunFuncPtr run ) } } } - LOG( "Lobby Stopping EM"); - EventManager::get().stop(); - LOG( "Lobby after Stopping EM"); - if ( !addressSelected ) { - display ( "Disconnecting from server..." ); +#ifdef ENABLE_STEAM + if ( _lobby->isSteam() ) { + // No EventManager loop / background thread to wind down for the Steam backend. + if ( !addressSelected ) { + display ( "Leaving lobby..." ); + } + } else +#endif + { + LOG( "Lobby Stopping EM"); + EventManager::get().stop(); + LOG( "Lobby after Stopping EM"); + if ( !addressSelected ) { + display ( "Disconnecting from server..." ); + } + //LOCK ( uiMutex ); + LOG( "Lobby wait mutex"); + //if ( lo) + uiCondVar.wait ( uiMutex ); } - //LOCK ( uiMutex ); - LOG( "Lobby wait mutex"); - //if ( lo) - uiCondVar.wait ( uiMutex ); LOG( "Lobby mutex signaled"); _ui->pop(); LOG( "Lobby clear ui"); @@ -545,7 +481,17 @@ void MainUi::matchmaking( RunFuncPtr run ) isMatchmaking = true; ifstream infile; string region = _config.getString ( "matchmakingRegion" ); - _mmm.reset( new MatchmakingManager( this, *serverList.cbegin(), region ) ); + +#ifdef ENABLE_STEAM + // Prefer Steam matchmaking when enabled + available; otherwise fall back to the relay server. + // ref() here creates the pump timer; TimerManager::initialize() (run by AutoManager below) + // preserves already-allocated timers, so the pre-AutoManager order is fine. + if ( _config.getInteger ( "useSteamLobby" ) && SteamManager::get().ref() ) + _mmm.reset ( new SteamMatchmaking ( this, region ) ); + else +#endif + _mmm.reset( new MatchmakingManager( this, *serverList.cbegin(), region ) ); + AutoManager _ ( _mmm.get(), getConsoleWindow() ); _mmm->start(); @@ -553,26 +499,41 @@ void MainUi::matchmaking( RunFuncPtr run ) display ( "Connecting to server..." ); LOCK ( uiMutex ); LOG( "lockConnectionMutex"); - uiCondVar.wait ( uiMutex ); +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) + _mmm->waitConnected(); + else +#endif + uiCondVar.wait ( uiMutex ); LOG( "unlockConnectionMutex"); _ui->pop(); - if ( ! EventManager::get().isRunning() || !_mmm->isRunning() ) { + if ( ( _mmm->needsEventManager() && ! EventManager::get().isRunning() ) || !_mmm->isRunning() ) { sessionError = "Failed to connect"; return; } display ( "Waiting for opponent..." ); LOG( "lockWaitingMutex"); - uiCondVar.wait ( uiMutex ); +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) + _mmm->waitMatched(); + else +#endif + uiCondVar.wait ( uiMutex ); LOG( "unlockWaitingMutex"); _ui->pop(); - if ( ! EventManager::get().isRunning() ) { + if ( _mmm->needsEventManager() && ! EventManager::get().isRunning() ) { sessionError = "Disconnected"; return; } if ( initialConfig.mode.value == ClientMode::Client ) { _netplayConfig.clear(); +#ifdef ENABLE_STEAM + // _address was already set to steam: by setAddr(); just tag the session. + if ( _mmm->isSteam() ) + initialConfig.mode.flags |= ClientMode::IsSteam; +#endif _mmm->ignoreKb = true; LOG( "MMM preRun"); RUN ( _address, initialConfig ); @@ -580,7 +541,13 @@ void MainUi::matchmaking( RunFuncPtr run ) _ui->popNonUserInput(); } else if ( initialConfig.mode.value == ClientMode::Host ) { _netplayConfig.clear(); - _address = "46318"; +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) { + initialConfig.mode.flags |= ClientMode::IsSteam; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + } else +#endif + _address = "46318"; _mmm->ignoreKb = true; LOG( "MMM preRun"); RUN ( _address, initialConfig ); @@ -590,12 +557,19 @@ void MainUi::matchmaking( RunFuncPtr run ) sessionError = "Session Closed"; } - LOG( "MMM stopping EM"); - EventManager::get().stop(); - LOG( "MMM done" ); - display ( "Disconnecting from server..." ); - uiCondVar.wait ( uiMutex ); - _ui->pop(); +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) { + // No EventManager loop / background thread to wind down for the Steam backend. + } else +#endif + { + LOG( "MMM stopping EM"); + EventManager::get().stop(); + LOG( "MMM done" ); + display ( "Disconnecting from server..." ); + uiCondVar.wait ( uiMutex ); + _ui->pop(); + } isMatchmaking = false; } @@ -1097,6 +1071,9 @@ void MainUi::settings() "Trial Input Guide Settings", "Experimental Settings", "About", +#ifdef ENABLE_STEAM + "Lobby backend", +#endif }; _ui->pushRight ( new ConsoleUi::Menu ( "Settings", options, "Back" ) ); @@ -1531,6 +1508,25 @@ void MainUi::settings() system ( "@pause > nul" ); break; +#ifdef ENABLE_STEAM + case 14: + _ui->pushInFront ( new ConsoleUi::Menu ( "Lobby / Matchmaking backend", + { "Steam", "Relay server" }, "Cancel" ), + { 0, 0 }, true ); // Don't expand but DO clear top + + _ui->top()->setPosition ( _config.getInteger ( "useSteamLobby" ) ? 0 : 1 ); + _ui->popUntilUserInput(); + + if ( _ui->top()->resultInt >= 0 && _ui->top()->resultInt <= 1 ) + { + _config.setInteger ( "useSteamLobby", _ui->top()->resultInt == 0 ? 1 : 0 ); + saveConfig(); + } + + _ui->pop(); + break; +#endif + default: break; } @@ -1649,6 +1645,9 @@ void MainUi::initialize() _config.setInteger ( "frameLimiter", 0 ); _config.setInteger ( "stageAnimations", 1 ); _config.setString ( "matchmakingRegion", "NA West" ); + // Back the Server -> Lobby/Matchmaking menus with Steam when available (only meaningful in + // ENABLE_STEAM builds; falls back to the relay server if Steam can't initialize). + _config.setInteger ( "useSteamLobby", 1 ); _config.setDouble ( "heldStartDuration", 1.5 ); _config.setInteger ( "updateChannel", static_cast(MainUpdater::Channel::Stable ) ); _config.setInteger ( "trialScreenFlashColor", 0xff0000ff ); @@ -1770,7 +1769,6 @@ void MainUi::main ( RunFuncPtr run ) const vector options = { "Netplay", - "Steam", "Spectate", "Broadcast", "Offline", @@ -1828,7 +1826,7 @@ void MainUi::main ( RunFuncPtr run ) _ui->clearRight(); - if ( mainSelection >= 0 && mainSelection <= 5 ) + if ( mainSelection >= 0 && mainSelection <= 4 ) { _config.setInteger ( "lastMainMenuPosition", mainSelection + 1 ); saveConfig(); @@ -1841,38 +1839,34 @@ void MainUi::main ( RunFuncPtr run ) break; case 1: - steam ( run ); - break; - - case 2: spectate ( run ); break; - case 3: + case 2: broadcast ( run ); break; - case 4: + case 3: offline ( run ); break; - case 5: + case 4: server( run ); break; - case 6: + case 5: controls(); break; - case 7: + case 6: settings(); break; - case 8: + case 7: update(); break; - case 9: + case 8: results(); break; @@ -2384,7 +2378,7 @@ void MainUi::fetchProgress ( MainUpdater *updater, const MainUpdater::Type& type bar->update ( bar->length * progress ); } -void MainUi::connectionFailed ( Lobby *lobby ) +void MainUi::connectionFailed ( ILobbyBackend *lobby ) { LOG( "mainUI Lobby Connection Failed" ); EventManager::get().stop(); @@ -2392,14 +2386,14 @@ void MainUi::connectionFailed ( Lobby *lobby ) unlock( lobby ); } -void MainUi::unlock ( Lobby *lobby ) +void MainUi::unlock ( ILobbyBackend *lobby ) { LOG( "mainUI lobby unlock" ); LOCK ( uiMutex ); uiCondVar.signal(); } -void MainUi::connectionFailed ( MatchmakingManager *mmm ) +void MainUi::connectionFailed ( IMatchmakingBackend *mmm ) { LOG( "mainUI mmm Connection Failed" ); EventManager::get().stop(); @@ -2412,7 +2406,7 @@ void MainUi::connectionFailed ( MatchmakingManager *mmm ) } } -void MainUi::unlock ( MatchmakingManager *mmm ) +void MainUi::unlock ( IMatchmakingBackend *mmm ) { LOG( "mainUI mmm unlock" ); LOCK ( uiMutex ); @@ -2420,13 +2414,18 @@ void MainUi::unlock ( MatchmakingManager *mmm ) } -void MainUi::setAddr ( MatchmakingManager *mmm, string addr ) +void MainUi::setAddr ( IMatchmakingBackend *mmm, string addr ) { - _address = addr; + // A steam: address must be stored verbatim (the ':' would otherwise be parsed as a port); + // build the IpAddrPort directly like the Steam socket path does. + if ( addr.compare ( 0, 6, "steam:" ) == 0 ) + _address = IpAddrPort ( addr, ( uint16_t ) 0 ); + else + _address = addr; } -void MainUi::setMode ( MatchmakingManager *mmm, string mode ) +void MainUi::setMode ( IMatchmakingBackend *mmm, string mode ) { if ( mode == "Host" ) { initialConfig.mode.value = ClientMode::Host; diff --git a/targets/MainUi.hpp b/targets/MainUi.hpp index d992ece5..51d5f6df 100644 --- a/targets/MainUi.hpp +++ b/targets/MainUi.hpp @@ -6,8 +6,8 @@ #include "ControllerManager.hpp" #include "KeyValueStore.hpp" #include "MainUpdater.hpp" -#include "Lobby.hpp" -#include "MatchmakingManager.hpp" +#include "ILobbyBackend.hpp" +#include "IMatchmakingBackend.hpp" #include #include @@ -30,8 +30,8 @@ class MainUi : private Controller::Owner , private ControllerManager::Owner , private MainUpdater::Owner - , private Lobby::Owner - , private MatchmakingManager::Owner + , private ILobbyBackend::Owner + , private IMatchmakingBackend::Owner { public: @@ -81,9 +81,9 @@ class MainUi std::shared_ptr _ui; - std::shared_ptr _lobby; + std::shared_ptr _lobby; - std::shared_ptr _mmm; + std::shared_ptr _mmm; MainUpdater _updater; @@ -104,7 +104,6 @@ class MainUi bool isMatchmaking = false; void netplay ( RunFuncPtr run ); - void steam ( RunFuncPtr run ); void server ( RunFuncPtr run ); void lobby ( RunFuncPtr run ); void matchmaking ( RunFuncPtr run ); @@ -151,12 +150,12 @@ class MainUi void fetchProgress ( MainUpdater *updater, const MainUpdater::Type& type, double progress ) override; - void connectionFailed ( Lobby *lobby ); - void unlock ( Lobby *lobby ); + void connectionFailed ( ILobbyBackend *lobby ) override; + void unlock ( ILobbyBackend *lobby ) override; - void connectionFailed( MatchmakingManager* lobby ); - void setAddr( MatchmakingManager* lobby, std::string addr ); - void setMode( MatchmakingManager* lobby, std::string mode ); - void unlock( MatchmakingManager* lobby ); + void connectionFailed( IMatchmakingBackend* lobby ) override; + void setAddr( IMatchmakingBackend* lobby, std::string addr ) override; + void setMode( IMatchmakingBackend* lobby, std::string mode ) override; + void unlock( IMatchmakingBackend* lobby ) override; }; From 8075107492108fc9e8595a5efb8fca6d25714d9a Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 16:27:40 -0300 Subject: [PATCH 03/25] Use KOTH mechanism for Steam lobbies --- Makefile | 4 +- lib/ILobbyBackend.hpp | 50 ++++++ lib/LobbyQueue.hpp | 74 +++++++++ lib/ProtocolEnums.hpp | 1 + lib/SteamLobby.cpp | 313 +++++++++++++++++++++++++++++++++----- lib/SteamLobby.hpp | 24 +++ lib/SteamManager.cpp | 31 ++++ lib/SteamManager.hpp | 8 + netplay/Messages.hpp | 22 +++ targets/DllMain.cpp | 12 ++ targets/MainApp.cpp | 7 + targets/MainUi.cpp | 73 ++++++++- targets/MainUi.hpp | 16 ++ tests/Test.LobbyQueue.cpp | 100 ++++++++++++ 14 files changed, 695 insertions(+), 40 deletions(-) create mode 100644 lib/LobbyQueue.hpp create mode 100644 tests/Test.LobbyQueue.cpp diff --git a/Makefile b/Makefile index 3111260c..a77de857 100644 --- a/Makefile +++ b/Makefile @@ -259,7 +259,7 @@ res/icon.res: res/icon.rc res/icon.ico LOGGING_PREFIX = build_logging_$(BRANCH) DEBUGGER_LIB_OBJECTS = \ - $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o,$(LIB_OBJECTS))) + $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o lib/SteamLobby.o lib/SteamMatchmaking.o,$(LIB_OBJECTS))) tools/$(DEBUGGER): tools/Debugger.cpp $(DEBUGGER_LIB_OBJECTS) $(CXX) -o $@ $(CC_FLAGS) $(LOGGING_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) \ @@ -271,7 +271,7 @@ tools/$(DEBUGGER): tools/Debugger.cpp $(DEBUGGER_LIB_OBJECTS) GENERATOR_LIB_OBJECTS = \ - $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o,$(LIB_OBJECTS))) + $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o lib/SteamLobby.o lib/SteamMatchmaking.o,$(LIB_OBJECTS))) tools/$(GENERATOR): tools/Generator.cpp $(GENERATOR_LIB_OBJECTS) $(CXX) -o $@ $(CC_FLAGS) $(LOGGING_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) diff --git a/lib/ILobbyBackend.hpp b/lib/ILobbyBackend.hpp index 8ba75714..9f7c09bc 100644 --- a/lib/ILobbyBackend.hpp +++ b/lib/ILobbyBackend.hpp @@ -26,6 +26,36 @@ enum LobbyMode }; +// Role this client plays in the current king-of-the-hill match. +enum class QueueRole +{ + None, // not in a match: sit in the lobby menu (un-readied) + Host, // play, hosting (the reigning "king") + Client, // play, connecting to the king + Spectator, // readied but waiting: auto-spectate the live match +}; + +// Phase of the queue, broadcast by the lobby owner. +enum class QueuePhase +{ + Assembling, // fewer than two ready, or between matches: no live match + Playing, // a match is live (hostId vs clientId) +}; + +// Snapshot of the king-of-the-hill queue, rebuilt each refresh() and read by MainUi (Steam only). +struct QueueState +{ + uint32_t gen = 0; // current match generation; 0 == none yet + QueuePhase phase = QueuePhase::Assembling; + QueueRole myRole = QueueRole::None; + uint64_t hostId = 0; // current match host (the king) + uint64_t clientId = 0; // current challenger + IpAddrPort hostAddr; // steam:, for Client / Spectator to connect to + bool iAmReady = false; + std::vector rows; // display rows for the lobby UI +}; + + // Backend interface for the "Server -> Lobby" menu. MainUi drives this surface; a concrete // backend is either the relay/Concerto server (ServerLobby, a Thread+Socket) or Steam lobbies // (SteamLobby, driven by SteamManager's pump). The seam virtuals (isSteam/needsEventManager/ @@ -98,4 +128,24 @@ struct ILobbyBackend // MainUi's uiCondVar from its thread instead, so this is a no-op there; the Steam backend // runs a bounded pump loop until its pending op reaches a terminal state. virtual void pumpUntilSettled() {} + + // ---- king-of-the-hill queue (Steam backend only; no-ops elsewhere) ---- + + // True if this backend drives the auto-matchmaking queue instead of the manual challenge flow. + virtual bool supportsQueue() const { return false; } + + // Toggle our own "ready to play" flag (advertised as member data). + virtual void setReady ( bool ready ) {} + + // The current queue snapshot. Called by MainUi while it holds entryMutex (like getMenu()), so + // implementations must NOT re-lock; refresh() rebuilds the cached snapshot under the lock. + virtual QueueState getQueueState() { return {}; } + + // Publish the outcome of match `gen` (our own member data). Both players report; on a clean + // finish they agree, on a disconnect only the survivor reports itself. + virtual void reportResult ( uint32_t gen, uint64_t winnerId ) {} + + // Owner-only: advance the queue and publish the next assignment. No-op when not the owner. + // Safe to call every UI tick. + virtual void coordinatorTick() {} }; diff --git a/lib/LobbyQueue.hpp b/lib/LobbyQueue.hpp new file mode 100644 index 00000000..ed71d4e4 --- /dev/null +++ b/lib/LobbyQueue.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + + +// King-of-the-hill queue ordering rules, deliberately free of any Steam / networking dependency +// so they can be unit-tested in isolation (see tests/Test.LobbyQueue.cpp). SteamLobby owns the +// transport + state; these functions only decide who is where in line. +// +// The queue is an ordered list of readied players' SteamIDs, front..back. The two at the front +// play the current match: order[0] hosts (the reigning "king") and order[1] challenges. When the +// match ends the winner stays at the front and the loser drops to the very back. +namespace LobbyQueue +{ + +inline bool contains ( const std::vector& v, uint64_t x ) +{ + return std::find ( v.begin(), v.end(), x ) != v.end(); +} + +// Drop ids no longer in `ready` (left or un-readied) while preserving their relative order, then +// append any newly-readied ids (in `ready` but missing from `order`) to the back in `ready` order. +inline std::vector reconcile ( const std::vector& order, + const std::vector& ready ) +{ + std::vector out; + + for ( uint64_t id : order ) + if ( contains ( ready, id ) && ! contains ( out, id ) ) + out.push_back ( id ); + + for ( uint64_t id : ready ) + if ( ! contains ( out, id ) ) + out.push_back ( id ); + + return out; +} + +// Advance the queue after a match between `host` (front) and `client` (second) won by `winner`. +// King-of-the-hill: the winner becomes/stays the front king; the loser drops to the very back. +// Members missing from `ready` are removed; newly-readied members are appended ahead of the loser. +// `winner` is only re-seated if it is still in `ready`. +inline std::vector advance ( const std::vector& order, + uint64_t host, uint64_t client, uint64_t winner, + const std::vector& ready ) +{ + const uint64_t loser = ( winner == host ) ? client : host; + + std::vector out; + + // The winner is the new king at the front (if still ready). + if ( winner && contains ( ready, winner ) ) + out.push_back ( winner ); + + // The existing queue order, minus the two who just played. + for ( uint64_t id : order ) + if ( id != winner && id != loser && contains ( ready, id ) && ! contains ( out, id ) ) + out.push_back ( id ); + + // Members who readied / joined during the match, ahead of the loser. + for ( uint64_t id : ready ) + if ( id != loser && ! contains ( out, id ) ) + out.push_back ( id ); + + // The loser goes to the very back. + if ( loser && loser != winner && contains ( ready, loser ) ) + out.push_back ( loser ); + + return out; +} + +} // namespace LobbyQueue diff --git a/lib/ProtocolEnums.hpp b/lib/ProtocolEnums.hpp index 1ccae9c3..c6d7d3c2 100644 --- a/lib/ProtocolEnums.hpp +++ b/lib/ProtocolEnums.hpp @@ -32,3 +32,4 @@ VersionConfig, JoysticksChanged, TransitionIndex, PaletteManager, +MatchResult, diff --git a/lib/SteamLobby.cpp b/lib/SteamLobby.cpp index 4109cbac..ce894831 100644 --- a/lib/SteamLobby.cpp +++ b/lib/SteamLobby.cpp @@ -3,10 +3,12 @@ #include "SteamLobby.hpp" #include "SteamManager.hpp" #include "SteamSocket.hpp" // steamAddr() +#include "LobbyQueue.hpp" #include "Logger.hpp" #include // Sleep #include +#include using namespace std; @@ -17,6 +19,54 @@ using namespace std; #define SETTLE_SLEEP_MS ( 10 ) +// ---- king-of-the-hill queue keys ---- +// Lobby data (owner-written) broadcasts the current match assignment; member data (self-written) +// carries each player's ready flag and reported result. +static const char *Q_GEN_KEY = "q_gen"; // match generation counter +static const char *Q_STATE_KEY = "q_state"; // "PLAYING" / "ASSEMBLING" +static const char *Q_ORDER_KEY = "q_order"; // comma-separated SteamIDs, front..back +static const char *Q_HOST_KEY = "q_host"; // current king (host) SteamID +static const char *Q_CLIENT_KEY = "q_client"; // current challenger SteamID +static const char *READY_KEY = "q_ready"; // member: "1" if readied to play +static const char *R_GEN_KEY = "q_rgen"; // member: gen this reported result is for +static const char *R_WINNER_KEY = "q_rwinner"; // member: winning SteamID for R_GEN + + +static string joinIds ( const vector& ids ) +{ + string s; + for ( size_t i = 0; i < ids.size(); ++i ) + { + if ( i ) + s += ","; + char b[32]; + snprintf ( b, sizeof ( b ), "%llu", ( unsigned long long ) ids[i] ); + s += b; + } + return s; +} + +static vector splitIds ( const string& s ) +{ + vector out; + size_t i = 0; + while ( i < s.size() ) + { + size_t j = s.find ( ',', i ); + if ( j == string::npos ) + j = s.size(); + if ( j > i ) + { + const uint64_t v = strtoull ( s.substr ( i, j - i ).c_str(), nullptr, 10 ); + if ( v ) + out.push_back ( v ); + } + i = j + 1; + } + return out; +} + + SteamLobby::SteamLobby ( ILobbyBackend::Owner* owner ) { this->owner = owner; @@ -47,6 +97,9 @@ bool SteamLobby::isRunning() void SteamLobby::stop() { + // Drop out of the play queue before leaving so the owner doesn't keep us in the rotation. + if ( _ready ) + setReady ( false ); SteamManager::get().leaveLobby(); } @@ -63,7 +116,7 @@ vector SteamLobby::getMenu() case CONCERTO_BROWSE: return publiclobbies; case CONCERTO_LOBBY: - return lobbyentries; + return _qs.rows; default: return {}; } @@ -209,47 +262,15 @@ void SteamLobby::refresh() for ( int i = 0; i < 3; ++i ) sm.pump(); - LOCK ( entryMutex ); - if ( mode == CONCERTO_LOBBY ) { - const uint64_t myId = sm.getSteamID(); - const vector members = sm.lobbyMembers(); - - lobbyentries.clear(); - lobbyips.clear(); - lobbyids.clear(); - - for ( const SteamManager::MemberInfo& m : members ) - { - if ( m.steamId == myId ) - continue; // don't list / challenge ourselves - - char idStr[32]; - snprintf ( idStr, sizeof ( idStr ), "%llu", ( unsigned long long ) m.steamId ); - - string disp = m.name.empty() ? string ( idStr ) : m.name; - string ip = "None"; - - if ( m.challengingTarget == myId ) - { - // This member is challenging us and is hosting -> we connect to them as Client. - ip = steamAddr ( m.hostId ? m.hostId : m.steamId ); - } - else if ( m.challengingTarget != 0 ) - { - disp += " (busy)"; - } - - lobbyentries.push_back ( disp ); - lobbyids.push_back ( idStr ); - lobbyips.push_back ( ip ); - } - - numEntries = ( int ) lobbyentries.size(); + LOCK ( entryMutex ); + readQueueState(); } else if ( mode == CONCERTO_BROWSE ) { + LOCK ( entryMutex ); + publiclobbies.clear(); roomcodes.clear(); @@ -322,4 +343,222 @@ void SteamLobby::finishPending() } } + +// ---- king-of-the-hill queue ---- + +void SteamLobby::setReady ( bool ready ) +{ + _ready = ready; + SteamManager::get().setMyMemberData ( READY_KEY, ready ? "1" : "0" ); +} + +QueueState SteamLobby::getQueueState() +{ + // Called by MainUi while it holds entryMutex (like getMenu()); just return the cached snapshot. + return _qs; +} + +void SteamLobby::reportResult ( uint32_t gen, uint64_t winnerId ) +{ + SteamManager& sm = SteamManager::get(); + // Write the winner before the gen, so a reader that sees the new gen always sees a winner. + sm.setMyMemberData ( R_WINNER_KEY, to_string ( winnerId ) ); + sm.setMyMemberData ( R_GEN_KEY, to_string ( gen ) ); +} + +uint64_t SteamLobby::readResult ( uint32_t gen, uint64_t host, uint64_t client ) +{ + SteamManager& sm = SteamManager::get(); + const uint64_t players[2] = { host, client }; + for ( uint64_t pid : players ) + { + if ( ! pid ) + continue; + const uint32_t rgen = ( uint32_t ) strtoul ( sm.getMemberData ( pid, R_GEN_KEY ).c_str(), nullptr, 10 ); + if ( rgen == gen ) + { + const uint64_t w = strtoull ( sm.getMemberData ( pid, R_WINNER_KEY ).c_str(), nullptr, 10 ); + if ( w ) + return w; + } + } + return 0; +} + +void SteamLobby::publishMatchOrAssemble() +{ + SteamManager& sm = SteamManager::get(); + + if ( _queueOrder.size() >= 2 ) + { + ++_gen; + sm.setLobbyData ( Q_ORDER_KEY, joinIds ( _queueOrder ) ); + sm.setLobbyData ( Q_HOST_KEY, to_string ( _queueOrder[0] ) ); + sm.setLobbyData ( Q_CLIENT_KEY, to_string ( _queueOrder[1] ) ); + sm.setLobbyData ( Q_GEN_KEY, to_string ( _gen ) ); + sm.setLobbyData ( Q_STATE_KEY, "PLAYING" ); + LOG ( "Queue gen=%u host=%llu client=%llu", _gen, + ( unsigned long long ) _queueOrder[0], ( unsigned long long ) _queueOrder[1] ); + } + else + { + // Only (re)write when something changed, to avoid SetLobbyData rate-limit spam. + const string order = joinIds ( _queueOrder ); + if ( sm.getLobbyData ( Q_STATE_KEY ) != "ASSEMBLING" ) + sm.setLobbyData ( Q_STATE_KEY, "ASSEMBLING" ); + if ( sm.getLobbyData ( Q_ORDER_KEY ) != order ) + sm.setLobbyData ( Q_ORDER_KEY, order ); + } +} + +void SteamLobby::coordinatorTick() +{ + if ( mode != CONCERTO_LOBBY ) + return; + + SteamManager& sm = SteamManager::get(); + const uint64_t myId = sm.getSteamID(); + + // Only the lobby owner coordinates. SetLobbyData from anyone else is ignored by Steam anyway, + // and ownership migrates automatically if the owner leaves, so a successor seamlessly resumes. + if ( sm.lobbyOwnerId() != myId ) + return; + + // Snapshot members once; derive the ready set and a membership test from it. + const vector members = sm.lobbyMembers(); + vector ready; + for ( const SteamManager::MemberInfo& m : members ) + if ( sm.getMemberData ( m.steamId, READY_KEY ) == "1" ) + ready.push_back ( m.steamId ); + + auto isMember = [&] ( uint64_t id ) -> bool + { + for ( const SteamManager::MemberInfo& m : members ) + if ( m.steamId == id ) + return true; + return false; + }; + + // Adopt the last-published order on first run / after an ownership migration. + if ( _queueOrder.empty() ) + { + _queueOrder = splitIds ( sm.getLobbyData ( Q_ORDER_KEY ) ); + _gen = ( uint32_t ) strtoul ( sm.getLobbyData ( Q_GEN_KEY ).c_str(), nullptr, 10 ); + } + + if ( sm.getLobbyData ( Q_STATE_KEY ) == "PLAYING" ) + { + const uint32_t gen = ( uint32_t ) strtoul ( sm.getLobbyData ( Q_GEN_KEY ).c_str(), nullptr, 10 ); + const uint64_t host = strtoull ( sm.getLobbyData ( Q_HOST_KEY ).c_str(), nullptr, 10 ); + const uint64_t client = strtoull ( sm.getLobbyData ( Q_CLIENT_KEY ).c_str(), nullptr, 10 ); + + const uint64_t winner = readResult ( gen, host, client ); + if ( winner ) + { + // Normal case: a player reported the outcome. + _queueOrder = LobbyQueue::advance ( _queueOrder, host, client, winner, ready ); + publishMatchOrAssemble(); + } + else if ( ! isMember ( host ) && ! isMember ( client ) ) + { + // Both competitors have left the lobby (a live match keeps them as members, so this + // is a hard crash, not just a slow result). Drop the pair to the very back. + LOG ( "Queue gen=%u: both players left without a result; rotating", gen ); + const vector reconciled = LobbyQueue::reconcile ( _queueOrder, ready ); + vector head, tail; + for ( uint64_t id : reconciled ) + ( ( id == host || id == client ) ? tail : head ).push_back ( id ); + head.insert ( head.end(), tail.begin(), tail.end() ); + _queueOrder = head; + publishMatchOrAssemble(); + } + // Otherwise the match is still in progress (or one player crashed and the survivor's + // self-report will arrive next tick) -> wait. + } + else + { + // Assembling / between matches: keep the order reconciled and start once 2+ are ready. + _queueOrder = LobbyQueue::reconcile ( _queueOrder, ready ); + publishMatchOrAssemble(); + } + + // Reflect whatever we just published in our own snapshot this same tick, so an owner who is + // also a player starts its match without a one-tick lag. + LOCK ( entryMutex ); + readQueueState(); +} + +void SteamLobby::readQueueState() +{ + // Assumes entryMutex is held by the caller (refresh() / coordinatorTick()). + SteamManager& sm = SteamManager::get(); + const uint64_t myId = sm.getSteamID(); + const vector members = sm.lobbyMembers(); + + QueueState qs; + qs.gen = ( uint32_t ) strtoul ( sm.getLobbyData ( Q_GEN_KEY ).c_str(), nullptr, 10 ); + qs.phase = ( sm.getLobbyData ( Q_STATE_KEY ) == "PLAYING" ) ? QueuePhase::Playing + : QueuePhase::Assembling; + qs.hostId = strtoull ( sm.getLobbyData ( Q_HOST_KEY ).c_str(), nullptr, 10 ); + qs.clientId = strtoull ( sm.getLobbyData ( Q_CLIENT_KEY ).c_str(), nullptr, 10 ); + qs.iAmReady = _ready; + if ( qs.hostId ) + qs.hostAddr = IpAddrPort ( steamAddr ( qs.hostId ), ( uint16_t ) 0 ); + + // My role in the current match. Un-readied players stay in the menu (None); readied players who + // are not one of the two competitors auto-spectate. + if ( qs.phase == QueuePhase::Playing && qs.gen ) + { + if ( qs.hostId == myId ) + qs.myRole = QueueRole::Host; + else if ( qs.clientId == myId ) + qs.myRole = QueueRole::Client; + else if ( _ready ) + qs.myRole = QueueRole::Spectator; + else + qs.myRole = QueueRole::None; + } + + auto nameOf = [&] ( uint64_t id ) -> string + { + for ( const SteamManager::MemberInfo& m : members ) + if ( m.steamId == id ) + return m.name.empty() ? to_string ( id ) : m.name; + return to_string ( id ); + }; + + // Row 0 is the ready toggle (MainUi maps selection 0 -> setReady). The rest are informational. + qs.rows.clear(); + qs.rows.push_back ( _ready ? "[*] Ready - select here to leave the queue" + : "[ ] Ready Up to join the queue" ); + + const vector order = splitIds ( sm.getLobbyData ( Q_ORDER_KEY ) ); + int pos = 1; + for ( uint64_t id : order ) + { + string tag; + if ( qs.phase == QueuePhase::Playing && id == qs.hostId ) + tag = " [KING - playing]"; + else if ( qs.phase == QueuePhase::Playing && id == qs.clientId ) + tag = " [challenger - playing]"; + const string me = ( id == myId ) ? " (you)" : ""; + qs.rows.push_back ( to_string ( pos++ ) + ". " + nameOf ( id ) + me + tag ); + } + + // Members present but not in the play queue (un-readied watchers). + for ( const SteamManager::MemberInfo& m : members ) + { + if ( LobbyQueue::contains ( order, m.steamId ) ) + continue; + const string me = ( m.steamId == myId ) ? " (you)" : ""; + qs.rows.push_back ( "- " + ( m.name.empty() ? to_string ( m.steamId ) : m.name ) + me + " (watching)" ); + } + + if ( qs.phase != QueuePhase::Playing && order.size() < 2 ) + qs.rows.push_back ( "Waiting for players to ready up..." ); + + _qs = qs; + numEntries = ( int ) qs.rows.size(); +} + #endif // ENABLE_STEAM diff --git a/lib/SteamLobby.hpp b/lib/SteamLobby.hpp index 40948f35..a3973697 100644 --- a/lib/SteamLobby.hpp +++ b/lib/SteamLobby.hpp @@ -2,6 +2,7 @@ #include "ILobbyBackend.hpp" +#include #include #include @@ -48,12 +49,35 @@ class SteamLobby : public ILobbyBackend void refresh() override; void pumpUntilSettled() override; + // king-of-the-hill queue + bool supportsQueue() const override { return true; } + void setReady ( bool ready ) override; + QueueState getQueueState() override; + void reportResult ( uint32_t gen, uint64_t winnerId ) override; + void coordinatorTick() override; + private: // Which async Steam op is in flight, so pumpUntilSettled() knows what to wait on + apply. enum Pending { P_NONE, P_CREATE, P_JOIN, P_BROWSE }; Pending _pending = P_NONE; + // ---- queue state ---- + bool _ready = false; // our own ready flag (source of truth we advertise) + QueueState _qs; // cached snapshot, rebuilt in refresh() under entryMutex + + // Owner-only authoritative queue (front..back). Seeded from the published order on first tick + // / after ownership migration, then maintained in memory. + std::vector _queueOrder; + uint32_t _gen = 0; // last gen we (as owner) published + + // Rebuild _qs from the lobby + member data (called at the end of refresh() in CONCERTO_LOBBY). + void readQueueState(); + + // Owner helpers. + uint64_t readResult ( uint32_t gen, uint64_t host, uint64_t client ); + void publishMatchOrAssemble(); + // Per-mode menu vectors (rebuilt in refresh(); read by getMenu/getIps/getIds while MainUi // holds entryMutex, so those readers must not re-lock). std::vector publiclobbies; // CONCERTO_BROWSE diff --git a/lib/SteamManager.cpp b/lib/SteamManager.cpp index a6f09b28..819433cc 100644 --- a/lib/SteamManager.cpp +++ b/lib/SteamManager.cpp @@ -465,6 +465,37 @@ void SteamManager::setLobbyMemberName ( const std::string& name ) SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_NAME_KEY, name.c_str() ); } +void SteamManager::setLobbyData ( const std::string& key, const std::string& value ) +{ + // No-op for non-owners: Steam ignores SetLobbyData unless we own the lobby. + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), _lobbyId, key.c_str(), value.c_str() ); +} + +std::string SteamManager::getLobbyData ( const std::string& key ) +{ + if ( ! _inited || ! _lobbyId ) + return ""; + + const char *v = SteamAPI_ISteamMatchmaking_GetLobbyData ( SteamMatchmaking(), _lobbyId, key.c_str() ); + return v ? v : ""; +} + +void SteamManager::setMyMemberData ( const std::string& key, const std::string& value ) +{ + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, key.c_str(), value.c_str() ); +} + +std::string SteamManager::getMemberData ( uint64_t memberId, const std::string& key ) +{ + if ( ! _inited || ! _lobbyId ) + return ""; + + const char *v = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, memberId, key.c_str() ); + return v ? v : ""; +} + void SteamManager::setChallenge ( uint64_t targetId ) { if ( ! _inited || ! _lobbyId ) diff --git a/lib/SteamManager.hpp b/lib/SteamManager.hpp index edd5a819..80a0aa61 100644 --- a/lib/SteamManager.hpp +++ b/lib/SteamManager.hpp @@ -104,6 +104,14 @@ class SteamManager : private Timer::Owner // Advertise our display name to the lobby (so peers can list us by name). void setLobbyMemberName ( const std::string& name ); + // ---- generic lobby / member data (used by the king-of-the-hill queue) ---- + // Lobby data is single-writer: Steam silently ignores SetLobbyData from non-owners, so the + // lobby owner is the queue's authoritative coordinator. Member data is self-writable only. + void setLobbyData ( const std::string& key, const std::string& value ); + std::string getLobbyData ( const std::string& key ); + void setMyMemberData ( const std::string& key, const std::string& value ); + std::string getMemberData ( uint64_t memberId, const std::string& key ); + // ---- challenge handshake (member-data based, no relay) ---- // Challenger advertises {challenging:, host_id:}; the target sees it on its next // member refresh and connects as Client to our SteamID. clearChallenge() withdraws it. diff --git a/netplay/Messages.hpp b/netplay/Messages.hpp index 34001e6a..0949ff7e 100644 --- a/netplay/Messages.hpp +++ b/netplay/Messages.hpp @@ -289,6 +289,28 @@ struct ConfirmConfig : public SerializableSequence }; +// Sent over IPC from the DLL to the standalone when a match ends (entering RetryMenu). The +// standalone side otherwise only learns the result via results.csv; the king-of-the-hill lobby +// queue needs it directly. hostWon is derived from the synced win counts + hostPlayer, so it is +// computed identically on both the host and the client. +struct MatchResult : public SerializableSequence +{ + uint8_t hostWon = 0; + uint8_t p1Wins = 0; + uint8_t p2Wins = 0; + + MatchResult ( uint8_t hostWon, uint8_t p1Wins, uint8_t p2Wins ) + : hostWon ( hostWon ), p1Wins ( p1Wins ), p2Wins ( p2Wins ) {} + + std::string str() const override + { + return format ( "MatchResult[hostWon=%u;%u-%u]", hostWon, p1Wins, p2Wins ); + } + + PROTOCOL_MESSAGE_BOILERPLATE ( MatchResult, hostWon, p1Wins, p2Wins ) +}; + + struct RngState : public SerializableSequence { uint32_t index = 0; diff --git a/targets/DllMain.cpp b/targets/DllMain.cpp index efa29459..6d797cca 100644 --- a/targets/DllMain.cpp +++ b/targets/DllMain.cpp @@ -1092,6 +1092,18 @@ struct DllMain // Reset retry menu index flag localRetryMenuIndexSent = false; + + // Report the match outcome to the standalone (for the lobby queue). hostWon is derived + // from the synced win counts + hostPlayer, so it is identical on host and client. + if ( clientMode.isNetplay() + && ( netMan.config.hostPlayer == 1 || netMan.config.hostPlayer == 2 ) ) + { + const uint8_t p1w = ( uint8_t ) *CC_P1_WINS_ADDR; + const uint8_t p2w = ( uint8_t ) *CC_P2_WINS_ADDR; + const uint8_t winnerPlayer = ( p1w >= p2w ) ? 1 : 2; + const uint8_t hostWon = ( winnerPlayer == netMan.config.hostPlayer ) ? 1 : 0; + procMan.ipcSend ( new MatchResult ( hostWon, p1w, p2w ) ); + } } else if ( lazyDisconnect ) { diff --git a/targets/MainApp.cpp b/targets/MainApp.cpp index 6bd57562..14e3f87d 100644 --- a/targets/MainApp.cpp +++ b/targets/MainApp.cpp @@ -1264,6 +1264,13 @@ struct MainApp updateStatusMessage(); return; + case MsgType::MatchResult: + ui.lastMatchResult.valid = true; + ui.lastMatchResult.hostWon = msg->getAs().hostWon; + ui.lastMatchResult.p1Wins = msg->getAs().p1Wins; + ui.lastMatchResult.p2Wins = msg->getAs().p2Wins; + return; + case MsgType::IpAddrPort: if ( ctrlSocket && ctrlSocket->isConnected() ) ctrlSocket->send ( msg ); diff --git a/targets/MainUi.cpp b/targets/MainUi.cpp index 5d0011fb..75436a7b 100644 --- a/targets/MainUi.cpp +++ b/targets/MainUi.cpp @@ -235,6 +235,8 @@ void MainUi::lobby( RunFuncPtr run ) string lobbyBase = "Lobby"; string lobbyDisplay = lobbyBase; string name = _config.getString ( "displayName" ); + uint32_t myLastGen = 0; // last king-of-the-hill match generation we acted on (Steam queue) + QueueState qs; // current queue snapshot (Steam queue) for ( ;; ) { if ( ! _lobby->isRunning() || ( _lobby->needsEventManager() && ! EventManager::get().isRunning() ) ) { @@ -244,6 +246,8 @@ void MainUi::lobby( RunFuncPtr run ) } // Steam backend has no background thread; pull fresh lobby/member state on this thread. _lobby->refresh(); + // If we own a king-of-the-hill lobby, advance the queue + publish the next assignment. + _lobby->coordinatorTick(); // Update ui LOG("Getting lobby mutex"); _lobby->entryMutex.lock(); @@ -251,8 +255,68 @@ void MainUi::lobby( RunFuncPtr run ) numEntries =_lobby->numEntries; lobbyIps =_lobby->getIps(); lobbyIds =_lobby->getIds(); + qs =_lobby->getQueueState(); _lobby->entryMutex.unlock(); LOG("releasing lobby mutex"); + +#ifdef ENABLE_STEAM + // King-of-the-hill auto-start: when the queue assigns us a role for a new match, launch it + // directly (no player selection). Un-readied players have role None and stay in the menu. + if ( _lobby->supportsQueue() && _lobby->mode == CONCERTO_LOBBY + && qs.phase == QueuePhase::Playing && qs.gen > myLastGen && qs.myRole != QueueRole::None ) + { + myLastGen = qs.gen; + lastMatchResult.valid = false; + initialConfig.mode.flags |= ClientMode::IsSteam; + + if ( qs.myRole == QueueRole::Host ) + { + initialConfig.mode.value = ClientMode::Host; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + } + else if ( qs.myRole == QueueRole::Client ) + { + initialConfig.mode.value = ClientMode::Client; + _netplayConfig.clear(); + _address = qs.hostAddr; + } + else // Spectator + { + initialConfig.mode.value = ClientMode::SpectateNetplay; + _address = qs.hostAddr; + } + + addressSelected = true; + LOG ( "Queue gen=%u role=%d prerun", qs.gen, ( int ) qs.myRole ); + RUN ( _address, initialConfig ); + LOG ( "Queue gen=%u postrun", qs.gen ); + _ui->popNonUserInput(); + + // Players report the outcome so the owner can advance the queue. Always report a + // winner so the queue can never stall: a clean finish gives the real winner (both + // sides agree); a disconnect makes the surviving reporter the winner; bailing out + // mid-match with no result is a forfeit (the opponent wins). + if ( qs.myRole == QueueRole::Host || qs.myRole == QueueRole::Client ) + { + const uint64_t myId = SteamManager::get().getSteamID(); + const uint64_t oppId = ( qs.myRole == QueueRole::Host ) ? qs.clientId : qs.hostId; + uint64_t winner; + if ( lastMatchResult.valid ) + winner = lastMatchResult.hostWon ? qs.hostId : qs.clientId; + else if ( ! sessionError.empty() ) + winner = myId; // opponent dropped; I survived + else + winner = oppId; // I left mid-match without a result -> forfeit + if ( winner ) + _lobby->reportResult ( qs.gen, winner ); + } + + // A spectate session ending (the host tore down) surfaces a benign disconnect. + sessionError.clear(); + continue; + } +#endif + int oldPos = _ui->top()->getPosition(); _ui->pop(); _ui->pushInFront ( new ConsoleUi::Menu ( lobbyDisplay, @@ -332,7 +396,14 @@ void MainUi::lobby( RunFuncPtr run ) lobbyDisplay = " | Room | Players | Error: " + _lobby->lobbyError; } } else if ( _lobby->mode == CONCERTO_LOBBY ) { - if ( mode < 0 || mode >= numEntries ) { + if ( _lobby->supportsQueue() ) { + // King-of-the-hill queue UI: row 0 toggles our ready flag; Exit (negative) leaves. + // Matches auto-start above; informational rows do nothing. + if ( mode == 0 ) + _lobby->setReady ( ! qs.iAmReady ); + else if ( mode < 0 ) + break; + } else if ( mode < 0 || mode >= numEntries ) { break; } else { if ( lobbyIps[ mode ] == "None" ) { diff --git a/targets/MainUi.hpp b/targets/MainUi.hpp index 51d5f6df..86f881f3 100644 --- a/targets/MainUi.hpp +++ b/targets/MainUi.hpp @@ -24,6 +24,18 @@ inline int computeDelay ( double latency ) } +// Outcome of the most recent match, populated from the DLL's MatchResult IPC message (see +// MainApp::ipcRead). The lobby queue reads this after a match's RUN() returns to advance the +// king-of-the-hill order. `valid` is cleared before each match and set when a result arrives. +struct MatchResultInfo +{ + bool valid = false; + bool hostWon = false; + uint8_t p1Wins = 0; + uint8_t p2Wins = 0; +}; + + class ConsoleUi; class MainUi @@ -45,6 +57,10 @@ class MainUi std::vector lobbyIps; std::vector lobbyIds; + // Most recent match outcome, written by MainApp from the DLL's MatchResult IPC message and + // read by the lobby queue after a match (mirrors how sessionError is plumbed from MainApp). + MatchResultInfo lastMatchResult; + MainUi(); void initialize(); diff --git a/tests/Test.LobbyQueue.cpp b/tests/Test.LobbyQueue.cpp new file mode 100644 index 00000000..7f8ee91d --- /dev/null +++ b/tests/Test.LobbyQueue.cpp @@ -0,0 +1,100 @@ +#ifndef RELEASE + +#include "LobbyQueue.hpp" + +#include + +#include + +using namespace std; + + +// Convenience: all of {1,2,3,...} readied, in the given order. +static vector ids ( initializer_list l ) { return vector ( l ); } + + +TEST ( LobbyQueue, WinnerStaysLoserToBack ) +{ + // 1 (king) beats 2; 1 stays at the front, 2 drops behind 3. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 2, 3 } ); + + const vector next = LobbyQueue::advance ( order, /*host*/ 1, /*client*/ 2, /*winner*/ 1, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3, 2 } ) ); +} + + +TEST ( LobbyQueue, ChallengerDethronesKing ) +{ + // 2 beats the king 1; 2 becomes the new king, 1 drops to the back behind 3. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 2, 3 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, /*winner*/ 2, ready ); + + EXPECT_EQ ( next, ids ( { 2, 3, 1 } ) ); +} + + +TEST ( LobbyQueue, LoserUnreadiedIsDropped ) +{ + // 2 loses and un-readies before the next round; it disappears from the queue entirely. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 3 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 1, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3 } ) ); +} + + +TEST ( LobbyQueue, NewJoinerAppendedAheadOfLoser ) +{ + // 4 readied up during the match; it should sit ahead of the just-demoted loser (2). + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 2, 3, 4 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 1, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3, 4, 2 } ) ); +} + + +TEST ( LobbyQueue, WinnerUnreadiedYieldsToField ) +{ + // The king 1 wins but un-readied; it is not re-seated and 3 leads the next field. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 2, 3 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 1, ready ); + + EXPECT_EQ ( next, ids ( { 3, 2 } ) ); +} + + +TEST ( LobbyQueue, ReconcileDropsAndAppends ) +{ + // 2 left; 4 and 5 readied. Survivors keep order, newcomers append in ready order. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 3, 1, 4, 5 } ); + + const vector next = LobbyQueue::reconcile ( order, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3, 4, 5 } ) ); +} + + +TEST ( LobbyQueue, TwoPlayersJustReplay ) +{ + // With exactly two ready players, the loser-to-back is the same pair again next round. + const vector order = ids ( { 1, 2 } ); + const vector ready = ids ( { 1, 2 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 2, ready ); + + EXPECT_EQ ( next, ids ( { 2, 1 } ) ); +} + + +#endif // NOT RELEASE From 9acd33a8e2bc33815e7ce03ec5af98b536f1f762 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 16:50:00 -0300 Subject: [PATCH 04/25] Add retry mechanism --- lib/SteamManager.cpp | 21 +++++++++++++++++++++ lib/SteamManager.hpp | 6 ++++++ lib/SteamSocket.cpp | 26 ++++++++++++++++++++++++++ lib/SteamSocket.hpp | 4 ++++ targets/DllMain.cpp | 6 ++++++ targets/MainApp.cpp | 7 +++++++ 6 files changed, 70 insertions(+) diff --git a/lib/SteamManager.cpp b/lib/SteamManager.cpp index 819433cc..c3d0fb34 100644 --- a/lib/SteamManager.cpp +++ b/lib/SteamManager.cpp @@ -12,6 +12,8 @@ // See the steamworks-mingw-flat-api note. #include "steam/steam_api_flat.h" +#include // Sleep + #include #include #include @@ -23,6 +25,11 @@ using namespace std; // How often to pump dispatch + drain messages #define PUMP_INTERVAL_MS ( 2 ) +// Bounded wait for the SDR relay network in waitRelayNetworkReady(): ~5s total (500 * 10ms), +// matching SteamMatchmaking's gate. +#define RELAY_READY_TRIES ( 500 ) +#define RELAY_READY_SLEEP_MS ( 10 ) + // Max messages drained per connection per pump (leftovers come next pump). #define RECV_BATCH ( 32 ) @@ -158,6 +165,20 @@ bool SteamManager::isRelayNetworkReady() const == k_ESteamNetworkingAvailability_Current ); } +bool SteamManager::waitRelayNetworkReady() +{ + // Mirror the matchmaking gate (SteamMatchmaking::waitConnected): ~5s of manual pumping, then + // proceed regardless. After a fresh ref() the relay config is often cached and this returns + // almost immediately; only a cold fetch takes a moment. + for ( int i = 0; i < RELAY_READY_TRIES && ! isRelayNetworkReady(); ++i ) + { + pump(); + Sleep ( RELAY_READY_SLEEP_MS ); + } + + return isRelayNetworkReady(); +} + // ---- transport wrappers ---- diff --git a/lib/SteamManager.hpp b/lib/SteamManager.hpp index 80a0aa61..6a2403c0 100644 --- a/lib/SteamManager.hpp +++ b/lib/SteamManager.hpp @@ -45,6 +45,12 @@ class SteamManager : private Timer::Owner // ready tends to fail, so callers should gate on it. bool isRelayNetworkReady() const; + // Pump until the SDR relay network is ready (so a subsequent ConnectP2P / listen succeeds), + // or until a bounded timeout (~5s) elapses. Cheap no-op when already ready. Returns the final + // readiness. Safe to call directly in a wait loop (no EventManager needed). Do NOT call from + // inside a Steam callback (e.g. SteamSocket::onClosed) - it pumps, which would be re-entrant. + bool waitRelayNetworkReady(); + // ---- transport wrappers (all handles are plain ints; 0 == invalid) ---- // Host: open a P2P listen socket on a virtual port. Returns the listen handle. diff --git a/lib/SteamSocket.cpp b/lib/SteamSocket.cpp index e6ad5811..e82729d7 100644 --- a/lib/SteamSocket.cpp +++ b/lib/SteamSocket.cpp @@ -15,6 +15,9 @@ using namespace std; SOCKET, SOCKET->_conn, SOCKET->_listenHandle, \ ( unsigned long long ) SOCKET->_peerSteamId, SOCKET->_state, ## __VA_ARGS__ ) +// How many times a client ConnectP2P that closed before connecting is re-attempted (see onClosed). +#define STEAM_CONNECT_RETRIES ( 5 ) + // Encode a SteamID into the synthetic address string used everywhere CCCaster expects an // IpAddrPort. Keeping addr non-empty also makes the base Socket's isClient()/isServer() @@ -262,6 +265,29 @@ void SteamSocket::onClosed() { LOG_STEAM_SOCKET ( this, "socketDisconnected" ); + // A client connection that dropped before ever reaching Connected may have just raced the SDR + // relay coming up (or a one-off NAT-punch failure). Re-attempt a bounded number of times + // before surfacing the disconnect. Server/child sockets, and sockets that DID connect (a real + // mid-match drop, state == Connected), fall through and disconnect as before. + if ( _peerSteamId && _parentSocket == 0 && _state == State::Connecting && _conn + && _connectRetries < STEAM_CONNECT_RETRIES ) + { + ++_connectRetries; + + SteamManager& sm = SteamManager::get(); + sm.unregisterConnection ( _conn ); + sm.closeConnection ( _conn ); + _conn = sm.connectP2P ( _peerSteamId, _virtualPort ); + + if ( _conn ) + { + sm.registerConnection ( _conn, this ); + LOG_STEAM_SOCKET ( this, "retrying connectP2P (%d)", _connectRetries ); + return; + } + // connectP2P failed outright; fall through to a normal disconnect. + } + Socket::Owner *const ownerCopy = this->owner; disconnect(); diff --git a/lib/SteamSocket.hpp b/lib/SteamSocket.hpp index 45199d1c..489185d9 100644 --- a/lib/SteamSocket.hpp +++ b/lib/SteamSocket.hpp @@ -92,6 +92,10 @@ class SteamSocket : public Socket // Remote peer SteamID64 (client/child only). uint64_t _peerSteamId = 0; + // Bounded re-attempts of a client ConnectP2P that closed before ever reaching Connected (e.g. + // the SDR relay momentarily flaked). Pairs with SteamManager::waitRelayNetworkReady(). + int _connectRetries = 0; + // Server: the just-accepted child waiting to be accept()'d out, and live children. SocketPtr _acceptedSocket; std::unordered_map _childSockets; diff --git a/targets/DllMain.cpp b/targets/DllMain.cpp index 6d797cca..fef87703 100644 --- a/targets/DllMain.cpp +++ b/targets/DllMain.cpp @@ -1842,8 +1842,14 @@ struct DllMain #ifdef ENABLE_STEAM // Launcher shut down its Steam instance before spawning the game; bring // Steam up in-process so we can re-establish the P2P connection by SteamID. + // This ref() is a cold init, so wait (bounded) for the SDR relay network to + // come up before listen/connect below - connecting too early tends to fail. + // Runs on the DLL's network thread, so this does not stall the game's render. if ( clientMode.isSteam() ) + { SteamManager::get().ref(); + SteamManager::get().waitRelayNetworkReady(); + } #endif if ( clientMode.isHost() ) diff --git a/targets/MainApp.cpp b/targets/MainApp.cpp index 14e3f87d..9b68b42d 100644 --- a/targets/MainApp.cpp +++ b/targets/MainApp.cpp @@ -252,6 +252,13 @@ struct MainApp _.doDeinit = !EventManager::get().isRunning(); +#ifdef ENABLE_STEAM + // The SDR relay network must be ready before ConnectP2P / listen, or the connection tends + // to fail. It is usually already warm from the lobby; wait (bounded) just in case. + if ( clientMode.isSteam() ) + SteamManager::get().waitRelayNetworkReady(); +#endif + if ( clientMode.isHost() ) { if ( !ui.isServer() ) { From b684cb452bc879ac5004658c64a5fec91b416e65 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 16:50:06 -0300 Subject: [PATCH 05/25] Add .env.example --- .env.example | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..3129efa5 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env (gitignored) and fill in. Used by scripts/build-steam.sh. +# Real environment variables and CLI args override these. + +# --- local Windows deploy target --- +GAME_DIR=C:/Games/MBAACC + +# logging (default, recommended for testing) | debug | release +BUILD_TYPE=logging + +# --- remote macOS / CrossOver deploy target (set all three to enable) --- +# REMOTE_GAME_DIR must be absolute (no leading ~); spaces are fine. +#REMOTE_USER=sky +#REMOTE_HOST=macbook.local +#REMOTE_GAME_DIR=/Users/sky/Library/Application Support/CrossOver/Bottles/MBAACC/drive_c/Games/MBAACC + +# --- build inputs (defaults usually fine) --- +#STEAM_SDK=../SteamworksSDK/public +#MINGW32_BIN=/c/msys64/mingw32/bin From 0c882c3e33c2050f07b1dee771ebda6f2685cdfc Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 18:34:16 -0300 Subject: [PATCH 06/25] Add CI building --- .github/workflows/main.yml | 154 ++++++++++++++++++++++++++++++------- .gitmodules | 3 + 3rdparty/SteamworksSDK | 1 + 3 files changed, 131 insertions(+), 27 deletions(-) create mode 160000 3rdparty/SteamworksSDK diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ae597fdf..c1cb9785 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,41 +1,141 @@ -# This is a basic workflow to help you get started with Actions name: CI -# Controls when the workflow will run +# Build on every push to master, every PR, and every tag. Tags additionally +# cut a draft GitHub release with the built artifact + an auto-generated changelog. on: - # Triggers the workflow on push or pull request events but only for the master branch push: branches: [ master ] + tags: [ 'v*' ] pull_request: branches: [ master ] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel +# Default to read-only; the release job opts into write below. +permissions: + contents: read + jobs: - # This workflow contains a single job called "build" + # ------------------------------------------------------------------ build + # Builds the Steam-enabled release zip on a Windows runner using the same + # 32-bit MSYS2/MinGW toolchain the project builds with locally, then uploads + # the zip as a workflow artifact (persisted on every commit). build: - # The type of runner that the job will run on - runs-on: ubuntu-latest + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - name: Checkout (with submodules) + uses: actions/checkout@v4 + with: + # imgui + SteamworksSDK are git submodules; recursive pulls both. + submodules: recursive + + - name: Set up MSYS2 (MINGW32 / i686) + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW32 + update: true + # mingw-w64-i686-toolchain provides i686-w64-mingw32-{gcc,g++}, windres, + # strip, objcopy and the d3dx9 import lib needed to link hook.dll. + install: >- + make + rsync + zip + git + mingw-w64-i686-toolchain - # Steps represent a sequence of tasks that will be executed as part of the job + - name: Build Steam release + run: | + set -euo pipefail + # The Makefile hardcodes the toolchain prefix as ../mingw32/bin/i686-w64-mingw32-* + # (a symlink to the MSYS2 mingw32 tree on the dev's machine). Reproduce that layout. + ln -sfn /mingw32 ../mingw32 + # MSYS2's recipe shell can blank TMP/TEMP, making native gcc/collect2 fall back to a + # non-writable C:\WINDOWS temp. Wrap the recipe shell to force a writable temp — same + # fix as scripts/build-steam.sh. + printf '%s\n' '#!/usr/bin/sh' 'export TMPDIR=/tmp TMP=/tmp TEMP=/tmp' 'shift' 'eval "$@"' > /tmp/recipe-shell.sh + chmod +x /tmp/recipe-shell.sh + # STEAM_SDK enables -DENABLE_STEAM and links the 32-bit steam_api import lib; the + # resulting zip bundles steam_api.dll + steam_appid.txt via the Makefile's STEAM_PKG_* + # hooks. CHMOD_X/GRANT are no-oped to skip per-file icacls calls (not needed in CI). + make release \ + STEAM_SDK=3rdparty/SteamworksSDK/public \ + OS=Windows_NT \ + SHELL=/tmp/recipe-shell.sh \ + CHMOD_X=true GRANT=true + + - name: Stage artifact + run: | + set -euo pipefail + mkdir -p dist + cp cccaster.v*.zip dist/ + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: cccaster-${{ github.sha }} + path: dist/*.zip + if-no-files-found: error + + # ---------------------------------------------------------------- release + # On a tag push (v*), create a DRAFT release with the built zip attached and + # release notes auto-generated from the git log since the previous tag. + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-latest + permissions: + contents: write steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 - - # Runs a single command using the runners shell - - name: Run a one-line script - run: uname - - name: Set up MinGW - uses: egor-tensin/setup-mingw@v2 - - name: Install wine + - name: Checkout (full history + tags for changelog) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Generate changelog from git log + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + prev="$(git describe --tags --abbrev=0 "${tag}^" 2>/dev/null || true)" + { + echo "## ${tag}" + echo + if [ -n "${prev}" ]; then + echo "Changes since ${prev}:" + echo + git log "${prev}..${tag}" --no-merges --pretty='- %s (%h)' + else + echo "Changes:" + echo + git log "${tag}" --no-merges --pretty='- %s (%h)' + fi + } > RELEASE_NOTES.md + echo "----- generated notes -----" + cat RELEASE_NOTES.md + + - name: Create draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - sudo dpkg --add-architecture i386 - wget -qO - https://dl.winehq.org/wine-builds/winehq.key | sudo apt-key add - - sudo add-apt-repository ppa:cybermax-dexter/sdl2-backport - sudo apt-add-repository "deb https://dl.winehq.org/wine-builds/ubuntu $(lsb_release -cs) main" - sudo apt install --install-recommends winehq-stable - # Runs a set of commands using the runners shell - - name: Run a multi-line script - run: make release + set -euo pipefail + tag="${GITHUB_REF_NAME}" + mapfile -t assets < <(find dist -type f -name '*.zip') + if [ "${#assets[@]}" -eq 0 ]; then + echo "No build artifacts (*.zip) found to attach" >&2 + exit 1 + fi + echo "Attaching: ${assets[*]}" + gh release create "${tag}" \ + --draft \ + --title "${tag}" \ + --notes-file RELEASE_NOTES.md \ + "${assets[@]}" diff --git a/.gitmodules b/.gitmodules index f787a778..a5cda9ca 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "3rdparty/imgui"] path = 3rdparty/imgui url = https://github.com/ocornut/imgui +[submodule "3rdparty/SteamworksSDK"] + path = 3rdparty/SteamworksSDK + url = https://github.com/rlabrecque/SteamworksSDK.git diff --git a/3rdparty/SteamworksSDK b/3rdparty/SteamworksSDK new file mode 160000 index 00000000..494c2d68 --- /dev/null +++ b/3rdparty/SteamworksSDK @@ -0,0 +1 @@ +Subproject commit 494c2d680b9e47bbc369496b57568f44ef2f6796 From c9a6742bf0b4da5d4889da6f21ba2989db5e4206 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 20:48:25 -0300 Subject: [PATCH 07/25] Remove GetNumberOfConsoleFonts UB --- lib/ConsoleUi.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/ConsoleUi.cpp b/lib/ConsoleUi.cpp index 55244c16..04387090 100644 --- a/lib/ConsoleUi.cpp +++ b/lib/ConsoleUi.cpp @@ -378,9 +378,18 @@ ConsoleUi::ConsoleUi ( const string& title, bool isWine ) // Get handle HANDLE handle = GetStdHandle ( STD_OUTPUT_HANDLE ); + // These undocumented console-font APIs are absent on modern Windows: GetProcAddress returns + // null (calling through it would crash) or GetNumberOfConsoleFonts returns 0. Skip the font + // tweak in that case rather than indexing an empty vector (which also trips _GLIBCXX_DEBUG). + if ( ! SetConsoleFont || ! GetConsoleFontInfo || ! GetNumberOfConsoleFonts ) + return; + // Get Number of console fonts DWORD numFounts = GetNumberOfConsoleFonts(); + if ( numFounts == 0 ) + return; + // Setup array vector fonts ( numFounts ); From 61b6ef0c22bd5a53dc2109859d572dab3725f731 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 20:57:44 -0300 Subject: [PATCH 08/25] Add Sentry crash reporting --- .env.example | 6 +- Makefile | 28 +- lib/SentryClient.cpp | 884 +++++++++++++++++++++++++++++++++++++++++ lib/SentryClient.hpp | 75 ++++ scripts/symbolicate.sh | 66 +++ targets/DllMain.cpp | 45 +++ targets/Main.cpp | 35 ++ tools/Launcher.cpp | 21 + 8 files changed, 1156 insertions(+), 4 deletions(-) create mode 100644 lib/SentryClient.cpp create mode 100644 lib/SentryClient.hpp create mode 100644 scripts/symbolicate.sh diff --git a/.env.example b/.env.example index 3129efa5..1d25ad3c 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # --- local Windows deploy target --- GAME_DIR=C:/Games/MBAACC -# logging (default, recommended for testing) | debug | release +# logging (default; built with SYMBOLS=1 so it's optimized AND symbolicatable) | debug | release BUILD_TYPE=logging # --- remote macOS / CrossOver deploy target (set all three to enable) --- @@ -16,3 +16,7 @@ BUILD_TYPE=logging # --- build inputs (defaults usually fine) --- #STEAM_SDK=../SteamworksSDK/public #MINGW32_BIN=/c/msys64/mingw32/bin + +# --- crash reporting (optional) --- +# Sentry/Glitchtip ingest DSN, baked into the build. Empty/unset => reporting disabled at runtime. +#SENTRY_DSN=https://@glitchtip.example.com/ diff --git a/Makefile b/Makefile index a77de857..00a3517f 100644 --- a/Makefile +++ b/Makefile @@ -96,7 +96,7 @@ CC_FLAGS = -m32 $(INCLUDES) $(DEFINES) CC_FLAGS += -mmmx -msse -msse2 -msse3 -mssse3 # Linker flags -LD_FLAGS = -m32 -static -lws2_32 -lpsapi -lwinpthread -lwinmm -lole32 -ldinput -lwininet -ldwmapi -lgdi32 +LD_FLAGS = -m32 -static -lws2_32 -lpsapi -lwinpthread -lwinmm -lole32 -ldinput -lwininet -ldwmapi -lgdi32 -ldbghelp # ----- Steamworks (optional Steam connection method) ----- # Disabled unless STEAM_SDK is set to the 'public' dir of the Steamworks SDK, e.g.: @@ -126,6 +126,17 @@ ifneq ($(STEAM_SDK),) STEAM_PKG_FOLDER = cp -f $(STEAM_DLL) $(FOLDER)/ && printf '411370' > $(FOLDER)/steam_appid.txt endif +# ----- Sentry / Glitchtip crash reporting (optional) ----- +# Set SENTRY_DSN to your ingest DSN to enable crash/error reporting, e.g.: +# make SENTRY_DSN='https://@glitchtip.example.com/1' +# Empty (default) => the client is compiled in but disabled at runtime (no-op, no network), so +# builds without a DSN are unaffected. Like the relay IPs, the DSN is baked into the binary; keep +# real DSNs out of git (pass via CLI or source from .env). lib/SentryClient.cpp links into the main +# exe + hook.dll automatically (lib/*.cpp wildcard); the launcher recipe pulls it in explicitly. +SENTRY_DSN ?= +SENTRY_DEFINE = -DSENTRY_DSN='"$(SENTRY_DSN)"' +DEFINES += $(SENTRY_DEFINE) + # Build options # DEFINES += -DDISABLE_LOGGING # DEFINES += -DDISABLE_ASSERTS @@ -144,6 +155,17 @@ else endif RELEASE_FLAGS = -s -Os -Ofast -fno-rtti -DNDEBUG -DRELEASE -DDISABLE_LOGGING -DDISABLE_ASSERTS +# SYMBOLS=1 keeps DWARF debug info in the optimized logging build so Sentry/crash-report addresses +# can be resolved with scripts/symbolicate.sh. It adds -ggdb (line info) and -fno-omit-frame-pointer +# (clean StackWalk64 stacks), drops -s, and skips the strip step — WITHOUT pulling in the debug +# build's -O0 or -D_GLIBCXX_DEBUG (which alter timing and abort on latent UB). Only affects logging; +# scripts/build-steam.sh passes it by default. Release stays stripped. +SYMBOLS ?= 0 +ifeq ($(SYMBOLS),1) + LOGGING_FLAGS := $(filter-out -s,$(LOGGING_FLAGS)) -ggdb -fno-omit-frame-pointer + STRIP = touch +endif + # Build type BUILD_TYPE = build_debug BUILD_PREFIX = $(BUILD_TYPE)_$(BRANCH) @@ -205,8 +227,8 @@ $(FOLDER)/$(DLL): $(addprefix $(BUILD_PREFIX)/,$(DLL_OBJECTS)) res/rollback.o ta $(STEAM_PKG_FOLDER) @echo -$(FOLDER)/$(LAUNCHER): tools/Launcher.cpp | $(FOLDER) - $(CXX) -o $@ $^ -m32 -s -Os -O2 -Wall -static -mwindows +$(FOLDER)/$(LAUNCHER): tools/Launcher.cpp lib/SentryClient.cpp | $(FOLDER) + $(CXX) -o $@ $^ -m32 -s -Os -O2 -Wall -static -mwindows -I$(CURDIR)/lib -I$(CURDIR)/3rdparty/cereal/include $(SENTRY_DEFINE) -lwininet -ldbghelp @echo $(STRIP) $@ $(CHMOD_X) diff --git a/lib/SentryClient.cpp b/lib/SentryClient.cpp new file mode 100644 index 00000000..32f473f4 --- /dev/null +++ b/lib/SentryClient.cpp @@ -0,0 +1,884 @@ +#include "SentryClient.hpp" + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + + +// SDK identity reported to Sentry / Glitchtip. +#define SENTRY_CLIENT_NAME "cccaster.sentry" +#define SENTRY_CLIENT_VERSION "1.0" + +// Max breadcrumbs retained for context, and max captured stack frames per crash. +#define MAX_BREADCRUMBS ( 30 ) +#define MAX_STACK_FRAMES ( 48 ) + +// WinINet timeouts (ms) so a dead endpoint never stalls a crash path for long. +#define HTTP_TIMEOUT_MS ( 5000 ) + + +namespace +{ + +// JSON is built with the rapidjson Writer that cereal already vendors (the same library the netplay +// protocol's JSON archive uses). The Writer handles all string escaping and framing for us, so the +// event payload is always well-formed regardless of what ends up in an exception/log message. +typedef rapidjson::Writer JsonWriter; + + +struct State +{ + bool enabled = false; + + // Parsed DSN. + bool https = true; + string host; + INTERNET_PORT port = 443; + string path; // /api//envelope/ + string dsn; // original DSN, echoed in the envelope header + string authHeader; // value of X-Sentry-Auth + + // Event metadata. + string release; + string environment; + string dist; + + // Guards breadcrumbs, tags and the async queue. + CRITICAL_SECTION cs; + bool csReady = false; + + vector> tags; + vector> breadcrumbs; // (timestamp, message) + + // Async upload worker. + HANDLE workerThread = 0; + HANDLE wakeEvent = 0; + vector queue; // ready-to-POST envelope bodies + + // Diagnostics sink (e.g. routed to the app's logger). + SentryClient::LogSink logSink = 0; + + // DbgHelp symbol handler initialized (for StackWalk64 + symbol names). + bool symReady = false; + + // Chained crash handlers + dedupe/reentrancy guards. + PVOID vehHandle = 0; + LPTOP_LEVEL_EXCEPTION_FILTER prevFilter = 0; + terminate_handler prevTerminate = 0; + void ( *prevSigabrt ) ( int ) = 0; + volatile LONG vehReported = 0; // VEH reports a fatal first-chance fault at most once + volatile LONG inFilter = 0; // reentrancy guard for the unhandled filter +}; + +State g; + + +// Emit an internal diagnostic line. Routed to the registered sink (the app logger) and always to +// OutputDebugString, so a crash reporter that goes quiet can still be debugged from the logs. +void diag ( const string& msg ) +{ + if ( g.logSink ) + g.logSink ( msg.c_str() ); + + OutputDebugStringA ( ( "[sentry] " + msg + "\n" ).c_str() ); +} + + +// ----- small helpers ------------------------------------------------------- + +string isoTimestamp() +{ + time_t t = time ( 0 ); + char buf[32] = { 0 }; + strftime ( buf, sizeof ( buf ), "%Y-%m-%dT%H:%M:%SZ", gmtime ( &t ) ); + return buf; +} + +string randomEventId() +{ + static const char *hex = "0123456789abcdef"; + char id[33]; + for ( int i = 0; i < 32; ++i ) + id[i] = hex[rand() % 16]; + id[32] = 0; + return id; +} + +string hexPtr ( const void *p ) +{ + char buf[24]; + snprintf ( buf, sizeof ( buf ), "0x%08x", ( unsigned ) ( uintptr_t ) p ); + return buf; +} + +string hexCode ( unsigned code ) +{ + char buf[16]; + snprintf ( buf, sizeof ( buf ), "0x%08x", code ); + return buf; +} + +const char *levelStr ( SentryClient::Level level ) +{ + switch ( level ) + { + case SentryClient::Level::Debug: + return "debug"; + case SentryClient::Level::Info: + return "info"; + case SentryClient::Level::Warning: + return "warning"; + case SentryClient::Level::Error: + return "error"; + case SentryClient::Level::Fatal: + return "fatal"; + } + return "error"; +} + +// Crash-class exception codes worth reporting from the first-chance vectored handler. Excludes C++ +// exceptions (0xE06D7363) and debugger/control codes so normal handled exceptions aren't reported. +bool isFatalCode ( DWORD code ) +{ + switch ( code ) + { + case EXCEPTION_ACCESS_VIOLATION: + case EXCEPTION_STACK_OVERFLOW: + case EXCEPTION_ILLEGAL_INSTRUCTION: + case EXCEPTION_PRIV_INSTRUCTION: + case EXCEPTION_INT_DIVIDE_BY_ZERO: + case EXCEPTION_IN_PAGE_ERROR: + case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: + return true; + default: + return false; + } +} + +// Write a "key": "value" string member onto the current JSON object. +void writeMember ( JsonWriter& w, const char *key, const string& value ) +{ + w.String ( key ); + w.String ( value.c_str(), ( rapidjson::SizeType ) value.size() ); +} + +string baseName ( const string& path ) +{ + const size_t i = path.find_last_of ( "\\/" ); + return ( i == string::npos ) ? path : path.substr ( i + 1 ); +} + +// Resolve the module owning an address; returns false if unknown. +bool moduleForAddr ( const void *addr, HMODULE& mod, string& path ) +{ + mod = 0; + if ( ! GetModuleHandleExA ( GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS + | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + ( LPCSTR ) addr, &mod ) ) + return false; + + char name[MAX_PATH] = { 0 }; + if ( GetModuleFileNameA ( mod, name, sizeof ( name ) ) ) + path = name; + return true; +} + +// True if the address belongs to a module under the Windows directory (a system DLL). Used to +// ignore benign first-chance exceptions that Windows DLLs (dxdiag/setupapi/...) raise as control +// flow, so the vectored handler only reports faults in the game / our own code. +bool addrInSystemModule ( const void *addr ) +{ + HMODULE mod = 0; + string path; + if ( ! moduleForAddr ( addr, mod, path ) || path.empty() ) + return false; + + char winDir[MAX_PATH] = { 0 }; + const UINT n = GetWindowsDirectoryA ( winDir, sizeof ( winDir ) ); + if ( n == 0 || n >= sizeof ( winDir ) ) + return false; + + return _strnicmp ( path.c_str(), winDir, n ) == 0; +} + + +// ----- HTTP ---------------------------------------------------------------- + +// Synchronous fire-and-forget POST of an already-framed envelope body. +void httpPost ( const string& body ) +{ + HINTERNET hInet = InternetOpenA ( SENTRY_CLIENT_NAME "/" SENTRY_CLIENT_VERSION, + INTERNET_OPEN_TYPE_PRECONFIG, 0, 0, 0 ); + if ( ! hInet ) + { + diag ( "InternetOpen failed err=" + hexCode ( GetLastError() ) ); + return; + } + + HINTERNET hConn = InternetConnectA ( hInet, g.host.c_str(), g.port, 0, 0, INTERNET_SERVICE_HTTP, 0, 0 ); + if ( ! hConn ) + { + diag ( "InternetConnect failed err=" + hexCode ( GetLastError() ) ); + InternetCloseHandle ( hInet ); + return; + } + + DWORD flags = INTERNET_FLAG_NO_UI | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE; + if ( g.https ) + { + // Be lenient on certs: self-hosted Glitchtip instances often use private/relaxed certs, + // and a crash report is best-effort anyway. + flags |= INTERNET_FLAG_SECURE + | INTERNET_FLAG_IGNORE_CERT_CN_INVALID + | INTERNET_FLAG_IGNORE_CERT_DATE_INVALID; + } + + HINTERNET hReq = HttpOpenRequestA ( hConn, "POST", g.path.c_str(), 0, 0, 0, flags, 0 ); + if ( ! hReq ) + { + diag ( "HttpOpenRequest failed err=" + hexCode ( GetLastError() ) ); + InternetCloseHandle ( hConn ); + InternetCloseHandle ( hInet ); + return; + } + + DWORD timeout = HTTP_TIMEOUT_MS; + InternetSetOption ( hReq, INTERNET_OPTION_CONNECT_TIMEOUT, &timeout, sizeof ( timeout ) ); + InternetSetOption ( hReq, INTERNET_OPTION_SEND_TIMEOUT, &timeout, sizeof ( timeout ) ); + InternetSetOption ( hReq, INTERNET_OPTION_RECEIVE_TIMEOUT, &timeout, sizeof ( timeout ) ); + + const string headers = "Content-Type: application/x-sentry-envelope\r\nX-Sentry-Auth: " + g.authHeader + "\r\n"; + + const BOOL ok = HttpSendRequestA ( hReq, headers.c_str(), ( DWORD ) headers.size(), + ( LPVOID ) body.data(), ( DWORD ) body.size() ); + + if ( ! ok ) + { + diag ( "HttpSendRequest failed err=" + hexCode ( GetLastError() ) ); + } + else + { + DWORD status = 0, len = sizeof ( status ); + HttpQueryInfoA ( hReq, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &len, 0 ); + char buf[16]; + snprintf ( buf, sizeof ( buf ), "%u", ( unsigned ) status ); + diag ( "POST " + g.host + g.path + " -> HTTP " + buf ); + } + + InternetCloseHandle ( hReq ); + InternetCloseHandle ( hConn ); + InternetCloseHandle ( hInet ); +} + +DWORD WINAPI workerMain ( LPVOID ) +{ + for ( ;; ) + { + WaitForSingleObject ( g.wakeEvent, INFINITE ); + + for ( ;; ) + { + string body; + + EnterCriticalSection ( &g.cs ); + if ( g.queue.empty() ) + { + LeaveCriticalSection ( &g.cs ); + break; + } + body = g.queue.front(); + g.queue.erase ( g.queue.begin() ); + LeaveCriticalSection ( &g.cs ); + + httpPost ( body ); + } + } + return 0; +} + +void enqueue ( const string& body ) +{ + EnterCriticalSection ( &g.cs ); + + if ( ! g.workerThread ) + { + g.wakeEvent = CreateEvent ( 0, FALSE, FALSE, 0 ); + g.workerThread = CreateThread ( 0, 0, workerMain, 0, 0, 0 ); + } + + g.queue.push_back ( body ); + LeaveCriticalSection ( &g.cs ); + + if ( g.wakeEvent ) + SetEvent ( g.wakeEvent ); +} + + +// ----- event / envelope building ------------------------------------------- + +// Collect the real faulting call chain. When we have the fault CONTEXT we walk it with StackWalk64 +// (the actual call stack, not the exception-dispatch path that RtlCaptureStackBackTrace returns +// from inside a filter). Otherwise (terminate/SIGABRT) we fall back to the current call stack. +int captureFrames ( EXCEPTION_POINTERS *info, void *addrs[MAX_STACK_FRAMES] ) +{ + int count = 0; + + if ( info && info->ContextRecord ) + { + CONTEXT ctx = *info->ContextRecord; // StackWalk64 mutates the context, so copy it + STACKFRAME64 sf = { 0 }; + sf.AddrPC.Offset = ctx.Eip; + sf.AddrPC.Mode = AddrModeFlat; + sf.AddrFrame.Offset = ctx.Ebp; + sf.AddrFrame.Mode = AddrModeFlat; + sf.AddrStack.Offset = ctx.Esp; + sf.AddrStack.Mode = AddrModeFlat; + + const HANDLE proc = GetCurrentProcess(); + const HANDLE thread = GetCurrentThread(); + + while ( count < MAX_STACK_FRAMES + && StackWalk64 ( IMAGE_FILE_MACHINE_I386, proc, thread, &sf, &ctx, + 0, SymFunctionTableAccess64, SymGetModuleBase64, 0 ) ) + { + if ( sf.AddrPC.Offset == 0 ) + break; + addrs[count++] = ( void * ) ( uintptr_t ) sf.AddrPC.Offset; + } + } + + if ( count == 0 ) + { + if ( info && info->ExceptionRecord ) + addrs[count++] = info->ExceptionRecord->ExceptionAddress; + count += CaptureStackBackTrace ( 0, MAX_STACK_FRAMES - count, &addrs[count], 0 ); + } + + return count; +} + +// Write a "stacktrace" member (frames oldest-first) onto the current exception-value object. Each +// frame carries the absolute address, owning module + base (so a module-relative offset can be +// derived) and, when DbgHelp can resolve it, a symbol name. For MinGW DWARF binaries DbgHelp only +// resolves exported names; the authoritative source location comes from running addr2line on the +// unstripped binary with (instruction_addr - image_addr). +void writeStacktrace ( JsonWriter& w, EXCEPTION_POINTERS *info ) +{ + void *addrs[MAX_STACK_FRAMES] = { 0 }; + const int count = captureFrames ( info, addrs ); + + if ( count == 0 ) + return; + + const HANDLE proc = GetCurrentProcess(); + + // Symbol buffer for SymFromAddr (struct + room for the name). + char symBuf[sizeof ( SYMBOL_INFO ) + 256] = { 0 }; + SYMBOL_INFO *sym = ( SYMBOL_INFO * ) symBuf; + sym->SizeOfStruct = sizeof ( SYMBOL_INFO ); + sym->MaxNameLen = 255; + + w.String ( "stacktrace" ); + w.StartObject(); + w.String ( "frames" ); + w.StartArray(); + + // Sentry expects frames oldest-first, so emit in reverse of the captured (newest-first) order. + for ( int i = count - 1; i >= 0; --i ) + { + if ( ! addrs[i] ) + continue; + + HMODULE mod = 0; + string path; + const bool haveMod = moduleForAddr ( addrs[i], mod, path ); + + // Function name: prefer a DbgHelp symbol, else fall back to module!+0xRVA so the frame is + // still human-readable and the RVA is right there for addr2line. + string function; + DWORD64 disp = 0; + if ( g.symReady && SymFromAddr ( proc, ( DWORD64 ) ( uintptr_t ) addrs[i], &disp, sym ) ) + { + char d[16]; + snprintf ( d, sizeof ( d ), "+0x%llx", ( unsigned long long ) disp ); + function = string ( sym->Name ) + d; + } + else if ( haveMod ) + { + const uintptr_t rva = ( uintptr_t ) addrs[i] - ( uintptr_t ) mod; + char r[24]; + snprintf ( r, sizeof ( r ), "!0x%x", ( unsigned ) rva ); + function = baseName ( path ) + r; + } + + w.StartObject(); + if ( ! function.empty() ) + writeMember ( w, "function", function ); + writeMember ( w, "instruction_addr", hexPtr ( addrs[i] ) ); + if ( haveMod ) + { + writeMember ( w, "package", path ); + writeMember ( w, "image_addr", hexPtr ( ( void * ) mod ) ); + } + w.EndObject(); + } + + w.EndArray(); + w.EndObject(); +} + +// Build the Sentry event payload. type empty => a message event; type non-empty => an exception +// event (optionally carrying a stacktrace from info). The generated event_id is returned via param. +string buildEvent ( SentryClient::Level level, const string& type, const string& value, + EXCEPTION_POINTERS *info, bool includeStack, string& eventId ) +{ + eventId = randomEventId(); + + rapidjson::StringBuffer sb; + JsonWriter w ( sb ); + + w.StartObject(); + + writeMember ( w, "event_id", eventId ); + writeMember ( w, "timestamp", isoTimestamp() ); + w.String ( "platform" ); + w.String ( "native" ); + writeMember ( w, "level", levelStr ( level ) ); + w.String ( "logger" ); + w.String ( "cccaster" ); + + w.String ( "sdk" ); + w.StartObject(); + w.String ( "name" ); + w.String ( SENTRY_CLIENT_NAME ); + w.String ( "version" ); + w.String ( SENTRY_CLIENT_VERSION ); + w.EndObject(); + + if ( ! g.release.empty() ) + writeMember ( w, "release", g.release ); + if ( ! g.environment.empty() ) + writeMember ( w, "environment", g.environment ); + if ( ! g.dist.empty() ) + writeMember ( w, "dist", g.dist ); + + EnterCriticalSection ( &g.cs ); + + if ( ! g.tags.empty() ) + { + w.String ( "tags" ); + w.StartObject(); + for ( const auto& t : g.tags ) + writeMember ( w, t.first.c_str(), t.second ); + w.EndObject(); + } + + if ( ! g.breadcrumbs.empty() ) + { + w.String ( "breadcrumbs" ); + w.StartObject(); + w.String ( "values" ); + w.StartArray(); + for ( const auto& b : g.breadcrumbs ) + { + w.StartObject(); + writeMember ( w, "timestamp", b.first ); + writeMember ( w, "message", b.second ); + w.EndObject(); + } + w.EndArray(); + w.EndObject(); + } + + LeaveCriticalSection ( &g.cs ); + + if ( type.empty() ) + { + w.String ( "message" ); + w.StartObject(); + writeMember ( w, "formatted", value ); + w.EndObject(); + } + else + { + w.String ( "exception" ); + w.StartObject(); + w.String ( "values" ); + w.StartArray(); + w.StartObject(); + writeMember ( w, "type", type ); + writeMember ( w, "value", value ); + if ( includeStack ) + writeStacktrace ( w, info ); + w.EndObject(); + w.EndArray(); + w.EndObject(); + } + + w.EndObject(); + + return string ( sb.GetString(), sb.Size() ); +} + +// Assemble a full Sentry envelope (header line, item header line, event payload) ready to POST. +string buildEnvelope ( SentryClient::Level level, const string& type, const string& value, + EXCEPTION_POINTERS *info, bool includeStack ) +{ + string eventId; + const string payload = buildEvent ( level, type, value, info, includeStack, eventId ); + + rapidjson::StringBuffer header; + JsonWriter hw ( header ); + hw.StartObject(); + writeMember ( hw, "event_id", eventId ); + writeMember ( hw, "sent_at", isoTimestamp() ); + writeMember ( hw, "dsn", g.dsn ); + hw.EndObject(); + + rapidjson::StringBuffer item; + JsonWriter iw ( item ); + iw.StartObject(); + iw.String ( "type" ); + iw.String ( "event" ); + iw.String ( "length" ); + iw.Uint ( ( unsigned ) payload.size() ); + iw.EndObject(); + + string envelope ( header.GetString(), header.Size() ); + envelope += "\n"; + envelope.append ( item.GetString(), item.Size() ); + envelope += "\n"; + envelope += payload; + envelope += "\n"; + return envelope; +} + +// Build a crash event and upload it. Fatal/terminating paths post synchronously (must deliver +// before the process dies); a swallowed first-chance fault posts async (process lives on, and we +// must not stall gameplay). +void captureCrash ( SentryClient::Level level, const string& type, const string& value, + EXCEPTION_POINTERS *info, bool async ) +{ + if ( ! g.enabled ) + return; + + const string envelope = buildEnvelope ( level, type, value, info, true ); + + if ( async ) + enqueue ( envelope ); + else + httpPost ( envelope ); +} + + +// ----- crash handlers ------------------------------------------------------ + +// First-chance vectored handler. Catches crash-class faults even when a downstream __except (the +// game's or D3D's) would swallow them before the unhandled filter runs. Reports at most once and +// posts asynchronously so it never blocks the game; the unhandled filter below is the reliable +// synchronous path when the fault actually terminates the process. +LONG CALLBACK vehHandler ( EXCEPTION_POINTERS *info ) +{ + if ( ! g.enabled || ! info || ! info->ExceptionRecord ) + return EXCEPTION_CONTINUE_SEARCH; + + if ( ! isFatalCode ( info->ExceptionRecord->ExceptionCode ) ) + return EXCEPTION_CONTINUE_SEARCH; + + // Ignore first-chance faults inside Windows system DLLs: they're handled internally (e.g. + // dxdiag/setupapi during device enumeration) and are not crashes in the game or our code. + if ( addrInSystemModule ( info->ExceptionRecord->ExceptionAddress ) ) + return EXCEPTION_CONTINUE_SEARCH; + + if ( InterlockedExchange ( &g.vehReported, 1 ) ) + return EXCEPTION_CONTINUE_SEARCH; + + const string code = hexCode ( info->ExceptionRecord->ExceptionCode ); + const string value = "First-chance fatal exception " + code + + " at " + hexPtr ( info->ExceptionRecord->ExceptionAddress ); + + diag ( "VEH caught " + value ); + captureCrash ( SentryClient::Level::Fatal, "VEH " + code, value, info, /* async */ true ); + + return EXCEPTION_CONTINUE_SEARCH; +} + +LONG WINAPI sehFilter ( EXCEPTION_POINTERS *info ) +{ + if ( g.enabled && info && info->ExceptionRecord && ! InterlockedExchange ( &g.inFilter, 1 ) ) + { + const string code = hexCode ( info->ExceptionRecord->ExceptionCode ); + const string value = "Unhandled SEH exception " + code + + " at " + hexPtr ( info->ExceptionRecord->ExceptionAddress ); + + diag ( "unhandled filter caught " + value ); + captureCrash ( SentryClient::Level::Fatal, "SEH " + code, value, info, /* async */ false ); + } + + // Preserve the host's normal crash behaviour. + return g.prevFilter ? g.prevFilter ( info ) : EXCEPTION_CONTINUE_SEARCH; +} + +void terminateHandler() +{ + string what = "Unhandled C++ exception"; + + try + { + exception_ptr e = current_exception(); + if ( e ) + rethrow_exception ( e ); + } + catch ( const exception& ex ) + { + what = string ( "Unhandled exception: " ) + ex.what(); + } + catch ( ... ) + { + } + + diag ( "terminate: " + what ); + captureCrash ( SentryClient::Level::Fatal, "terminate", what, 0, /* async */ false ); + + if ( g.prevTerminate ) + g.prevTerminate(); + else + abort(); +} + +void sigabrtHandler ( int sig ) +{ + diag ( "SIGABRT caught" ); + captureCrash ( SentryClient::Level::Fatal, "SIGABRT", "abort() called (assertion failure)", 0, + /* async */ false ); + + // Chain to whatever was installed before us so existing cleanup/exit still runs. + if ( g.prevSigabrt && g.prevSigabrt != SIG_DFL && g.prevSigabrt != SIG_IGN ) + { + g.prevSigabrt ( sig ); + } + else + { + signal ( SIGABRT, SIG_DFL ); + raise ( SIGABRT ); + } +} + +} // anonymous namespace + + +// ----- public API ---------------------------------------------------------- + +namespace SentryClient +{ + +void setLogSink ( LogSink sink ) +{ + g.logSink = sink; +} + +void init ( const string& dsn, const string& release, const string& environment, const string& dist ) +{ + if ( ! g.csReady ) + { + InitializeCriticalSection ( &g.cs ); + g.csReady = true; + } + + g.enabled = false; + + if ( dsn.empty() ) + { + diag ( "disabled: no DSN configured (build with -DSENTRY_DSN / SENTRY_DSN=...)" ); + return; + } + + // Parse: ://@[:]/ + const size_t schemeEnd = dsn.find ( "://" ); + if ( schemeEnd == string::npos ) + { + diag ( "disabled: malformed DSN (no scheme)" ); + return; + } + + g.https = ( dsn.compare ( 0, schemeEnd, "https" ) == 0 ); + + const string rest = dsn.substr ( schemeEnd + 3 ); + + const size_t at = rest.find ( '@' ); + if ( at == string::npos ) + { + diag ( "disabled: malformed DSN (no '@')" ); + return; + } + + const string publicKey = rest.substr ( 0, at ); + string hostPart = rest.substr ( at + 1 ); + + const size_t slash = hostPart.find ( '/' ); + if ( slash == string::npos ) + { + diag ( "disabled: malformed DSN (no project id)" ); + return; + } + + string projectId = hostPart.substr ( slash + 1 ); + const string hostPort = hostPart.substr ( 0, slash ); + + // projectId may have a trailing slash or query; keep just the leading number/segment. + const size_t projEnd = projectId.find_first_of ( "/?" ); + if ( projEnd != string::npos ) + projectId = projectId.substr ( 0, projEnd ); + + if ( publicKey.empty() || hostPort.empty() || projectId.empty() ) + { + diag ( "disabled: malformed DSN (empty key/host/project)" ); + return; + } + + const size_t colon = hostPort.find ( ':' ); + if ( colon != string::npos ) + { + g.host = hostPort.substr ( 0, colon ); + g.port = ( INTERNET_PORT ) atoi ( hostPort.substr ( colon + 1 ).c_str() ); + } + else + { + g.host = hostPort; + g.port = ( g.https ? 443 : 80 ); + } + + g.path = "/api/" + projectId + "/envelope/"; + g.dsn = dsn; + g.authHeader = "Sentry sentry_version=7, sentry_key=" + publicKey + + ", sentry_client=" SENTRY_CLIENT_NAME "/" SENTRY_CLIENT_VERSION; + + g.release = release; + g.environment = environment; + g.dist = dist; + + g.enabled = true; + + char portBuf[8]; + snprintf ( portBuf, sizeof ( portBuf ), "%u", ( unsigned ) g.port ); + diag ( "enabled: " + string ( g.https ? "https" : "http" ) + " host=" + g.host + ":" + portBuf + + " path=" + g.path + " release=" + g.release + " env=" + g.environment ); +} + +bool isEnabled() +{ + return g.enabled; +} + +void setTag ( const string& key, const string& value ) +{ + if ( ! g.enabled ) + return; + + EnterCriticalSection ( &g.cs ); + for ( auto& t : g.tags ) + { + if ( t.first == key ) + { + t.second = value; + LeaveCriticalSection ( &g.cs ); + return; + } + } + g.tags.push_back ( { key, value } ); + LeaveCriticalSection ( &g.cs ); +} + +void addBreadcrumb ( const string& message ) +{ + if ( ! g.enabled ) + return; + + EnterCriticalSection ( &g.cs ); + g.breadcrumbs.push_back ( { isoTimestamp(), message } ); + if ( g.breadcrumbs.size() > MAX_BREADCRUMBS ) + g.breadcrumbs.erase ( g.breadcrumbs.begin() ); + LeaveCriticalSection ( &g.cs ); +} + +void captureMessage ( Level level, const string& message, bool async ) +{ + if ( ! g.enabled ) + return; + + diag ( "captureMessage: " + message ); + + const string envelope = buildEnvelope ( level, "", message, 0, false ); + + if ( async ) + enqueue ( envelope ); + else + httpPost ( envelope ); +} + +void captureException ( const string& type, const string& value, bool async ) +{ + if ( ! g.enabled ) + return; + + diag ( "captureException: " + type + ": " + value ); + + const string envelope = buildEnvelope ( Level::Error, type, value, 0, false ); + + if ( async ) + enqueue ( envelope ); + else + httpPost ( envelope ); +} + +void installCrashHandler() +{ + if ( ! g.enabled ) + return; + + // DbgHelp: used to walk the faulting stack and resolve symbol names at crash time. + if ( ! g.symReady ) + { + SymSetOptions ( SYMOPT_DEFERRED_LOADS | SYMOPT_UNDNAME | SYMOPT_LOAD_LINES ); + if ( SymInitialize ( GetCurrentProcess(), 0, TRUE ) ) + g.symReady = true; + } + + g.vehHandle = AddVectoredExceptionHandler ( 1, vehHandler ); + g.prevFilter = SetUnhandledExceptionFilter ( sehFilter ); + g.prevTerminate = set_terminate ( terminateHandler ); + g.prevSigabrt = signal ( SIGABRT, sigabrtHandler ); + + diag ( "crash handlers installed (veh + unhandled filter + terminate + SIGABRT)" ); +} + +void reassertCrashHandler() +{ + if ( ! g.enabled ) + return; + + // Reclaim the top-level filter if something replaced it; only update the chain when the current + // top isn't already ours (else we'd chain to ourselves and recurse). + LPTOP_LEVEL_EXCEPTION_FILTER cur = SetUnhandledExceptionFilter ( sehFilter ); + if ( cur != sehFilter ) + { + g.prevFilter = cur; + diag ( "reasserted unhandled filter (it had been replaced)" ); + } +} + +} // namespace SentryClient diff --git a/lib/SentryClient.hpp b/lib/SentryClient.hpp new file mode 100644 index 00000000..a4e190e8 --- /dev/null +++ b/lib/SentryClient.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include + + +// The DSN is baked in at build time via -DSENTRY_DSN (see the Makefile). Fall back to an empty +// string so any translation unit that includes this header still compiles if the define is absent +// (e.g. cppcheck, ad-hoc tool builds). An empty DSN leaves reporting disabled. +#ifndef SENTRY_DSN +#define SENTRY_DSN "" +#endif + + +// Lightweight Sentry / Glitchtip crash & error reporter. +// +// Self-contained: depends only on , and the C++ stdlib, so it can be linked +// into the standalone, the injected hook.dll, AND the dependency-light launcher.exe. It deliberately +// does NOT use LOG/ASSERT (would recurse through the crash path) or EventManager/Thread (uses raw +// Win32 threading so launcher.exe doesn't need to pull in winpthread). +// +// All entry points are cheap no-ops until init() is given a non-empty DSN, so call sites need no +// #ifdef guards. The DSN is baked in at build time via -DSENTRY_DSN (see the Makefile); an empty +// SENTRY_DSN leaves reporting disabled with effectively zero overhead. + +namespace SentryClient +{ + +enum class Level { Debug, Info, Warning, Error, Fatal }; + + +// Configure the client. Empty dsn => disabled (every other call becomes a no-op). +// dsn : Sentry DSN, e.g. https://@host[:port]/ +// release : maps to Sentry "release" (we pass the CCCaster version code) +// environment : maps to Sentry "environment" (we pass the build type: debug/logging/release) +// dist : optional build distinguisher (revision / build time) +void init ( const std::string& dsn, + const std::string& release, + const std::string& environment, + const std::string& dist = "" ); + +bool isEnabled(); + +// Route internal diagnostics (init result, handler install, crash caught, upload status) to this +// sink — e.g. the app's logger so they land in the debug/dll log. Diagnostics are ALSO always +// emitted via OutputDebugString. Set this before init() to capture the init diagnostics too. +typedef void ( *LogSink ) ( const char *message ); +void setLogSink ( LogSink sink ); + +// A tag attached to every subsequent event (e.g. "mode", "revision", "wine"). +void setTag ( const std::string& key, const std::string& value ); + +// Append a breadcrumb to the bounded ring buffer included with future events. +void addBreadcrumb ( const std::string& message ); + +// Capture a plain message event. When async, the upload is handed to a background worker thread so +// the caller never blocks (use this for mid-match captures in the hook.dll). +void captureMessage ( Level level, const std::string& message, bool async = false ); + +// Capture an exception-style event (shows in Sentry with a distinct type + value). +void captureException ( const std::string& type, const std::string& value, bool async = false ); + +// Install process-wide crash handlers: a vectored exception handler (catches faults even when a +// downstream __except — e.g. the game's or D3D's — would otherwise swallow them), the SEH +// unhandled-exception filter (access violations, etc. that actually terminate the process), +// std::terminate (unhandled C++ exceptions) and SIGABRT (abort()/failed ASSERT). Previous handlers +// are chained so the host's normal crash behaviour is preserved. Reports the faulting context + +// a best-effort backtrace. +void installCrashHandler(); + +// Re-assert the unhandled-exception filter. For the injected hook.dll: MBAA.exe / the D3D runtime +// can install their own filter during startup and bump ours off the top. Call this once the game +// is fully loaded. Safe to call repeatedly; preserves correct chaining. +void reassertCrashHandler(); + +} // namespace SentryClient diff --git a/scripts/symbolicate.sh b/scripts/symbolicate.sh new file mode 100644 index 00000000..80765e4f --- /dev/null +++ b/scripts/symbolicate.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Resolve Sentry/Glitchtip crash-frame addresses to function + source:line for a CCCaster build. +# +# A native crash report from lib/SentryClient carries, per frame, an absolute `instruction_addr` +# and the owning module's runtime `image_addr`. Because of ASLR the runtime load address differs +# from the binary's linked ImageBase, so we map each frame back to a file virtual address: +# +# file_VA = + (instruction_addr - image_addr) +# +# and feed that to addr2line. This only yields source lines for an UNSTRIPPED build (debug, or +# logging — release is stripped with -s, so keep a copy of the unstripped binary if you need this). +# +# Usage: +# scripts/symbolicate.sh [ ...] +# +# binary cccaster/hook.dll (the SAME build that crashed) or cccaster.v*.exe +# image_addr the frame's image_addr (module runtime base) from the event +# instruction_addr one or more frame instruction_addr values from the event +# +# Example (fault + a couple of caller frames, all in hook.dll): +# scripts/symbolicate.sh cccaster/hook.dll 0x72900000 0x72901dca 0x72903f51 +# +# MINGW32_BIN may point at the 32-bit binutils dir (default: /c/msys64/mingw32/bin). + +set -euo pipefail + +if [ "$#" -lt 3 ]; then + sed -n '2,30p' "$0" + exit 2 +fi + +BIN="$1"; IMAGE_ADDR="$2"; shift 2 + +MINGW32_BIN="${MINGW32_BIN:-/c/msys64/mingw32/bin}" +ADDR2LINE="$MINGW32_BIN/addr2line" +OBJDUMP="$MINGW32_BIN/objdump" +# Use the 32-bit binutils from MINGW32_BIN; only fall back to PATH if they're genuinely absent. +# A 64-bit addr2line/objdump on PATH silently returns "??" for a 32-bit DWARF binary, so the +# (extensionless) full path must be preferred — `command -v` on it fails for the .exe, so test +# the file directly instead. +[ -x "$ADDR2LINE" ] || [ -x "$ADDR2LINE.exe" ] || ADDR2LINE="addr2line" +[ -x "$OBJDUMP" ] || [ -x "$OBJDUMP.exe" ] || OBJDUMP="objdump" + +[ -f "$BIN" ] || { echo "ERROR: binary not found: $BIN" >&2; exit 1; } + +# Linked ImageBase from the PE header (printed in hex without a 0x prefix). +IMAGE_BASE=$("$OBJDUMP" -p "$BIN" | awk '/ImageBase/{print "0x"$2}') +[ -n "$IMAGE_BASE" ] || { echo "ERROR: could not read ImageBase from $BIN" >&2; exit 1; } + +if ! "$OBJDUMP" -h "$BIN" | grep -q '\.debug_info'; then + echo "WARNING: $BIN has no .debug_info (stripped?) — addresses won't resolve to source lines." >&2 +fi + +echo "binary : $BIN" +echo "ImageBase : $IMAGE_BASE" +echo "image_addr : $IMAGE_ADDR" +echo + +for INSTR in "$@"; do + RVA=$(( INSTR - IMAGE_ADDR )) + FILE_VA=$(( IMAGE_BASE + RVA )) + printf '%s (rva=0x%x)\n' "$INSTR" "$RVA" + "$ADDR2LINE" -f -C -i -e "$BIN" "$(printf '0x%x' "$FILE_VA")" | sed 's/^/ /' + echo +done diff --git a/targets/DllMain.cpp b/targets/DllMain.cpp index fef87703..10ab8a16 100644 --- a/targets/DllMain.cpp +++ b/targets/DllMain.cpp @@ -11,6 +11,7 @@ #include "SteamManager.hpp" #endif #include "Exceptions.hpp" +#include "SentryClient.hpp" #include "Enum.hpp" #include "ErrorStringsExt.hpp" #include "KeyboardState.hpp" @@ -35,6 +36,24 @@ using namespace std; // The main log file path #define LOG_FILE FOLDER "dll.log" +// Build type reported to Sentry as the "environment". +static const char *sentryEnvironment() +{ +#if defined(RELEASE) + return "release"; +#elif defined(LOGGING) + return "logging"; +#else + return "debug"; +#endif +} + +// Route SentryClient diagnostics into dll.log. +static void sentryLogSink ( const char *message ) +{ + LOG ( "[sentry] %s", message ); +} + // The number of milliseconds to poll for events each frame #define POLL_TIMEOUT ( 3 ) @@ -2168,6 +2187,20 @@ extern "C" BOOL APIENTRY DllMain ( HMODULE, DWORD reason, LPVOID ) LOG ( "DLL_PROCESS_ATTACH" ); LOG ( "gameDir='%s'", ProcessManager::gameDir ); + // Initialize crash/error reporting from inside MBAA.exe. No-op unless a DSN was baked + // in at build time. A vectored handler + the unhandled-exception filter catch ALL + // crashes in the game process; fatal crashes upload synchronously (the process is + // dying), while non-fatal captures during a match are uploaded off-thread (see + // AsmHacks::callback) so gameplay never stalls. + SentryClient::setLogSink ( sentryLogSink ); + SentryClient::init ( SENTRY_DSN, LocalVersion.code, sentryEnvironment(), LocalVersion.revision ); + SentryClient::setTag ( "component", "hook-dll" ); + SentryClient::setTag ( "revision", LocalVersion.revision ); + SentryClient::setTag ( "build_time", LocalVersion.buildTime ); + if ( ProcessManager::isWine() ) + SentryClient::setTag ( "wine", "1" ); + SentryClient::installCrashHandler(); + // We want the DLL to be able to rebind any previously bound ports Socket::forceReusePort ( true ); @@ -2243,6 +2276,10 @@ extern "C" void callback() if ( appState == AppState::Deinitialized ) return; + // Captured before the frame runs: decides whether a non-fatal capture below uploads on a + // background thread (mid-match, must not stall gameplay) or inline (out of match). + const bool inMatch = ( mainApp && mainApp->netMan.isInGame() ); + try { if ( appState == AppState::Uninitialized ) @@ -2258,6 +2295,10 @@ extern "C" void callback() // Start polling now EventManager::get().startPolling(); appState = AppState::Polling; + + // The game / D3D runtime installs its own handlers during startup; reclaim the + // top-level unhandled-exception filter now that the game is fully loaded. + SentryClient::reassertCrashHandler(); } ASSERT ( mainApp.get() != 0 ); @@ -2267,17 +2308,21 @@ extern "C" void callback() catch ( const Exception& exc ) { LOG ( "Stopping due to exception: %s", exc ); + // Upload off-thread while mid-match so the game loop never blocks on the network. + SentryClient::captureException ( "Exception", exc.str(), inMatch ); stopDllMain ( exc.user ); } #ifdef NDEBUG catch ( const std::exception& exc ) { LOG ( "Stopping due to std::exception: %s", exc.what() ); + SentryClient::captureException ( "std::exception", exc.what(), inMatch ); stopDllMain ( string ( "Error: " ) + exc.what() ); } catch ( ... ) { LOG ( "Stopping due to unknown exception!" ); + SentryClient::captureMessage ( SentryClient::Level::Fatal, "Unknown exception in DLL callback", inMatch ); stopDllMain ( "Unknown error!" ); } #endif // NDEBUG diff --git a/targets/Main.cpp b/targets/Main.cpp index da44c59b..aa78d892 100644 --- a/targets/Main.cpp +++ b/targets/Main.cpp @@ -5,6 +5,7 @@ #include "StringUtils.hpp" #include "ConsoleUi.hpp" #include "Version.hpp" +#include "SentryClient.hpp" #include #include @@ -26,6 +27,25 @@ MainUi ui; string lastError; +// Build type reported to Sentry as the "environment". +static const char *sentryEnvironment() +{ +#if defined(RELEASE) + return "release"; +#elif defined(LOGGING) + return "logging"; +#else + return "debug"; +#endif +} + +// Route SentryClient diagnostics into the debug log. +static void sentryLogSink ( const char *message ) +{ + LOG ( "[sentry] %s", message ); +} + + void runMain ( const IpAddrPort& address, const Serializable& config ); void runFake ( const IpAddrPort& address, const Serializable& config ); void stopMain(); @@ -405,6 +425,18 @@ int main ( int argc, char *argv[] ) LOG ( "Running from: %s", ProcessManager::appDir ); + // Initialize crash/error reporting now that logging is up (so its diagnostics are logged). + // No-op unless a DSN was baked in at build time. Installed after the signal handlers above so + // its SIGABRT hook chains back to signalHandler. + SentryClient::setLogSink ( sentryLogSink ); + SentryClient::init ( SENTRY_DSN, LocalVersion.code, sentryEnvironment(), LocalVersion.revision ); + SentryClient::setTag ( "component", "standalone" ); + SentryClient::setTag ( "revision", LocalVersion.revision ); + SentryClient::setTag ( "build_time", LocalVersion.buildTime ); + if ( ProcessManager::isWine() ) + SentryClient::setTag ( "wine", "1" ); + SentryClient::installCrashHandler(); + // Log parsed command line opt for ( size_t i = 0; i < opt.size(); ++i ) { @@ -562,15 +594,18 @@ int main ( int argc, char *argv[] ) } catch ( const Exception& exc ) { + SentryClient::captureException ( "Exception", exc.str() ); PRINT ( "%s", exc.user ); } #ifdef NDEBUG catch ( const std::exception& exc ) { + SentryClient::captureException ( "std::exception", exc.what() ); PRINT ( "Error: %s", exc.what() ); } catch ( ... ) { + SentryClient::captureMessage ( SentryClient::Level::Fatal, "Unknown exception in ui.main" ); PRINT ( "Unknown error!" ); } #endif // NDEBUG diff --git a/tools/Launcher.cpp b/tools/Launcher.cpp index 65913a03..a6ea5030 100644 --- a/tools/Launcher.cpp +++ b/tools/Launcher.cpp @@ -1,6 +1,8 @@ #define WIN32_LEAN_AND_MEAN #include +#include "SentryClient.hpp" + #include #include @@ -25,6 +27,8 @@ bool hookDLL ( const string& dll_path, const PROCESS_INFORMATION *pi ) char buffer[4096]; snprintf ( buffer, sizeof ( buffer ), "Could not create remote thread [%d].", ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); @@ -46,6 +50,8 @@ bool hookDLL ( const string& dll_path, const PROCESS_INFORMATION *pi ) { TerminateProcess ( pi->hProcess, -1 ); + SentryClient::captureMessage ( SentryClient::Level::Error, "Could not hook dll" ); + if ( popup_errors ) MessageBox ( 0, "Could not hook dll", "launcher error", MB_OK ); return false; @@ -87,6 +93,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, snprintf ( buffer, sizeof ( buffer ), "Couldn't find exe='%s'\nError [%d].", exe_path.c_str(), ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); return false; @@ -98,6 +106,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, snprintf ( buffer, sizeof ( buffer ), "Couldn't find dll='%s'\nError [%d].", dll_path.c_str(), ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); return false; @@ -119,6 +129,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, snprintf ( buffer, sizeof ( buffer ), "exe='%s'\ndir='%s'\nCould not create process [%d].", exe_path.c_str(), dir_path.c_str(), ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); return false; @@ -130,6 +142,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, DWORD address; if ( ! getbase ( pi.hProcess, &address, &orig_code ) ) { + SentryClient::captureMessage ( SentryClient::Level::Error, "Could not find entry point" ); + if ( popup_errors ) MessageBox ( 0, "Could not find entry point", "launcher error", MB_OK | MB_ICONEXCLAMATION ); return false; @@ -153,6 +167,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, Sleep ( 100 ); TerminateProcess ( pi.hProcess, -1 ); + SentryClient::captureMessage ( SentryClient::Level::Error, "Could not get thread context." ); + if ( popup_errors ) MessageBox ( 0, "Could not get thread context.", "launcher error", MB_OK ); return false; @@ -191,6 +207,11 @@ int main ( int argc, char *argv[] ) popup_errors = ( options.find ( "--popup_errors" ) != options.end() ); + // Crash/error reporting. No-op unless a DSN was baked in at build time. + SentryClient::init ( SENTRY_DSN, "", "", "" ); + SentryClient::setTag ( "component", "launcher" ); + SentryClient::installCrashHandler(); + // Create process and hook library. if ( argc > 2 && hook ( argv[1], argv[2], options.find ( "--high" ) != options.end(), options.find ( "--framestep" ) != options.end(), argv[3] ) ) return 0; From ca92bc02f618938bd0c14761d5cf110e5d4fa44f Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 21:07:09 -0300 Subject: [PATCH 09/25] Fix alt+enter crash when ImGui is shown --- targets/DllOverlayUi.cpp | 7 +++++++ targets/DllOverlayUiImGui.cpp | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/targets/DllOverlayUi.cpp b/targets/DllOverlayUi.cpp index d0cb7f1e..14c565b8 100644 --- a/targets/DllOverlayUi.cpp +++ b/targets/DllOverlayUi.cpp @@ -58,6 +58,13 @@ void InvalidateDeviceObjects() initalizedDirectX = false; invalidateOverlayText(); +#ifdef LOGGING + // Release ImGui's D3DPOOL_DEFAULT resources (vertex/index buffers, font texture, state block) + // BEFORE the game resets the device (e.g. alt+enter fullscreen toggle). Otherwise Reset() fails + // on the still-live default-pool resources and ImGui then renders against freed memory, which + // faults inside hook.dll. The next ImGui_ImplDX9_NewFrame() recreates them automatically. + ImGui_ImplDX9_InvalidateDeviceObjects(); +#endif } // Note: this is called on the SAME thread as the main application thread diff --git a/targets/DllOverlayUiImGui.cpp b/targets/DllOverlayUiImGui.cpp index 75740049..c489b9c8 100644 --- a/targets/DllOverlayUiImGui.cpp +++ b/targets/DllOverlayUiImGui.cpp @@ -18,6 +18,13 @@ extern bool doEndScene; extern bool initalizedDirectX; void initImGui( IDirect3DDevice9 *device ) { + // Initialize the ImGui context + backends only once. On a device reset the device pointer is + // unchanged (the game calls IDirect3DDevice9::Reset, not a recreate) and only its resources are + // lost, so we must NOT recreate the context here -- that would leak it and re-bind the backend. + // The device objects are recreated lazily by ImGui_ImplDX9_NewFrame() after invalidation. + if ( context ) + return; + IMGUI_CHECKVERSION(); context = ImGui::CreateContext(); void* windowHandle = ProcessManager::findWindow ( CC_TITLE ); From 68d498feec0d617ee17041d642fa858fe65e67bb Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 21:17:01 -0300 Subject: [PATCH 10/25] Derive version from nearest git tag on build --- .github/workflows/main.yml | 4 ++++ Makefile | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1cb9785..6e85207b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,10 @@ jobs: with: # imgui + SteamworksSDK are git submodules; recursive pulls both. submodules: recursive + # Full history + tags so the Makefile's `git describe` can resolve the + # nearest tag to derive the baked-in version (not just on tag pushes). + fetch-depth: 0 + fetch-tags: true - name: Set up MSYS2 (MINGW32 / i686) uses: msys2/setup-msys2@v2 diff --git a/Makefile b/Makefile index 00a3517f..9d79975a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,12 @@ -VERSION = 3.1 -SUFFIX = .007 +# Full version code derived from the nearest reachable git tag (leading 'v' stripped). +# Falls back to a literal when no tag is reachable (e.g. a source export with no git history). +GIT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') +FULL_VERSION := $(or $(GIT_VERSION),3.1.007) + +# major.minor: drives the binary/archive filenames and netplay-compat comparison +VERSION := $(shell echo '$(FULL_VERSION)' | cut -d. -f1,2) +# remaining patch part, with its leading dot (empty for tags like v2.1e) +SUFFIX := $(patsubst $(VERSION)%,%,$(FULL_VERSION)) NAME = cccaster TAG = BRANCH := $(shell git rev-parse --abbrev-ref HEAD) From 1c5ce836c1f0648a7ab80f4edfb558e1df0f05ad Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 21:24:26 -0300 Subject: [PATCH 11/25] Add missing import --- lib/EventManager.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/EventManager.hpp b/lib/EventManager.hpp index d202fed5..358dceee 100644 --- a/lib/EventManager.hpp +++ b/lib/EventManager.hpp @@ -3,6 +3,7 @@ #include "Thread.hpp" #include "BlockingQueue.hpp" +#include #include From 6bc309f0a3f53512c33f347724262d6e35d51c18 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 21:34:16 -0300 Subject: [PATCH 12/25] Force-include in all translation units CI's MSYS2 libstdc++ doesn't transitively expose the fixed-width integer types (uint8_t/uint32_t/...) that ~95 source files rely on via other standard headers, so the release build failed file-by-file (EventManager, ReplayCreator, ...). Add -include cstdint to CC_FLAGS so every TU gets up front, rather than hand-adding the include to every file. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9d79975a..adaa67e0 100644 --- a/Makefile +++ b/Makefile @@ -97,7 +97,9 @@ DEFINES += -DLOBBY_LIST='"$(LOBBY_LIST)"' INCLUDES = -I$(CURDIR) -I$(CURDIR)/netplay -I$(CURDIR)/lib -I$(CURDIR)/tests -I$(CURDIR)/3rdparty -I$(CURDIR)/sequences INCLUDES += -I$(CURDIR)/3rdparty/cereal/include -I$(CURDIR)/3rdparty/gtest/include -I$(CURDIR)/3rdparty/minhook/include INCLUDES += -I$(CURDIR)/3rdparty/d3dhook -I$(CURDIR)/3rdparty/framedisplay -I$(CURDIR)/3rdparty/imgui -I$(CURDIR)/3rdparty/imgui/backends -CC_FLAGS = -m32 $(INCLUDES) $(DEFINES) +# -include cstdint: some libstdc++ versions (e.g. CI's MSYS2 toolchain) don't transitively +# pull in , so force it into every TU rather than fixing ~95 missing includes by hand. +CC_FLAGS = -m32 -include cstdint $(INCLUDES) $(DEFINES) # https://gcc.gnu.org/onlinedocs/gcc/x86-Options.html # Intel Celeron 440 is listed as minimum CPU for melty on steam CC_FLAGS += -mmmx -msse -msse2 -msse3 -mssse3 From 1029ee84d02776a044d9bd4006ba7847c3d8a514 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 21:40:44 -0300 Subject: [PATCH 13/25] Parallelize CI build with -j MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release build compiled serially (~3.5 min). The Makefile's recursive phases propagate -j via MAKEFLAGS, so -j $(nproc) parallelizes the main build's compilation while link steps still wait on their object deps — same as scripts/build-steam.sh already does. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6e85207b..6d8ed84b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -67,7 +67,8 @@ jobs: STEAM_SDK=3rdparty/SteamworksSDK/public \ OS=Windows_NT \ SHELL=/tmp/recipe-shell.sh \ - CHMOD_X=true GRANT=true + CHMOD_X=true GRANT=true \ + -j "$(nproc)" - name: Stage artifact run: | From 7ee8dd5dcde0dbd903969b9306e49b7c34a13d8e Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 21:45:14 -0300 Subject: [PATCH 14/25] Use stdint.h, not cstdint, for the forced include CC_FLAGS is shared with the C sources (3rdparty/md5.c, miniz.c, minhook), and is C++-only, so -include cstdint broke the C compiles with "cstdint: No such file or directory". stdint.h works for both C and C++ and still exposes the bare global uint32_t the codebase relies on. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index adaa67e0..38b49925 100644 --- a/Makefile +++ b/Makefile @@ -97,9 +97,12 @@ DEFINES += -DLOBBY_LIST='"$(LOBBY_LIST)"' INCLUDES = -I$(CURDIR) -I$(CURDIR)/netplay -I$(CURDIR)/lib -I$(CURDIR)/tests -I$(CURDIR)/3rdparty -I$(CURDIR)/sequences INCLUDES += -I$(CURDIR)/3rdparty/cereal/include -I$(CURDIR)/3rdparty/gtest/include -I$(CURDIR)/3rdparty/minhook/include INCLUDES += -I$(CURDIR)/3rdparty/d3dhook -I$(CURDIR)/3rdparty/framedisplay -I$(CURDIR)/3rdparty/imgui -I$(CURDIR)/3rdparty/imgui/backends -# -include cstdint: some libstdc++ versions (e.g. CI's MSYS2 toolchain) don't transitively -# pull in , so force it into every TU rather than fixing ~95 missing includes by hand. -CC_FLAGS = -m32 -include cstdint $(INCLUDES) $(DEFINES) +# -include stdint.h: some libstdc++ versions (e.g. CI's MSYS2 toolchain) don't transitively +# pull in the fixed-width int types that ~95 files rely on, so force them into every TU rather +# than hand-adding the include everywhere. Use stdint.h (not the C++-only ) because +# CC_FLAGS is shared with the C sources (3rdparty/md5.c, miniz.c, ...); stdint.h works for both +# and still exposes the bare global uint32_t the codebase uses. +CC_FLAGS = -m32 -include stdint.h $(INCLUDES) $(DEFINES) # https://gcc.gnu.org/onlinedocs/gcc/x86-Options.html # Intel Celeron 440 is listed as minimum CPU for melty on steam CC_FLAGS += -mmmx -msse -msse2 -msse3 -mssse3 From 2431989fee96b0db7d9cb8acde8977ae4acfab7e Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 22:54:27 -0300 Subject: [PATCH 15/25] Update on github releases --- targets/MainUi.cpp | 5 +- targets/MainUpdater.cpp | 540 ++++++++++++++++++++++++++++------------ targets/MainUpdater.hpp | 60 +++-- 3 files changed, 434 insertions(+), 171 deletions(-) diff --git a/targets/MainUi.cpp b/targets/MainUi.cpp index 75436a7b..5952b4d0 100644 --- a/targets/MainUi.cpp +++ b/targets/MainUi.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -2202,7 +2203,9 @@ string MainUi::getUpdate ( bool isStartup ) return "Cannot fetch info for " + _updater.getTargetDescName() + " version"; } - if ( LocalVersion.isSimilar ( _updater.getTargetVersion(), ( _config.getInteger("updateChannel") - 1 ) ? 3 : 2 ) ) + // Tag-only comparison: GitHub release tags carry no revision/buildTime, so both channels + // compare at suffix level (2). The channel still drives prerelease filtering in MainUpdater. + if ( LocalVersion.isSimilar ( _updater.getTargetVersion(), 2 ) ) { _upToDate = true; if ( ! isStartup ) diff --git a/targets/MainUpdater.cpp b/targets/MainUpdater.cpp index 13933018..9f04d95d 100644 --- a/targets/MainUpdater.cpp +++ b/targets/MainUpdater.cpp @@ -2,10 +2,17 @@ #include "Logger.hpp" #include "ProcessManager.hpp" +// rapidjson (vendored by cereal) is included before so the windows min/max macros +// don't clash with its templates. +#include + #include #include +#include +#include #include +#include using namespace std; @@ -13,14 +20,165 @@ using namespace std; // Main update archive file name #define UPDATE_ARCHIVE_FILE "update.zip" -// Timeout for update version check -#define VERSION_CHECK_TIMEOUT ( 1000 ) +// GitHub repository the updater pulls releases from. +#define GITHUB_OWNER "SkyLeite" +#define GITHUB_REPO "CCCaster" +#define GITHUB_RELEASES_URL "https://api.github.com/repos/" GITHUB_OWNER "/" GITHUB_REPO "/releases?per_page=30" + +// Reported to GitHub; the API rejects requests without a User-Agent. +#define UPDATER_USER_AGENT "CCCaster-Updater" + +// Timeouts (ms): the version check is small, the archive can be several MB. +#define VERSION_CHECK_TIMEOUT ( 5000 ) +#define ARCHIVE_TIMEOUT ( 30000 ) + +// How often the event-loop timer polls the worker thread. +#define POLL_INTERVAL_MS ( 50 ) + + +namespace +{ +// Strip a leading 'v'/'V' from a release tag (e.g. "v4.0" -> "4.0") to get a Version code. +string stripTagPrefix ( const string& tag ) +{ + if ( ! tag.empty() && ( tag[0] == 'v' || tag[0] == 'V' ) ) + return tag.substr ( 1 ); + return tag; +} -static const vector updateServers = +// Blocking HTTPS GET via WinINet (handles TLS + redirects). Returns true on HTTP 200, with the +// body in outBody. Mirrors the WinINet usage in lib/SentryClient.cpp. +bool httpsGet ( const string& url, const string& headers, string& outBody, int& statusCode, uint32_t timeoutMs ) { - "http://150.230.44.244/" -}; + outBody.clear(); + statusCode = 0; + + HINTERNET hInet = InternetOpenA ( UPDATER_USER_AGENT, INTERNET_OPEN_TYPE_PRECONFIG, 0, 0, 0 ); + if ( ! hInet ) + { + LOG ( "InternetOpen failed err=%lu", GetLastError() ); + return false; + } + + DWORD t = timeoutMs; + InternetSetOption ( hInet, INTERNET_OPTION_CONNECT_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_SEND_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_RECEIVE_TIMEOUT, &t, sizeof ( t ) ); + + const DWORD flags = INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE + | INTERNET_FLAG_NO_UI | INTERNET_FLAG_KEEP_CONNECTION; + + HINTERNET hUrl = InternetOpenUrlA ( hInet, url.c_str(), + headers.empty() ? 0 : headers.c_str(), + headers.empty() ? 0 : ( DWORD ) headers.size(), + flags, 0 ); + if ( ! hUrl ) + { + LOG ( "InternetOpenUrl failed err=%lu url=%s", GetLastError(), url ); + InternetCloseHandle ( hInet ); + return false; + } + + DWORD status = 0, len = sizeof ( status ); + HttpQueryInfoA ( hUrl, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &len, 0 ); + statusCode = ( int ) status; + + char buf[8192]; + DWORD read = 0; + while ( InternetReadFile ( hUrl, buf, sizeof ( buf ), &read ) && read > 0 ) + outBody.append ( buf, read ); + + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + + return ( statusCode == 200 ); +} + +// Blocking HTTPS download to a file, reporting progress via onProgress(downloaded, total). +bool httpsDownload ( const string& url, const string& filePath, uint32_t timeoutMs, + const function& onProgress ) +{ + HINTERNET hInet = InternetOpenA ( UPDATER_USER_AGENT, INTERNET_OPEN_TYPE_PRECONFIG, 0, 0, 0 ); + if ( ! hInet ) + { + LOG ( "InternetOpen failed err=%lu", GetLastError() ); + return false; + } + + DWORD t = timeoutMs; + InternetSetOption ( hInet, INTERNET_OPTION_CONNECT_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_SEND_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_RECEIVE_TIMEOUT, &t, sizeof ( t ) ); + + const DWORD flags = INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE + | INTERNET_FLAG_NO_UI | INTERNET_FLAG_KEEP_CONNECTION; + + HINTERNET hUrl = InternetOpenUrlA ( hInet, url.c_str(), 0, 0, flags, 0 ); + if ( ! hUrl ) + { + LOG ( "InternetOpenUrl failed err=%lu url=%s", GetLastError(), url ); + InternetCloseHandle ( hInet ); + return false; + } + + DWORD status = 0, len = sizeof ( status ); + HttpQueryInfoA ( hUrl, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &len, 0 ); + + if ( status != 200 ) + { + LOG ( "Download HTTP %lu for %s", status, url ); + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + return false; + } + + DWORD total = 0; + len = sizeof ( total ); + HttpQueryInfoA ( hUrl, HTTP_QUERY_CONTENT_LENGTH | HTTP_QUERY_FLAG_NUMBER, &total, &len, 0 ); + + ofstream out ( filePath.c_str(), ios::binary ); + if ( ! out ) + { + LOG ( "Could not open for write: %s", filePath ); + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + return false; + } + + char buf[16384]; + DWORD read = 0; + uint32_t done = 0; + bool ok = true; + + for ( ;; ) + { + if ( ! InternetReadFile ( hUrl, buf, sizeof ( buf ), &read ) ) + { + LOG ( "InternetReadFile failed err=%lu", GetLastError() ); + ok = false; + break; + } + + if ( read == 0 ) + break; + + out.write ( buf, read ); + done += read; + + if ( onProgress ) + onProgress ( done, total ); + } + + out.close(); + + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + + return ok; +} + +} // anonymous namespace MainUpdater::MainUpdater ( Owner *owner ) : owner ( owner ) @@ -43,50 +201,250 @@ void MainUpdater::fetch ( const Type& type ) { _type = type; - _currentServerIdx = 0; + { + LOCK ( _mutex ); + _done = false; + _success = false; + _progDone = 0; + _progTotal = 0; + } - doFetch ( type ); + if ( type == Type::Version ) + { + _targetVersion.clear(); + _changelogBody.clear(); + _assetUrl.clear(); + } + + // Run the blocking WinINet work on a worker thread, then poll it from the event loop so the + // owner callbacks (and progress bar) stay on the main thread. + _worker.reset ( new FetchThread ( *this, type ) ); + _worker->start(); + + _pollTimer.reset ( new Timer ( this ) ); + _pollTimer->start ( POLL_INTERVAL_MS ); } -void MainUpdater::doFetch ( const Type& type ) +void MainUpdater::timerExpired ( Timer *timer ) { - string url = updateServers[_currentServerIdx]; + ASSERT ( _pollTimer.get() == timer ); + + bool done, success; + uint32_t progDone, progTotal; + + { + LOCK ( _mutex ); + done = _done; + success = _success; + progDone = _progDone; + progTotal = _progTotal; + } + + if ( ! done ) + { + if ( owner && progTotal > 0 ) + owner->fetchProgress ( this, _type, double ( progDone ) / progTotal ); + + _pollTimer->start ( POLL_INTERVAL_MS ); + return; + } + + _pollTimer.reset(); + + if ( _worker ) + { + _worker->join(); + _worker.reset(); + } + + if ( ! owner ) + return; + + if ( success ) + owner->fetchCompleted ( this, _type ); + else + owner->fetchFailed ( this, _type ); +} + +void MainUpdater::runFetch ( const Type& type ) +{ + bool ok = false; switch ( type.value ) { case Type::Version: - _targetVersion.code = ""; - url += getVersionFilePath(); - _httpGet.reset ( new HttpGet ( this, url, VERSION_CHECK_TIMEOUT ) ); - _httpGet->start(); + ok = fetchVersion(); break; case Type::ChangeLog: - url += CHANGELOG; - _httpDownload.reset ( new HttpDownload ( this, url, _downloadDir + CHANGELOG ) ); - _httpDownload->start(); + ok = writeChangeLog(); break; case Type::Archive: - if ( _targetVersion.empty() ) + ok = downloadArchive(); + break; + + default: + break; + } + + LOCK ( _mutex ); + _success = ok; + _done = true; +} + +bool MainUpdater::fetchVersion() +{ + static const string headers = + "Accept: application/vnd.github+json\r\n" + "X-GitHub-Api-Version: 2022-11-28\r\n" + "Accept-Encoding: identity\r\n"; + + string body; + int status = 0; + + if ( ! httpsGet ( GITHUB_RELEASES_URL, headers, body, status, VERSION_CHECK_TIMEOUT ) ) + { + LOG ( "Failed to fetch GitHub releases (status=%d)", status ); + return false; + } + + return parseReleases ( body ); +} + +bool MainUpdater::parseReleases ( const string& body ) +{ + // This (cereal-vendored) rapidjson predates the modern API: Parse needs explicit flags and + // arrays are walked with Begin()/End() rather than GetArray(). + rapidjson::Document doc; + doc.Parse<0> ( body.c_str() ); + + if ( doc.HasParseError() || ! doc.IsArray() ) + { + LOG ( "Could not parse releases JSON" ); + return false; + } + + // Pick the Nth release matching the channel (newest-first): Latest=0, Previous=1. + const unsigned wantIndex = ( _temporal == Temporal::Previous ) ? 1 : 0; + unsigned matched = 0; + + for ( auto it = doc.Begin(); it != doc.End(); ++it ) + { + rapidjson::Value& rel = *it; + + if ( ! rel.IsObject() ) + continue; + + // This rapidjson exposes IsTrue()/IsFalse() rather than IsBool()/GetBool(); IsTrue() is + // exactly the "present and true" test we want. + const bool draft = rel.HasMember ( "draft" ) && rel["draft"].IsTrue(); + if ( draft ) + continue; + + const bool prerelease = rel.HasMember ( "prerelease" ) && rel["prerelease"].IsTrue(); + + // Stable channel skips prereleases; Dev channel keeps them. + if ( _channel == Channel::Stable && prerelease ) + continue; + + if ( matched++ != wantIndex ) + continue; + + if ( ! rel.HasMember ( "tag_name" ) || ! rel["tag_name"].IsString() ) + { + LOG ( "Matched release has no tag_name" ); + return false; + } + + const string code = stripTagPrefix ( rel["tag_name"].GetString() ); + + _targetVersion = Version ( code ); + + _changelogBody.clear(); + if ( rel.HasMember ( "body" ) && rel["body"].IsString() ) + _changelogBody = rel["body"].GetString(); + + // Choose the release asset: prefer cccaster.v.zip, else the first .zip. + _assetUrl.clear(); + const string preferred = format ( "cccaster.v%s.zip", code ); + + if ( rel.HasMember ( "assets" ) && rel["assets"].IsArray() ) + { + rapidjson::Value& assets = rel["assets"]; + + for ( auto a = assets.Begin(); a != assets.End(); ++a ) { - std::string name = getTargetDescName(); - name[0] = std::toupper(name[0]); - LOG(name + " version is unknown"); + rapidjson::Value& asset = *a; + + if ( ! asset.IsObject() + || ! asset.HasMember ( "name" ) || ! asset["name"].IsString() + || ! asset.HasMember ( "browser_download_url" ) || ! asset["browser_download_url"].IsString() ) + continue; - if ( owner ) - owner->fetchFailed ( this, Type::Archive ); - return; + const string name = asset["name"].GetString(); + const string url = asset["browser_download_url"].GetString(); + + if ( name == preferred ) + { + _assetUrl = url; + break; + } + + if ( _assetUrl.empty() && name.size() >= 4 && name.compare ( name.size() - 4, 4, ".zip" ) == 0 ) + _assetUrl = url; } + } - url += format ( "cccaster.v%s.zip", _targetVersion.code ); - _httpDownload.reset ( new HttpDownload ( this, url, _downloadDir + UPDATE_ARCHIVE_FILE ) ); - _httpDownload->start(); - break; + LOG ( "Resolved %s: code=%s asset=%s", getTargetDescName(), code, _assetUrl ); - default: - break; + return ( ! _targetVersion.major().empty() && ! _targetVersion.minor().empty() ); } + + LOG ( "No matching release for %s", getTargetDescName() ); + return false; +} + +bool MainUpdater::writeChangeLog() +{ + if ( _changelogBody.empty() ) + { + LOG ( "No changelog body cached" ); + return false; + } + + const string path = _downloadDir + CHANGELOG; + + ofstream out ( path.c_str(), ios::binary ); + if ( ! out ) + { + LOG ( "Could not write changelog: %s", path ); + return false; + } + + out.write ( _changelogBody.data(), _changelogBody.size() ); + out.close(); + + return true; +} + +bool MainUpdater::downloadArchive() +{ + if ( _targetVersion.empty() || _assetUrl.empty() ) + { + std::string name = getTargetDescName(); + name[0] = std::toupper ( name[0] ); + LOG ( name + " has no downloadable archive" ); + return false; + } + + return httpsDownload ( _assetUrl, _downloadDir + UPDATE_ARCHIVE_FILE, ARCHIVE_TIMEOUT, + [this] ( uint32_t done, uint32_t total ) + { + LOCK ( _mutex ); + _progDone = done; + _progTotal = total; + } ); } bool MainUpdater::openChangeLog() const @@ -157,31 +515,6 @@ bool MainUpdater::extractArchive() const return true; } -std::string MainUpdater::getVersionFilePath() const -{ - switch (_channel.value) { - case Channel::Stable: - switch (_temporal.value) { - case Temporal::Latest: - return "LatestVersion"; - case Temporal::Previous: - return "PreviousVersion"; - default: break; - } - case Channel::Dev: - switch (_temporal.value) { - case Temporal::Latest: - return "LatestVersionDev"; - case Temporal::Previous: - return "PreviousVersionDev"; - default: break; - } - break; - default: break; - } - return "LatestVersion"; -} - std::string MainUpdater::getChannelName() const { switch (_channel.value) { @@ -204,102 +537,3 @@ std::string MainUpdater::getTargetDescName() const { return getTemporalName() + " " + getChannelName(); } - -void MainUpdater::httpResponse ( HttpGet *httpGet, int code, const string& data, uint32_t remainingBytes ) -{ - ASSERT ( _httpGet.get() == httpGet ); - ASSERT ( _type == Type::Version ); - - Version version; - vector versiondata; - switch (_channel.value) { - case Channel::Stable: - version = Version( trimmed ( data ) ); - break; - case Channel::Dev: - versiondata = split( data, "\n" ); - LOG( versiondata[0] ); - LOG( versiondata[1] ); - LOG( versiondata[2] ); - version = Version ( trimmed ( versiondata[0] ), - trimmed ( versiondata[1] ), - trimmed ( versiondata[2] ) ); - break; - default: - version = Version( trimmed ( data ) ); - break; - } - - if ( code != 200 || version.major().empty() || version.minor().empty() ) - { - httpFailed ( httpGet ); - return; - } - - _httpGet.reset(); - - _targetVersion = version; - - if ( owner ) - owner->fetchCompleted ( this, Type::Version ); -} - -void MainUpdater::httpFailed ( HttpGet *httpGet ) -{ - ASSERT ( _httpGet.get() == httpGet ); - ASSERT ( _type == Type::Version ); - - _httpGet.reset(); - - ++_currentServerIdx; - - if ( _currentServerIdx >= updateServers.size() ) - { - if ( owner ) - owner->fetchFailed ( this, Type::Version ); - return; - } - - doFetch ( Type::Version ); -} - -void MainUpdater::downloadComplete ( HttpDownload *httpDownload ) -{ - ASSERT ( _httpDownload.get() == httpDownload ); - ASSERT ( _type == Type::ChangeLog || _type == Type::Archive ); - - _httpDownload.reset(); - - if ( owner ) - owner->fetchCompleted ( this, _type ); -} - -void MainUpdater::downloadFailed ( HttpDownload *httpDownload ) -{ - ASSERT ( _httpDownload.get() == httpDownload ); - ASSERT ( _type == Type::ChangeLog || _type == Type::Archive ); - - _httpDownload.reset(); - - ++_currentServerIdx; - - if ( _currentServerIdx >= updateServers.size() ) - { - if ( owner ) - owner->fetchFailed ( this, _type ); - return; - } - - doFetch ( _type ); - - // Reset the download progress to zero - downloadProgress ( _httpDownload.get(), 0, 1 ); -} - -void MainUpdater::downloadProgress ( HttpDownload *httpDownload, uint32_t downloadedBytes, uint32_t totalBytes ) -{ - if ( owner ) - owner->fetchProgress ( this, _type, double ( downloadedBytes ) / totalBytes ); - - LOG ( "%u / %u", downloadedBytes, totalBytes ); -} diff --git a/targets/MainUpdater.hpp b/targets/MainUpdater.hpp index 998fcbc5..d4321854 100644 --- a/targets/MainUpdater.hpp +++ b/targets/MainUpdater.hpp @@ -1,16 +1,15 @@ #pragma once #include "Enum.hpp" -#include "HttpDownload.hpp" -#include "HttpGet.hpp" +#include "Thread.hpp" +#include "Timer.hpp" #include "Version.hpp" #include class MainUpdater - : private HttpDownload::Owner - , private HttpGet::Owner + : private Timer::Owner { public: @@ -39,8 +38,6 @@ class MainUpdater bool extractArchive() const; - std::string getVersionFilePath() const; - void setChannel(const Channel& channel) { _channel = channel; } void setTemporal(const Temporal& temporal) { _temporal = temporal; } @@ -54,13 +51,20 @@ class MainUpdater private: - Type _type; + // Worker thread that runs the (blocking) WinINet operation off the event loop. + struct FetchThread : public Thread + { + MainUpdater& updater; + Type type; - std::shared_ptr _httpGet; + FetchThread ( MainUpdater& updater, const Type& type ) : updater ( updater ), type ( type ) {} - std::shared_ptr _httpDownload; + void run() override { updater.runFetch ( type ); } + }; - uint32_t _currentServerIdx = 0; + friend struct FetchThread; + + Type _type; Channel _channel; @@ -68,15 +72,37 @@ class MainUpdater Version _targetVersion; + // Changelog body and download URL cached from the most recent Version fetch. + std::string _changelogBody; + + std::string _assetUrl; + std::string _downloadDir; - void doFetch ( const Type& type ); + // Worker thread + the state it hands back to the polling timer (guarded by _mutex). + ThreadPtr _worker; + + TimerPtr _pollTimer; + + mutable Mutex _mutex; + + bool _done = false; + + bool _success = false; + + uint32_t _progDone = 0, _progTotal = 0; + + // Runs on the worker thread. + void runFetch ( const Type& type ); + + bool fetchVersion(); + + bool parseReleases ( const std::string& body ); + + bool writeChangeLog(); - void httpResponse ( HttpGet *httpGet, int code, const std::string& data, uint32_t remainingBytes ) override; - void httpFailed ( HttpGet *httpGet ) override; - void httpProgress ( HttpGet *httpGet, uint32_t receivedBytes, uint32_t totalBytes ) override {} + bool downloadArchive(); - void downloadComplete ( HttpDownload *httpDownload ) override; - void downloadFailed ( HttpDownload *httpDownload ) override; - void downloadProgress ( HttpDownload *httpDownload, uint32_t downloadedBytes, uint32_t totalBytes ) override; + // Runs on the event loop; polls the worker and dispatches owner callbacks. + void timerExpired ( Timer *timer ) override; }; From 6ba9260e17708bb0830b8e1210bd22075b12d6fd Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Thu, 4 Jun 2026 23:56:50 -0300 Subject: [PATCH 16/25] Harden build version derivation to prefer highest tag on HEAD When multiple release tags share the HEAD commit, git describe could pick a lower version (e.g. v4.0 over v4.0.1), producing a binary whose built-in version never matches the GitHub 'latest' release and so re-prompts to update forever. Prefer the highest version tag pointing at HEAD, falling back to the nearest reachable tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 38b49925..1db94717 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ -# Full version code derived from the nearest reachable git tag (leading 'v' stripped). -# Falls back to a literal when no tag is reachable (e.g. a source export with no git history). -GIT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') +# Full version code derived from a git tag (leading 'v' stripped). +# When several tags share the HEAD commit (e.g. v4.0 and v4.0.1), prefer the HIGHEST version +# rather than whatever `git describe` happens to pick first. Fall back to the nearest reachable +# tag, then to a literal when no tag is reachable (e.g. a source export with no git history). +GIT_TAG := $(shell git tag --points-at HEAD --sort=-v:refname 2>/dev/null | head -n1) +GIT_TAG := $(or $(GIT_TAG),$(shell git describe --tags --abbrev=0 2>/dev/null)) +GIT_VERSION := $(patsubst v%,%,$(GIT_TAG)) FULL_VERSION := $(or $(GIT_VERSION),3.1.007) # major.minor: drives the binary/archive filenames and netplay-compat comparison From 934a5a6ab15b66e5476752f50386749f9538d304 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Fri, 5 Jun 2026 01:07:48 -0300 Subject: [PATCH 17/25] Fix CI release: deterministic version, verify, idempotent upload The release build derived its version from git *inside* the recursive make, but on the CI runner the MSYS2 git only had safe.directory configured for the Windows git (set by actions/checkout). make_version's git calls returned empty, so the archive was named from the tag (vX.Y.Z) while the binary baked in a bogus version and a bare '-custom' revision. Clients then saw a version that never matched the release and an exe/dll version mismatch ('incompatible hook.dll'). - Mark the workdir safe.directory for the MSYS2 git too. - On tag builds, pass FULL_VERSION straight from the tag so the baked-in version is deterministic and always matches the tag/archive name. - Add a guardrail step that fails the build if the produced exe doesn't embed the tag version, so a mis-versioned artifact can never be published. - Make the release step idempotent (clobber on re-run) so a re-pushed tag updates the asset instead of failing on an existing release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main.yml | 53 +++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d8ed84b..1324dcaf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,21 +55,54 @@ jobs: # The Makefile hardcodes the toolchain prefix as ../mingw32/bin/i686-w64-mingw32-* # (a symlink to the MSYS2 mingw32 tree on the dev's machine). Reproduce that layout. ln -sfn /mingw32 ../mingw32 + # actions/checkout only marks the workdir safe for the *Windows* git; the MSYS2 git used + # by the Makefile has its own config and otherwise refuses the repo ("dubious ownership"), + # which makes make_version's `git rev-parse`/`git status` return empty (baking a bogus + # version + bare "-custom" revision). Trust the workdir for the MSYS2 git too. + git config --global --add safe.directory "$(pwd)" || true # MSYS2's recipe shell can blank TMP/TEMP, making native gcc/collect2 fall back to a # non-writable C:\WINDOWS temp. Wrap the recipe shell to force a writable temp — same # fix as scripts/build-steam.sh. printf '%s\n' '#!/usr/bin/sh' 'export TMPDIR=/tmp TMP=/tmp TEMP=/tmp' 'shift' 'eval "$@"' > /tmp/recipe-shell.sh chmod +x /tmp/recipe-shell.sh + # On a tag build, pin the version straight from the tag (e.g. v4.1.0 -> 4.1.0). This is + # the authoritative source for a release and makes the baked-in version deterministic, + # independent of how `git describe` resolves inside the recursive make on the runner. + version_arg= + case "${GITHUB_REF:-}" in + refs/tags/v*) version_arg="FULL_VERSION=${GITHUB_REF_NAME#v}" ;; + esac # STEAM_SDK enables -DENABLE_STEAM and links the 32-bit steam_api import lib; the # resulting zip bundles steam_api.dll + steam_appid.txt via the Makefile's STEAM_PKG_* # hooks. CHMOD_X/GRANT are no-oped to skip per-file icacls calls (not needed in CI). make release \ + $version_arg \ STEAM_SDK=3rdparty/SteamworksSDK/public \ OS=Windows_NT \ SHELL=/tmp/recipe-shell.sh \ CHMOD_X=true GRANT=true \ -j "$(nproc)" + - name: Verify baked version matches tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + ver="${GITHUB_REF_NAME#v}" + zip="cccaster.v${ver}.zip" + if [ ! -f "$zip" ]; then + echo "::error::expected archive $zip not produced (got: $(ls cccaster.v*.zip 2>/dev/null))" + exit 1 + fi + # The standalone exe is named by major.minor; assert the full tag version is actually + # compiled into it, so a mis-derived version can never be shipped to the update channel. + exe="cccaster.v$(echo "$ver" | cut -d. -f1,2).exe" + unzip -p "$zip" "$exe" > /tmp/release-exe + if ! grep -aqF "$ver" /tmp/release-exe; then + echo "::error::$exe in $zip does not embed version '$ver' (baked version mismatch)" + exit 1 + fi + echo "OK: $zip embeds version $ver" + - name: Stage artifact run: | set -euo pipefail @@ -126,7 +159,7 @@ jobs: echo "----- generated notes -----" cat RELEASE_NOTES.md - - name: Create draft release + - name: Create or update draft release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -139,8 +172,16 @@ jobs: exit 1 fi echo "Attaching: ${assets[*]}" - gh release create "${tag}" \ - --draft \ - --title "${tag}" \ - --notes-file RELEASE_NOTES.md \ - "${assets[@]}" + # Idempotent: if a re-run (or re-pushed tag) finds the release already exists, replace its + # asset with --clobber instead of failing on `gh release create`, so the published asset + # always reflects the latest build for this tag. + if gh release view "${tag}" >/dev/null 2>&1; then + gh release upload "${tag}" "${assets[@]}" --clobber + gh release edit "${tag}" --notes-file RELEASE_NOTES.md + else + gh release create "${tag}" \ + --draft \ + --title "${tag}" \ + --notes-file RELEASE_NOTES.md \ + "${assets[@]}" + fi From 82129746e3752b52d05dbab32f3315c15fdfdff1 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Sat, 6 Jun 2026 21:43:01 -0300 Subject: [PATCH 18/25] Vendor Steam DLL and steam_appid.txt --- .gitignore | 2 -- vendor/steam_api.dll | Bin 0 -> 274072 bytes vendor/steam_appid.txt | 1 + 3 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 vendor/steam_api.dll create mode 100644 vendor/steam_appid.txt diff --git a/.gitignore b/.gitignore index f3cc56dd..523b879d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,6 @@ TAGS scripts/seqlists* debugging CLAUDE.local.md -steam_api*.dll -steam_appid.txt spike/ scripts/.recipe-shell.sh .env diff --git a/vendor/steam_api.dll b/vendor/steam_api.dll new file mode 100644 index 0000000000000000000000000000000000000000..b7ae7971a76a1cffe5ff716ca6e3e54e97e65bf5 GIT binary patch literal 274072 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 z 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 Date: Sat, 6 Jun 2026 21:47:34 -0300 Subject: [PATCH 19/25] Add vendored files to release zip --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 1db94717..0c32095f 100644 --- a/Makefile +++ b/Makefile @@ -218,6 +218,7 @@ ifneq (,$(findstring release,$(MAKECMDGOALS))) $(ZIP) $(ARCHIVE) -j scripts/Add_Handler_Protocol.bat $(ZIP) $(ARCHIVE) -j $(RELAY_LIST) $(ZIP) $(ARCHIVE) -j $(LOBBY_LIST) + $(ZIP) $(ARCHIVE) -j vendor/* cp -r res/GRP GRP $(ZIP) $(ARCHIVE) -r GRP $(ZIP) $(ARCHIVE) -r cccaster/trials From dbac9ad645330e673d0262f1ddcf2d7a96a8be00 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Sat, 6 Jun 2026 21:58:14 -0300 Subject: [PATCH 20/25] Fix CI version guardrail: don't depend on unzip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Verify baked version matches tag' step used `unzip -p` to read the exe out of the release zip, but the CI MSYS2 environment only installs zip, not unzip, so the step failed with 'unzip: command not found' (exit 127) on tag builds. Grep the built exe directly instead — it's the exact file that gets zipped, so the check is equivalent and needs no extra tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1324dcaf..f8bcc447 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,15 +93,19 @@ jobs: echo "::error::expected archive $zip not produced (got: $(ls cccaster.v*.zip 2>/dev/null))" exit 1 fi - # The standalone exe is named by major.minor; assert the full tag version is actually - # compiled into it, so a mis-derived version can never be shipped to the update channel. + # The standalone exe (named by major.minor) is the exact file that gets zipped; assert the + # full tag version is actually compiled into it, so a mis-derived version can never reach + # the update channel. Check the built exe directly (no unzip dependency on the runner). exe="cccaster.v$(echo "$ver" | cut -d. -f1,2).exe" - unzip -p "$zip" "$exe" > /tmp/release-exe - if ! grep -aqF "$ver" /tmp/release-exe; then - echo "::error::$exe in $zip does not embed version '$ver' (baked version mismatch)" + if [ ! -f "$exe" ]; then + echo "::error::expected built exe $exe not found" exit 1 fi - echo "OK: $zip embeds version $ver" + if ! grep -aqF "$ver" "$exe"; then + echo "::error::$exe does not embed version '$ver' (baked version mismatch)" + exit 1 + fi + echo "OK: $zip / $exe embed version $ver" - name: Stage artifact run: | From f893bb237fd9451306f53c5d62a7a03b1a72d025 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Sat, 6 Jun 2026 22:48:03 -0300 Subject: [PATCH 21/25] Fix CI: inject release version via Actions context, not shell env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix gated the FULL_VERSION override on $GITHUB_REF inside the msys2 build shell. That check didn't fire on the runner (the override never reached make), so the build fell back to git-in-make derivation and baked a version that didn't match the tag — caught by the guardrail ('does not embed version'). Inject the tag version as TAG_VERSION via the Actions context (github.ref_name), which the runner resolves before the step runs, so it can't be lost by the shell. Passing FULL_VERSION bypasses git-in-make entirely; verified locally that a full `make release FULL_VERSION=X` bakes X and passes the guardrail. Also cat the generated Version.local.hpp after the build for diagnostics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/main.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f8bcc447..d8fa3147 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,6 +50,11 @@ jobs: mingw-w64-i686-toolchain - name: Build Steam release + env: + # Inject the tag version from the Actions *context* (not the shell). github.ref_name is + # resolved by the runner before the step runs, so it can't be lost the way an unexported + # $GITHUB_REF inside the msys2 shell can. Empty on non-tag builds. + TAG_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }} run: | set -euo pipefail # The Makefile hardcodes the toolchain prefix as ../mingw32/bin/i686-w64-mingw32-* @@ -65,13 +70,14 @@ jobs: # fix as scripts/build-steam.sh. printf '%s\n' '#!/usr/bin/sh' 'export TMPDIR=/tmp TMP=/tmp TEMP=/tmp' 'shift' 'eval "$@"' > /tmp/recipe-shell.sh chmod +x /tmp/recipe-shell.sh - # On a tag build, pin the version straight from the tag (e.g. v4.1.0 -> 4.1.0). This is - # the authoritative source for a release and makes the baked-in version deterministic, - # independent of how `git describe` resolves inside the recursive make on the runner. + # On a tag build, pin the version straight from the tag (e.g. v4.1.0 -> 4.1.0). Passing + # FULL_VERSION makes the baked-in version deterministic and bypasses git-in-make entirely, + # so a flaky git on the runner can't produce a version that mismatches the tag. version_arg= - case "${GITHUB_REF:-}" in - refs/tags/v*) version_arg="FULL_VERSION=${GITHUB_REF_NAME#v}" ;; - esac + if [ -n "${TAG_VERSION:-}" ]; then + version_arg="FULL_VERSION=${TAG_VERSION#v}" + echo "Pinning release version: ${version_arg}" + fi # STEAM_SDK enables -DENABLE_STEAM and links the 32-bit steam_api import lib; the # resulting zip bundles steam_api.dll + steam_appid.txt via the Makefile's STEAM_PKG_* # hooks. CHMOD_X/GRANT are no-oped to skip per-file icacls calls (not needed in CI). @@ -82,6 +88,9 @@ jobs: SHELL=/tmp/recipe-shell.sh \ CHMOD_X=true GRANT=true \ -j "$(nproc)" + # Diagnostic: show exactly what version got baked in, so any future mismatch is obvious. + echo "=== generated lib/Version.local.hpp ===" + cat lib/Version.local.hpp || true - name: Verify baked version matches tag if: startsWith(github.ref, 'refs/tags/v') From da78e48ed9ef3900d59392f863803184840aa677 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Wed, 10 Jun 2026 14:36:21 -0300 Subject: [PATCH 22/25] Remove useless comments --- .github/workflows/main.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d8fa3147..880ff734 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,15 +10,11 @@ on: branches: [ master ] workflow_dispatch: -# Default to read-only; the release job opts into write below. permissions: contents: read jobs: # ------------------------------------------------------------------ build - # Builds the Steam-enabled release zip on a Windows runner using the same - # 32-bit MSYS2/MinGW toolchain the project builds with locally, then uploads - # the zip as a workflow artifact (persisted on every commit). build: runs-on: windows-latest defaults: @@ -28,10 +24,7 @@ jobs: - name: Checkout (with submodules) uses: actions/checkout@v4 with: - # imgui + SteamworksSDK are git submodules; recursive pulls both. submodules: recursive - # Full history + tags so the Makefile's `git describe` can resolve the - # nearest tag to derive the baked-in version (not just on tag pushes). fetch-depth: 0 fetch-tags: true @@ -40,8 +33,6 @@ jobs: with: msystem: MINGW32 update: true - # mingw-w64-i686-toolchain provides i686-w64-mingw32-{gcc,g++}, windres, - # strip, objcopy and the d3dx9 import lib needed to link hook.dll. install: >- make rsync @@ -51,9 +42,6 @@ jobs: - name: Build Steam release env: - # Inject the tag version from the Actions *context* (not the shell). github.ref_name is - # resolved by the runner before the step runs, so it can't be lost the way an unexported - # $GITHUB_REF inside the msys2 shell can. Empty on non-tag builds. TAG_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }} run: | set -euo pipefail From f115b07d3a657764d411fd7fdb7afd2e74d898c3 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Wed, 10 Jun 2026 15:52:47 -0300 Subject: [PATCH 23/25] Fix after-round false desync detection --- targets/DllHacks.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/targets/DllHacks.cpp b/targets/DllHacks.cpp index 8a86c34f..3e6b9ba6 100644 --- a/targets/DllHacks.cpp +++ b/targets/DllHacks.cpp @@ -298,7 +298,7 @@ SyncHash::SyncHash ( IndexedFrame indexedFrame ) chara[N-1].seqState = *CC_P ## N ## _SEQ_STATE_ADDR; \ chara[N-1].health = *CC_P ## N ## _HEALTH_ADDR; \ chara[N-1].redHealth = *CC_P ## N ## _RED_HEALTH_ADDR; \ - chara[N-1].meter = *CC_P ## N ## _METER_ADDR; \ + chara[N-1].meter = ( *CC_INTRO_STATE_ADDR ? 0 : *CC_P ## N ## _METER_ADDR ); \ chara[N-1].heat = *CC_P ## N ## _HEAT_ADDR; \ chara[N-1].guardBar = ( *CC_INTRO_STATE_ADDR ? 0 : *CC_P ## N ## _GUARD_BAR_ADDR ); \ chara[N-1].guardQuality = *CC_P ## N ## _GUARD_QUALITY_ADDR; \ From 23d3cc2231f0d37460cc0cd070679acdbe72d4b5 Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Wed, 10 Jun 2026 16:12:44 -0300 Subject: [PATCH 24/25] Fix CI --- .github/workflows/main.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 880ff734..d1d0cbab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,19 +90,20 @@ jobs: echo "::error::expected archive $zip not produced (got: $(ls cccaster.v*.zip 2>/dev/null))" exit 1 fi - # The standalone exe (named by major.minor) is the exact file that gets zipped; assert the - # full tag version is actually compiled into it, so a mis-derived version can never reach - # the update channel. Check the built exe directly (no unzip dependency on the runner). - exe="cccaster.v$(echo "$ver" | cut -d. -f1,2).exe" - if [ ! -f "$exe" ]; then - echo "::error::expected built exe $exe not found" + # The zip's existence already proves the Makefile derived the right major.minor+suffix + # (ARCHIVE is named from VERSION+SUFFIX). The remaining risk is the *runtime* version + # string: LocalVersion.code, which feeds the update channel, is generated into + # lib/Version.local.hpp by scripts/make_version from that same VERSION+SUFFIX. Assert the + # full tag version is the baked code there. We check the generated source rather than + # grepping the exe: an optimized release build constructs the short SSO string via + # immediate-mov instructions (e.g. "4.1." as a dword + '2','\0' as byte stores), so the + # version never appears as a contiguous run in the binary and a byte-grep is unreliable. + if ! grep -qF "\"$ver\"" lib/Version.local.hpp; then + echo "::error::lib/Version.local.hpp does not bake code \"$ver\" (baked version mismatch)" + cat lib/Version.local.hpp || true exit 1 fi - if ! grep -aqF "$ver" "$exe"; then - echo "::error::$exe does not embed version '$ver' (baked version mismatch)" - exit 1 - fi - echo "OK: $zip / $exe embed version $ver" + echo "OK: $zip produced and lib/Version.local.hpp bakes version $ver" - name: Stage artifact run: | From a1a30dfe2b59d72ed73c355820fc3ac347922e0a Mon Sep 17 00:00:00 2001 From: Sky Leite Date: Wed, 10 Jun 2026 16:51:43 -0300 Subject: [PATCH 25/25] Fix version matching --- .github/workflows/main.yml | 34 ++++++++++++++++++++-------------- scripts/make_version | 10 ++++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1d0cbab..93214197 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,20 +90,26 @@ jobs: echo "::error::expected archive $zip not produced (got: $(ls cccaster.v*.zip 2>/dev/null))" exit 1 fi - # The zip's existence already proves the Makefile derived the right major.minor+suffix - # (ARCHIVE is named from VERSION+SUFFIX). The remaining risk is the *runtime* version - # string: LocalVersion.code, which feeds the update channel, is generated into - # lib/Version.local.hpp by scripts/make_version from that same VERSION+SUFFIX. Assert the - # full tag version is the baked code there. We check the generated source rather than - # grepping the exe: an optimized release build constructs the short SSO string via - # immediate-mov instructions (e.g. "4.1." as a dword + '2','\0' as byte stores), so the - # version never appears as a contiguous run in the binary and a byte-grep is unreliable. - if ! grep -qF "\"$ver\"" lib/Version.local.hpp; then - echo "::error::lib/Version.local.hpp does not bake code \"$ver\" (baked version mismatch)" - cat lib/Version.local.hpp || true - exit 1 - fi - echo "OK: $zip produced and lib/Version.local.hpp bakes version $ver" + # The zip's existence proves the Makefile derived the right major.minor+suffix (ARCHIVE is + # named from VERSION+SUFFIX). Now assert the full tag version is embedded as a CONTIGUOUS + # string in both shipped binaries. This is exactly what the runtime needs: the exe's + # hook.dll compatibility check (targets/Main.cpp) searches the dll's bytes for the version + # string, so a release whose binaries lack a contiguous copy boots as "Incompatible + # hook.dll". scripts/make_version embeds LocalVersionCode[] precisely so this holds; grep + # here so any regression of that embedding fails CI instead of shipping a broken release. + exe="cccaster.v$(echo "$ver" | cut -d. -f1,2).exe" + dll="cccaster/hook.dll" + for bin in "$exe" "$dll"; do + if [ ! -f "$bin" ]; then + echo "::error::expected built binary $bin not found" + exit 1 + fi + if ! grep -aqF "$ver" "$bin"; then + echo "::error::$bin does not embed contiguous version '$ver' (would boot as Incompatible hook.dll)" + exit 1 + fi + done + echo "OK: $zip / $exe / $dll embed version $ver" - name: Stage artifact run: | diff --git a/scripts/make_version b/scripts/make_version index 17bdb649..810c9bb6 100755 --- a/scripts/make_version +++ b/scripts/make_version @@ -13,3 +13,13 @@ printf " \"$COMMIT_ID\",\n" fi printf " \"`date`\"\n" printf ");\n" + +# Also embed the version code as a plain string literal. LocalVersion.code is a std::string the +# optimizer builds at runtime via immediate-mov instructions (e.g. "4.1." as a dword then '2','\0' +# as byte stores), so the version never appears as a contiguous run in the optimized binary. The +# exe's hook.dll compatibility check (targets/Main.cpp) searches the dll *file bytes* for that +# version string, so without a literal copy a release build always reports "Incompatible hook.dll". +# A const char[] is initialized data: its bytes land contiguously in .rodata and survive -Ofast and +# strip (the link has no --gc-sections). extern + __attribute__((used)) keeps it from being elided. +printf "extern const char LocalVersionCode[];\n" +printf "const char LocalVersionCode[] __attribute__ ( ( used ) ) = \"$CODE\";\n"