From 5dad6c24f77320ac59448513e282128fdbcfea6f Mon Sep 17 00:00:00 2001 From: itsRevela Date: Tue, 24 Mar 2026 11:30:14 -0500 Subject: [PATCH] Fix server list refresh and add cancellable non-blocking connection Server list: edits and deletions now update the UI immediately by calling SearchForGames() in ForceFriendsSessionRefresh() and UpdateGamesList() on nav-back to LoadOrJoinMenu. Connection: moved WinsockNetLayer::JoinGame() to a background thread with non-blocking sockets (5s timeout, 3 retries). Users can cancel with B/Escape during the attempt. Failed connections always show an error dialog. --- .../Common/Network/GameNetworkManager.cpp | 24 ++++ .../Common/Network/GameNetworkManager.h | 4 + .../Network/PlatformNetworkManagerStub.cpp | 62 ++++++++++ .../Network/PlatformNetworkManagerStub.h | 5 + .../Common/UI/UIScene_JoinMenu.cpp | 69 ++++++++++- Minecraft.Client/Common/UI/UIScene_JoinMenu.h | 2 + .../Common/UI/UIScene_LoadOrJoinMenu.cpp | 5 + .../Windows64/Network/WinsockNetLayer.cpp | 115 ++++++++++++++++-- .../Windows64/Network/WinsockNetLayer.h | 15 +++ README.md | 7 ++ 10 files changed, 297 insertions(+), 11 deletions(-) diff --git a/Minecraft.Client/Common/Network/GameNetworkManager.cpp b/Minecraft.Client/Common/Network/GameNetworkManager.cpp index 50aeae68..2b415fe0 100644 --- a/Minecraft.Client/Common/Network/GameNetworkManager.cpp +++ b/Minecraft.Client/Common/Network/GameNetworkManager.cpp @@ -772,8 +772,32 @@ void CGameNetworkManager::CancelJoinGame(LPVOID lpParam) #ifdef _XBOX_ONE s_pPlatformNetworkManager->CancelJoinGame(); #endif +#ifdef _WINDOWS64 + static_cast(s_pPlatformNetworkManager)->CancelJoinGame(); +#endif } +#ifdef _WINDOWS64 +bool CGameNetworkManager::BeginJoinGameAsync(FriendSessionInfo *searchResult, int localUsersMask) +{ + app.SetTutorialMode(false); + g_NetworkManager.SetLocalGame(false); + + int primaryUserIndex = ProfileManager.GetLockedProfile(); + + Minecraft::GetInstance()->clearConnectionFailed(); + + localUsersMask |= GetLocalPlayerMask(ProfileManager.GetPrimaryPad()); + + return static_cast(s_pPlatformNetworkManager)->BeginJoinGameAsync(searchResult, localUsersMask, primaryUserIndex); +} + +int CGameNetworkManager::FinishJoinGame(FriendSessionInfo *searchResult) +{ + return static_cast(s_pPlatformNetworkManager)->FinishJoinGame(searchResult); +} +#endif + bool CGameNetworkManager::LeaveGame(bool bMigrateHost) { Minecraft::GetInstance()->gui->clearMessages(); diff --git a/Minecraft.Client/Common/Network/GameNetworkManager.h b/Minecraft.Client/Common/Network/GameNetworkManager.h index 3357b3cd..5f105165 100644 --- a/Minecraft.Client/Common/Network/GameNetworkManager.h +++ b/Minecraft.Client/Common/Network/GameNetworkManager.h @@ -106,6 +106,10 @@ public: bool JoinGameFromInviteInfo( int userIndex, int userMask, const INVITE_INFO *pInviteInfo); eJoinGameResult JoinGame(FriendSessionInfo *searchResult, int localUsersMask); static void CancelJoinGame(LPVOID lpParam); // Not part of the shared interface +#ifdef _WINDOWS64 + bool BeginJoinGameAsync(FriendSessionInfo *searchResult, int localUsersMask); + int FinishJoinGame(FriendSessionInfo *searchResult); +#endif bool LeaveGame(bool bMigrateHost); static int JoinFromInvite_SignInReturned(void *pParam,bool bContinue, int iPad); void UpdateAndSetGameSessionData(INetworkPlayer *pNetworkPlayerLeaving = nullptr); diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp index 1e625098..ff0d6fbb 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp @@ -546,6 +546,61 @@ int CPlatformNetworkManagerStub::JoinGame(FriendSessionInfo* searchResult, int l #endif } +#ifdef _WINDOWS64 +bool CPlatformNetworkManagerStub::BeginJoinGameAsync(FriendSessionInfo* searchResult, int localUsersMask, int primaryUserIndex) +{ + if (searchResult == nullptr) + return false; + + const char* hostIP = searchResult->data.hostIP; + int hostPort = searchResult->data.hostPort; + + if (hostPort <= 0 || hostIP[0] == 0) + return false; + + m_bLeavingGame = false; + m_bLeaveGameOnTick = false; + IQNet::s_isHosting = false; + m_pIQNet->ClientJoinGame(); + + IQNet::m_player[0].m_smallId = 0; + IQNet::m_player[0].m_isRemote = true; + IQNet::m_player[0].m_isHostPlayer = true; + IQNet::m_player[0].m_resolvedXuid = Win64Xuid::GetLegacyEmbeddedHostXuid(); + wcsncpy_s(IQNet::m_player[0].m_gamertag, 32, searchResult->data.hostName, _TRUNCATE); + + WinsockNetLayer::StopDiscovery(); + + return WinsockNetLayer::StartJoinGameAsync(hostIP, hostPort); +} + +int CPlatformNetworkManagerStub::FinishJoinGame(FriendSessionInfo* searchResult) +{ + BYTE localSmallId = WinsockNetLayer::GetLocalSmallId(); + + IQNet::m_player[localSmallId].m_smallId = localSmallId; + IQNet::m_player[localSmallId].m_isRemote = false; + IQNet::m_player[localSmallId].m_isHostPlayer = false; + IQNet::m_player[localSmallId].m_resolvedXuid = Win64Xuid::ResolvePersistentXuid(); + + Minecraft* pMinecraft = Minecraft::GetInstance(); + wcscpy_s(IQNet::m_player[localSmallId].m_gamertag, 32, pMinecraft->user->name.c_str()); + IQNet::s_playerCount = localSmallId + 1; + + NotifyPlayerJoined(&IQNet::m_player[0]); + NotifyPlayerJoined(&IQNet::m_player[localSmallId]); + + m_pGameNetworkManager->StateChange_AnyToStarting(); + + return CGameNetworkManager::JOINGAME_SUCCESS; +} + +void CPlatformNetworkManagerStub::CancelJoinGame() +{ + WinsockNetLayer::CancelJoinGame(); +} +#endif + bool CPlatformNetworkManagerStub::SetLocalGame(bool isLocal) { m_bIsOfflineGame = isLocal; @@ -955,6 +1010,13 @@ void CPlatformNetworkManagerStub::ForceFriendsSessionRefresh() delete m_pSearchResults[i]; m_pSearchResults[i] = nullptr; } + +#ifdef _WINDOWS64 + // Immediately rebuild the session list from servers.db so that + // edits/deletions are visible as soon as the UI regains focus, + // rather than waiting for the next TickSearch() cycle. + SearchForGames(); +#endif } INetworkPlayer *CPlatformNetworkManagerStub::addNetworkPlayer(IQNetPlayer *pQNetPlayer) diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h index 4a3f4068..77c3a7eb 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h @@ -42,6 +42,11 @@ public: virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); virtual int JoinGame(FriendSessionInfo *searchResult, int localUsersMask, int primaryUserIndex ); +#ifdef _WINDOWS64 + bool BeginJoinGameAsync(FriendSessionInfo *searchResult, int localUsersMask, int primaryUserIndex); + int FinishJoinGame(FriendSessionInfo *searchResult); + void CancelJoinGame(); +#endif virtual bool SetLocalGame(bool isLocal); virtual bool IsLocalGame() { return m_bIsOfflineGame; } virtual void SetPrivateGame(bool isPrivate); diff --git a/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp b/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp index 417c1700..176a4749 100644 --- a/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp @@ -7,6 +7,9 @@ #include "..\..\MinecraftServer.h" #include "..\..\..\Minecraft.World\net.minecraft.world.level.h" #include "..\..\..\Minecraft.World\net.minecraft.world.h" +#ifdef _WINDOWS64 +#include "..\..\Windows64\Network\WinsockNetLayer.h" +#endif #define UPDATE_PLAYERS_TIMER_ID 0 #define UPDATE_PLAYERS_TIMER_TIME 30000 @@ -26,6 +29,8 @@ UIScene_JoinMenu::UIScene_JoinMenu(int iPad, void *_initData, UILayer *parentLay m_editServerPhase = eEditServer_Idle; m_editServerButtonIndex = -1; m_deleteServerButtonIndex = -1; + m_asyncJoinInProgress = false; + m_joinLocalUsersMask = 0; #endif } @@ -59,6 +64,35 @@ void UIScene_JoinMenu::updateTooltips() void UIScene_JoinMenu::tick() { +#ifdef _WINDOWS64 + if (m_asyncJoinInProgress && WinsockNetLayer::IsJoinComplete()) + { + m_asyncJoinInProgress = false; + if (WinsockNetLayer::GetJoinResult()) + { + int result = g_NetworkManager.FinishJoinGame(m_selectedSession); + app.SetLiveLinkRequired(false); + if (result != CGameNetworkManager::JOINGAME_SUCCESS) + { + m_bIgnoreInput = false; + m_buttonJoinGame.setLabel(app.GetString(IDS_JOIN_GAME)); + updateTooltips(); + } + } + else + { + app.SetLiveLinkRequired(false); + m_bIgnoreInput = false; + m_buttonJoinGame.setLabel(app.GetString(IDS_JOIN_GAME)); + updateTooltips(); + + UINT uiIDA[1]; + uiIDA[0] = IDS_CONFIRM_OK; + ui.RequestErrorMessage(IDS_CONNECTION_FAILED, IDS_CONNECTION_LOST_SERVER, uiIDA, 1, ProfileManager.GetPrimaryPad()); + } + } +#endif + if( !m_friendInfoRequestIssued ) { ui.NavigateToScene(m_iPad, eUIScene_Timer); @@ -282,6 +316,19 @@ wstring UIScene_JoinMenu::getMoviePath() void UIScene_JoinMenu::handleInput(int iPad, int key, bool repeat, bool pressed, bool released, bool &handled) { +#ifdef _WINDOWS64 + if (m_asyncJoinInProgress && key == ACTION_MENU_CANCEL && pressed) + { + g_NetworkManager.CancelJoinGame(nullptr); + m_asyncJoinInProgress = false; + m_bIgnoreInput = false; + m_buttonJoinGame.setLabel(app.GetString(IDS_JOIN_GAME)); + updateTooltips(); + handled = true; + return; + } +#endif + if(m_bIgnoreInput) return; ui.AnimateKeyPress(m_iPad, key, repeat, pressed, released); @@ -578,7 +625,19 @@ void UIScene_JoinMenu::JoinGame(UIScene_JoinMenu* pClass) ProfileManager.DisplaySystemMessage( SCE_MSG_DIALOG_SYSMSG_TYPE_TRC_PSN_CHAT_RESTRICTION, ProfileManager.GetPrimaryPad() ); } #endif +#ifdef _WINDOWS64 + if (g_NetworkManager.BeginJoinGameAsync(pClass->m_selectedSession, dwLocalUsersMask)) + { + pClass->m_asyncJoinInProgress = true; + pClass->m_joinLocalUsersMask = dwLocalUsersMask; + pClass->m_buttonJoinGame.setLabel(app.GetString(IDS_PROGRESS_CONNECTING)); + ui.SetTooltips(DEFAULT_XUI_MENU_USER, -1, IDS_TOOLTIPS_CANCEL_JOIN); + return; + } + CGameNetworkManager::eJoinGameResult result = CGameNetworkManager::JOINGAME_FAIL_GENERAL; +#else CGameNetworkManager::eJoinGameResult result = g_NetworkManager.JoinGame( pClass->m_selectedSession, dwLocalUsersMask ); +#endif // Alert the app the we no longer want to be informed of ethernet connections app.SetLiveLinkRequired( false ); @@ -637,16 +696,18 @@ void UIScene_JoinMenu::JoinGame(UIScene_JoinMenu* pClass) if( exitReasonStringId == -1 ) { - ui.NavigateBack(pClass->m_iPad); + // No specific disconnect reason was set — the server was likely + // unreachable. Show a "Connection Failed" dialog instead of + // silently navigating back so the user knows what happened. + exitReasonStringId = IDS_CONNECTION_LOST_SERVER; } - else + { UINT uiIDA[1]; uiIDA[0]=IDS_CONFIRM_OK; ui.RequestErrorMessage( IDS_CONNECTION_FAILED, exitReasonStringId, uiIDA,1,ProfileManager.GetPrimaryPad()); - exitReasonStringId = -1; - ui.NavigateToHomeMenu(); + pClass->m_bIgnoreInput = false; } } } diff --git a/Minecraft.Client/Common/UI/UIScene_JoinMenu.h b/Minecraft.Client/Common/UI/UIScene_JoinMenu.h index 566697cd..7c555e84 100644 --- a/Minecraft.Client/Common/UI/UIScene_JoinMenu.h +++ b/Minecraft.Client/Common/UI/UIScene_JoinMenu.h @@ -70,6 +70,8 @@ private: wstring m_editServerPort; int m_editServerButtonIndex; int m_deleteServerButtonIndex; + bool m_asyncJoinInProgress; + DWORD m_joinLocalUsersMask; #endif public: diff --git a/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp b/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp index 234ec928..79929b55 100644 --- a/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp @@ -637,6 +637,11 @@ void UIScene_LoadOrJoinMenu::handleGainFocus(bool navBack) if( m_bMultiplayerAllowed ) { +#ifdef _WINDOWS64 + // Refresh the games list immediately so that any server + // edits/deletions made in the JoinMenu are visible now. + UpdateGamesList(); +#endif #if TO_BE_IMPLEMENTED HXUICLASS hClassFullscreenProgress = XuiFindClass( L"CScene_FullscreenProgress" ); HXUICLASS hClassConnectingProgress = XuiFindClass( L"CScene_ConnectingProgress" ); diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp index 981ab3ab..96b35187 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp @@ -67,6 +67,13 @@ SOCKET WinsockNetLayer::s_splitScreenSocket[XUSER_MAX_COUNT] = { INVALID_SOCKET, BYTE WinsockNetLayer::s_splitScreenSmallId[XUSER_MAX_COUNT] = { 0xFF, 0xFF, 0xFF, 0xFF }; HANDLE WinsockNetLayer::s_splitScreenRecvThread[XUSER_MAX_COUNT] = {nullptr, nullptr, nullptr, nullptr}; +volatile bool WinsockNetLayer::s_joinCancelled = false; +volatile bool WinsockNetLayer::s_joinComplete = false; +bool WinsockNetLayer::s_joinResult = false; +HANDLE WinsockNetLayer::s_joinGameThread = nullptr; +char WinsockNetLayer::s_joinIP[256] = {}; +int WinsockNetLayer::s_joinPort = 0; + bool g_Win64MultiplayerHost = false; bool g_Win64MultiplayerJoin = false; int g_Win64MultiplayerPort = WIN64_NET_DEFAULT_PORT; @@ -337,10 +344,17 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) bool connected = false; BYTE assignedSmallId = 0; - const int maxAttempts = 12; + const int maxAttempts = 3; + const int connectTimeoutSec = 5; for (int attempt = 0; attempt < maxAttempts; ++attempt) { + if (s_joinCancelled) + { + app.DebugPrintf("JoinGame cancelled by user\n"); + break; + } + s_hostConnectionSocket = socket(result->ai_family, result->ai_socktype, result->ai_protocol); if (s_hostConnectionSocket == INVALID_SOCKET) { @@ -351,17 +365,55 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) int noDelay = 1; setsockopt(s_hostConnectionSocket, IPPROTO_TCP, TCP_NODELAY, (const char*)&noDelay, sizeof(noDelay)); + // Use non-blocking connect with select() timeout so we don't freeze + // the game for the full OS TCP timeout when the server is unreachable. + u_long nonBlocking = 1; + ioctlsocket(s_hostConnectionSocket, FIONBIO, &nonBlocking); + iResult = connect(s_hostConnectionSocket, result->ai_addr, static_cast(result->ai_addrlen)); if (iResult == SOCKET_ERROR) { int err = WSAGetLastError(); - app.DebugPrintf("connect() to %s:%d failed (attempt %d/%d): %d\n", ip, port, attempt + 1, maxAttempts, err); - closesocket(s_hostConnectionSocket); - s_hostConnectionSocket = INVALID_SOCKET; - Sleep(200); - continue; + if (err == WSAEWOULDBLOCK) + { + fd_set writeSet, errorSet; + FD_ZERO(&writeSet); + FD_SET(s_hostConnectionSocket, &writeSet); + FD_ZERO(&errorSet); + FD_SET(s_hostConnectionSocket, &errorSet); + + struct timeval tv; + tv.tv_sec = connectTimeoutSec; + tv.tv_usec = 0; + + int selectResult = select(0, nullptr, &writeSet, &errorSet, &tv); + if (selectResult <= 0 || FD_ISSET(s_hostConnectionSocket, &errorSet)) + { + app.DebugPrintf("connect() to %s:%d timed out or failed (attempt %d/%d)\n", ip, port, attempt + 1, maxAttempts); + closesocket(s_hostConnectionSocket); + s_hostConnectionSocket = INVALID_SOCKET; + continue; + } + // Connection succeeded via non-blocking path + } + else + { + app.DebugPrintf("connect() to %s:%d failed (attempt %d/%d): %d\n", ip, port, attempt + 1, maxAttempts, err); + closesocket(s_hostConnectionSocket); + s_hostConnectionSocket = INVALID_SOCKET; + Sleep(200); + continue; + } } + // Restore blocking mode for normal socket I/O + u_long blocking = 0; + ioctlsocket(s_hostConnectionSocket, FIONBIO, &blocking); + + // Set a recv timeout so we don't block forever waiting for the small ID + DWORD recvTimeout = connectTimeoutSec * 1000; + setsockopt(s_hostConnectionSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&recvTimeout, sizeof(recvTimeout)); + BYTE assignBuf[1]; int bytesRecv = recv(s_hostConnectionSocket, (char*)assignBuf, 1, 0); if (bytesRecv != 1) @@ -369,7 +421,6 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) app.DebugPrintf("Failed to receive small ID assignment from host (attempt %d/%d)\n", attempt + 1, maxAttempts); closesocket(s_hostConnectionSocket); s_hostConnectionSocket = INVALID_SOCKET; - Sleep(200); continue; } @@ -421,6 +472,56 @@ bool WinsockNetLayer::JoinGame(const char* ip, int port) return true; } +DWORD WINAPI WinsockNetLayer::JoinGameThreadProc(LPVOID param) +{ + s_joinResult = JoinGame(s_joinIP, s_joinPort); + s_joinComplete = true; + return 0; +} + +bool WinsockNetLayer::StartJoinGameAsync(const char* ip, int port) +{ + // Wait for any previous join thread to finish + if (s_joinGameThread != nullptr) + { + WaitForSingleObject(s_joinGameThread, 5000); + CloseHandle(s_joinGameThread); + s_joinGameThread = nullptr; + } + + strncpy_s(s_joinIP, sizeof(s_joinIP), ip, _TRUNCATE); + s_joinPort = port; + s_joinCancelled = false; + s_joinComplete = false; + s_joinResult = false; + + s_joinGameThread = CreateThread(nullptr, 0, JoinGameThreadProc, nullptr, 0, nullptr); + return s_joinGameThread != nullptr; +} + +bool WinsockNetLayer::IsJoinComplete() +{ + return s_joinComplete; +} + +bool WinsockNetLayer::GetJoinResult() +{ + return s_joinResult; +} + +void WinsockNetLayer::CancelJoinGame() +{ + s_joinCancelled = true; + + // Close the socket to immediately unblock any in-progress connect/select/recv + SOCKET sock = s_hostConnectionSocket; + if (sock != INVALID_SOCKET) + { + s_hostConnectionSocket = INVALID_SOCKET; + closesocket(sock); + } +} + bool WinsockNetLayer::SendOnSocket(SOCKET sock, const void* data, int dataSize) { if (sock == INVALID_SOCKET || dataSize <= 0 || dataSize > WIN64_NET_MAX_PACKET_SIZE) return false; diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h index afccbd66..9bf67cfb 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h @@ -69,6 +69,12 @@ public: static bool HostGame(int port, const char* bindIp = nullptr); static bool JoinGame(const char* ip, int port); + // Async join: runs JoinGame on a background thread so the UI stays responsive + static bool StartJoinGameAsync(const char* ip, int port); + static bool IsJoinComplete(); + static bool GetJoinResult(); + static void CancelJoinGame(); + static bool SendToSmallId(BYTE targetSmallId, const void* data, int dataSize); static bool SendOnSocket(SOCKET sock, const void* data, int dataSize); @@ -112,6 +118,7 @@ private: static DWORD WINAPI SplitScreenRecvThreadProc(LPVOID param); static DWORD WINAPI AdvertiseThreadProc(LPVOID param); static DWORD WINAPI DiscoveryThreadProc(LPVOID param); + static DWORD WINAPI JoinGameThreadProc(LPVOID param); static SOCKET s_listenSocket; static SOCKET s_hostConnectionSocket; @@ -154,6 +161,14 @@ private: static SOCKET s_smallIdToSocket[256]; static CRITICAL_SECTION s_smallIdToSocketLock; + // Async join state + static volatile bool s_joinCancelled; + static volatile bool s_joinComplete; + static bool s_joinResult; + static HANDLE s_joinGameThread; + static char s_joinIP[256]; + static int s_joinPort; + // Per-pad split-screen TCP connections (client-side, non-host only) static SOCKET s_splitScreenSocket[XUSER_MAX_COUNT]; static BYTE s_splitScreenSmallId[XUSER_MAX_COUNT]; diff --git a/README.md b/README.md index 76cc5ce0..89ca47f7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,13 @@ This project is based on source code of Minecraft Legacy Console Edition v1.6.05 ## Latest: +Server list and connection improvements: +- Server edits and deletions now apply immediately without needing to restart the game +- Connecting to an offline/unreachable server no longer freezes the game indefinitely +- Connection attempts use non-blocking sockets with a 5-second timeout (3 retries max) instead of the OS TCP timeout +- Connection runs on a background thread so the UI stays responsive, with a cancel option (press B or Escape) to back out at any time +- Failed connections now always show a "Connection Failed" dialog instead of silently navigating back + Upstream merge: - Fixed font rendering for color and formatting codes, splash text like "Colormatic!" now renders with proper per-character colors - Fixed Sign editing UI, SignEntryMenu720 restored to correct version