From de125e5275da4268b82ab95cad1561d6ebfbf6bb Mon Sep 17 00:00:00 2001 From: veroxsity Date: Fri, 17 Apr 2026 23:47:32 +0100 Subject: [PATCH] Add TCP-over-WebSocket relay client for Minecraft - Implemented Win64LceLiveRelay.h and Win64LceLiveSignaling.cpp to facilitate TCP-over-WebSocket communication for Minecraft, allowing game traffic to route through the LCELive relay server when direct TCP is blocked. - Introduced signaling mechanisms for host and joiner connections, including session management and candidate exchange. - Added logging functionality in Windows_Log.cpp and Windows_Log.h for better debugging and session tracking. - Created build-release.bat script for streamlined build and deployment process, including exclusion of unnecessary files. --- .../Network/PlatformNetworkManagerStub.cpp | 7 + .../Common/UI/UIScene_DebugOverlay.cpp | 2 +- .../Common/UI/UIScene_LceLiveInvites.cpp | 318 +++++- .../Common/UI/UIScene_LceLiveInvites.h | 17 + .../Common/UI/UIScene_LceLiveRequests.cpp | 169 +-- .../Common/UI/UIScene_LceLiveRequests.h | 23 +- .../Common/UI/UIScene_LoadOrJoinMenu.cpp | 2 +- .../Windows64/Network/WinsockNetLayer.cpp | 23 + .../Windows64/Network/WinsockNetLayer.h | 1 + .../Windows64/Windows64_LceLive.cpp | 28 +- .../Windows64/Windows64_LceLive.h | 4 +- .../Windows64/Windows64_LceLiveP2P.cpp | 909 +++++++++++++++++ .../Windows64/Windows64_LceLiveP2P.h | 65 ++ .../Windows64/Windows64_LceLiveRelay.cpp | 962 ++++++++++++++++++ .../Windows64/Windows64_LceLiveRelay.h | 72 ++ .../Windows64/Windows64_LceLiveSignaling.cpp | 724 +++++++++++++ .../Windows64/Windows64_LceLiveSignaling.h | 75 ++ Minecraft.Client/Windows64/Windows64_Log.cpp | 97 ++ Minecraft.Client/Windows64/Windows64_Log.h | 37 + .../Windows64/Windows64_Minecraft.cpp | 101 ++ Minecraft.Client/cmake/sources/Windows.cmake | 8 + build-release-exclude.txt | 4 + build-release.bat | 42 + 23 files changed, 3519 insertions(+), 171 deletions(-) create mode 100644 Minecraft.Client/Windows64/Windows64_LceLiveP2P.cpp create mode 100644 Minecraft.Client/Windows64/Windows64_LceLiveP2P.h create mode 100644 Minecraft.Client/Windows64/Windows64_LceLiveRelay.cpp create mode 100644 Minecraft.Client/Windows64/Windows64_LceLiveRelay.h create mode 100644 Minecraft.Client/Windows64/Windows64_LceLiveSignaling.cpp create mode 100644 Minecraft.Client/Windows64/Windows64_LceLiveSignaling.h create mode 100644 Minecraft.Client/Windows64/Windows64_Log.cpp create mode 100644 Minecraft.Client/Windows64/Windows64_Log.h create mode 100644 build-release-exclude.txt create mode 100644 build-release.bat diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp index ec63bd06..dadad842 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp @@ -7,6 +7,8 @@ #include "../../Windows64/Network/WinsockNetLayer.h" #include "../../Windows64/Windows64_Xuid.h" #include "../../Windows64/Windows64_LceLive.h" +#include "../../Windows64/Windows64_LceLiveP2P.h" +#include "../../Windows64/Windows64_LceLiveSignaling.h" #include "../../Minecraft.h" #include "../../User.h" #include "../../MinecraftServer.h" @@ -445,6 +447,8 @@ bool CPlatformNetworkManagerStub::LeaveGame(bool bMigrateHost) SystemFlagReset(); #ifdef _WINDOWS64 + Win64LceLiveSignaling::Close(); // close signaling WebSocket before P2P teardown + Win64LceLiveP2P::HostClose(); // tear down P2P socket + remove UPnP mapping WinsockNetLayer::Shutdown(); WinsockNetLayer::Initialize(); #endif @@ -499,6 +503,9 @@ void CPlatformNetworkManagerStub::HostGame(int localUsersMask, bool bOnlineGame, if (WinsockNetLayer::IsActive()) { + // Start P2P discovery (UPnP IGD → STUN fallback) so we have an + // external endpoint ready before the first joiner arrives. + Win64LceLiveP2P::HostOpen(); // For Dedicated Server, refer to `lan-advertise` in `server.properties` bool enableLanAdvertising = true; if (g_Win64DedicatedServer) diff --git a/Minecraft.Client/Common/UI/UIScene_DebugOverlay.cpp b/Minecraft.Client/Common/UI/UIScene_DebugOverlay.cpp index eb0871af..f8c9f513 100644 --- a/Minecraft.Client/Common/UI/UIScene_DebugOverlay.cpp +++ b/Minecraft.Client/Common/UI/UIScene_DebugOverlay.cpp @@ -48,7 +48,7 @@ UIScene_DebugOverlay::UIScene_DebugOverlay(int iPad, void *initData, UILayer *pa { if (Item::items[i] != nullptr) { - sortedItems.emplace_back(std::wstring(app.GetString(Item::items[i]->getDescriptionId())), i); + sortedItems.emplace_back(std::wstring(app.GetString(Item::items[i]->getDescriptionId())), static_cast(i)); } } diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.cpp b/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.cpp index 9cb0874f..a7c3c0ca 100644 --- a/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.cpp @@ -3,6 +3,11 @@ #include "UIScene_LceLiveInvites.h" #include "../../Minecraft.h" #include "../../Windows64/Network/WinsockNetLayer.h" +#ifdef _WINDOWS64 +#include "../../Windows64/Windows64_LceLiveP2P.h" +#include "../../Windows64/Windows64_LceLiveSignaling.h" +#include "../../Windows64/Windows64_LceLiveRelay.h" +#endif // Fallbacks until string ID headers are regenerated. #ifndef IDS_TITLE_SEND_INVITE @@ -88,6 +93,12 @@ UIScene_LceLiveInvites::UIScene_LceLiveInvites(int iPad, void *initData, UILayer m_invitedAccountIds.clear(); m_pendingInviteAccountId.clear(); m_pendingInviteLabel.clear(); + m_gameInvites.clear(); + m_pendingAcceptInviteId.clear(); + m_pendingAcceptHostIp.clear(); + m_pendingAcceptHostPort = 0; + m_pendingAcceptHostName.clear(); + m_pendingAcceptSignalingSessionId.clear(); #endif doHorizontalResizeCheck(); @@ -162,14 +173,24 @@ void UIScene_LceLiveInvites::handleInput(int iPad, int key, bool repeat, bool pr if (controlHasFocus(eControl_ActionsList)) PerformSelectedAction(); else if (controlHasFocus(eControl_FriendsList)) - PromptInviteSelectedFriend(); + { +#ifdef _WINDOWS64 + if (IsReceiveMode()) + PromptAcceptSelectedInvite(); + else + PromptInviteSelectedFriend(); +#endif + } } break; case ACTION_MENU_X: if (pressed && !repeat) { #ifdef _WINDOWS64 - PromptInviteSelectedFriend(); + if (IsReceiveMode()) + PromptAcceptSelectedInvite(); + else + PromptInviteSelectedFriend(); #endif handled = true; } @@ -197,7 +218,12 @@ void UIScene_LceLiveInvites::handlePress(F64 controlId, F64 childId) if (static_cast(controlId) == eControl_FriendsList) { m_friendsList.updateChildFocus(static_cast(childId)); - PromptInviteSelectedFriend(); +#ifdef _WINDOWS64 + if (IsReceiveMode()) + PromptAcceptSelectedInvite(); + else + PromptInviteSelectedFriend(); +#endif } else if (static_cast(controlId) == eControl_ActionsList) { @@ -214,12 +240,51 @@ void UIScene_LceLiveInvites::FetchAndDisplay() { m_friends.clear(); m_invitedAccountIds.clear(); + m_gameInvites.clear(); m_bDataReady = true; - m_statusMessage = L"Sign in to LCELIVE to invite friends."; + m_statusMessage = IsReceiveMode() + ? L"Sign in to LCELive to view game invites." + : L"Sign in to LCELive to invite friends."; RebuildLists(); return; } + // ── Receive mode: show incoming game invites ────────────────────────── + if (IsReceiveMode()) + { + const int previousSelection = m_friendsList.getCurrentSelection(); + const Win64LceLive::GameInvitesResult result = Win64LceLive::GetGameInvitesSync(); + if (!result.success) + { + m_gameInvites.clear(); + m_bDataReady = true; + m_statusMessage = Utf8ToWideLocal(result.error); + RebuildLists(); + return; + } + + m_gameInvites = result.incoming; + m_bDataReady = true; + + if (m_gameInvites.empty()) + m_statusMessage = L"No pending game invites."; + else if (m_statusMessage == L"No pending game invites.") + m_statusMessage.clear(); + + RebuildLists(); + + if (!m_gameInvites.empty()) + { + int sel = previousSelection; + if (sel < 0) sel = 0; + if (sel >= static_cast(m_gameInvites.size())) + sel = static_cast(m_gameInvites.size()) - 1; + m_friendsList.setCurrentSelection(sel); + } + return; + } + + // ── Send mode: show friends list for inviting ───────────────────────── const int previousSelection = m_friendsList.getCurrentSelection(); const Win64LceLive::FriendsListResult result = Win64LceLive::GetFriendsSync(); if (!result.success) @@ -270,12 +335,43 @@ void UIScene_LceLiveInvites::RebuildLists() m_friendsList.clearList(); #ifdef _WINDOWS64 - for (const Win64LceLive::SocialEntry &entry : m_friends) + if (IsReceiveMode()) { - std::wstring label = BuildFriendLabel(entry); - if (AlreadyInvited(entry.accountId)) - label += L" [SENT]"; - m_friendsList.addItem(label); + for (const Win64LceLive::GameInviteEntry &entry : m_gameInvites) + { + std::wstring label; + if (!entry.senderDisplayName.empty()) + label = Utf8ToWideLocal(entry.senderDisplayName); + else if (!entry.senderUsername.empty()) + label = Utf8ToWideLocal(entry.senderUsername); + else + label = L""; + + if (!entry.senderUsername.empty()) + { + label += L" (@"; + label += Utf8ToWideLocal(entry.senderUsername); + label += L")"; + } + if (!entry.hostName.empty()) + { + label += L" - "; + label += Utf8ToWideLocal(entry.hostName); + } + if (!entry.sessionActive) + label += L" [inactive]"; + m_friendsList.addItem(label); + } + } + else + { + for (const Win64LceLive::SocialEntry &entry : m_friends) + { + std::wstring label = BuildFriendLabel(entry); + if (AlreadyInvited(entry.accountId)) + label += L" [SENT]"; + m_friendsList.addItem(label); + } } #endif @@ -294,7 +390,7 @@ void UIScene_LceLiveInvites::UpdateStatusLabel() m_labelStatus.setVisible(true); } - m_labelFriendsTitle.setLabel(L"INVITE FRIENDS", true, true); + m_labelFriendsTitle.setLabel(IsReceiveMode() ? L"GAME INVITES" : L"INVITE FRIENDS", true, true); m_labelActionsTitle.setLabel(L"ACTIONS", true, true); } @@ -368,7 +464,7 @@ void UIScene_LceLiveInvites::InvitePendingFriend() if (m_pendingInviteAccountId.empty()) return; - if (!g_NetworkManager.IsHost() || !WinsockNetLayer::IsHosting() || g_Win64MultiplayerIP[0] == 0 || g_Win64MultiplayerPort <= 0) + if (!g_NetworkManager.IsHost() || !WinsockNetLayer::IsHosting() || WinsockNetLayer::GetHostPort() <= 0) { m_statusMessage = L"The game session is no longer active."; m_pendingInviteAccountId.clear(); @@ -377,11 +473,26 @@ void UIScene_LceLiveInvites::InvitePendingFriend() return; } + // Pick the right host IP for joiners: + // tcpPortMapped=true → UPnP mapped the TCP game port on the router, + // so internet joiners can reach externalIp:port directly. + // tcpPortMapped=false → no TCP port mapping; use the LAN IP so same-network + // testing works. Internet play without UPnP needs KCP. + const Win64LceLiveP2P::P2PSnapshot snap = Win64LceLiveP2P::GetP2PSnapshot(); + const std::string hostIp = (snap.tcpPortMapped && !snap.externalIp.empty()) + ? snap.externalIp + : WinsockNetLayer::GetLocalIPv4(); + const int hostPort = WinsockNetLayer::GetHostPort(); + + // Include the P2P signaling session ID so the joiner can do hole punching. + const std::string signalingSessionId = Win64LceLiveSignaling::GetSnapshot().sessionId; + const Win64LceLive::SocialActionResult result = Win64LceLive::SendGameInviteSync( m_pendingInviteAccountId, - g_Win64MultiplayerIP, - g_Win64MultiplayerPort, - ""); + hostIp, + hostPort, + "", + signalingSessionId); if (!result.success) { m_statusMessage = Utf8ToWideLocal(result.error); @@ -421,4 +532,183 @@ int UIScene_LceLiveInvites::InviteFriendConfirmCallback(void *pParam, int iPad, return 0; } + +// ── Receive mode ────────────────────────────────────────────────────────── + +bool UIScene_LceLiveInvites::IsReceiveMode() const +{ + return Minecraft::GetInstance()->level == nullptr; +} + +void UIScene_LceLiveInvites::PromptAcceptSelectedInvite() +{ + const int idx = FocusedFriendIndex(); + if (idx < 0 || idx >= static_cast(m_gameInvites.size())) + { + m_statusMessage = L"Select an invite first."; + UpdateStatusLabel(); + return; + } + + m_pendingAcceptInviteId = m_gameInvites[idx].inviteId; + + UINT optionIds[2]; + optionIds[0] = IDS_NO; + optionIds[1] = IDS_YES; + + ui.RequestAlertMessage( + IDS_TITLE_SEND_INVITE, + IDS_TEXT_SEND_INVITE_CONFIRMATION, + optionIds, + 2, + m_iPad, + &UIScene_LceLiveInvites::AcceptInviteConfirmCallback, + this); +} + +void UIScene_LceLiveInvites::ResolvePendingInvite(bool accept) +{ + if (m_pendingAcceptInviteId.empty()) + return; + + const std::string inviteId = m_pendingAcceptInviteId; + + if (accept) + { + const Win64LceLive::GameInviteActionResult result = Win64LceLive::AcceptGameInviteSync(inviteId); + if (result.success) + { + m_pendingAcceptHostIp = result.hostIp; + m_pendingAcceptHostPort = result.hostPort; + m_pendingAcceptHostName = result.hostName; + m_pendingAcceptSignalingSessionId = result.signalingSessionId; + + if (!result.signalingSessionId.empty()) + { + // Always open the joiner relay — the host always has one ready. + // Relay goes over WSS (port 443) so it works on any network including + // campus WiFi, hotel WiFi, or any firewall that blocks port 25565. + const int relayProxyPort = + Win64LceLiveRelay::JoinerOpen(result.signalingSessionId); + + // Decide which address to give the game: + // Public host IP → try direct TCP first (UPnP may have mapped the port). + // If the local network blocks port 25565, this will + // fail and the relay is already waiting as a fallback. + // Private host IP → direct TCP can't work from the internet; use relay. + const std::string& hIp = result.hostIp; + const bool hostIsPublicIp = + !hIp.empty() && + hIp.substr(0, 8) != "192.168." && + hIp.substr(0, 3) != "10." && + hIp.substr(0, 7) != "172.16." && + hIp != "127.0.0.1"; + + if (!hostIsPublicIp && relayProxyPort > 0) + { + // No public IP — relay is the only path. + m_pendingAcceptHostIp = "127.0.0.1"; + m_pendingAcceptHostPort = relayProxyPort; + } + else if (relayProxyPort > 0) + { + // Public IP → try direct TCP first. Stash the relay proxy port so + // the main Tick auto-retries through the relay if the direct attempt + // fails (e.g. campus / hotel WiFi blocks port 25565 outbound). + // This is Xbox Live's TURN fallback pattern: relay is pre-allocated + // on both sides before any direct connection is even attempted. + g_LceLiveRelayFallbackPort = relayProxyPort; + } + + // Set up the P2P/signaling path for hole-punching (best-effort). + Win64LceLiveP2P::HostOpen(); + Win64LceLiveSignaling::PrepareJoin(result.signalingSessionId); + } + + JoinAcceptedInvite(); + m_pendingAcceptInviteId.clear(); + return; + } + + m_statusMessage = Utf8ToWideLocal(result.error); + UpdateStatusLabel(); + FetchAndDisplay(); + m_pendingAcceptInviteId.clear(); + return; + } + + const Win64LceLive::SocialActionResult declineResult = Win64LceLive::DeclineGameInviteSync(inviteId); + m_statusMessage = declineResult.success ? L"Game invite declined." : Utf8ToWideLocal(declineResult.error); + m_pendingAcceptInviteId.clear(); + + FetchAndDisplay(); +} + +void UIScene_LceLiveInvites::JoinAcceptedInvite() +{ + if (m_pendingAcceptHostIp.empty() || m_pendingAcceptHostPort <= 0) + { + m_statusMessage = L"The game session is no longer active."; + UpdateStatusLabel(); + FetchAndDisplay(); + return; + } + + ProfileManager.SetLockedProfile(m_iPad); + ProfileManager.SetPrimaryPad(m_iPad); + g_NetworkManager.SetLocalGame(false); + ProfileManager.QuerySigninStatus(); + Minecraft::GetInstance()->clearConnectionFailed(); + + int localUsersMask = 0; + for (unsigned int index = 0; index < XUSER_MAX_COUNT; ++index) + { + if (ProfileManager.IsSignedIn(index)) + localUsersMask |= g_NetworkManager.GetLocalPlayerMask(index); + } + + INVITE_INFO inviteInfo = {}; + inviteInfo.netVersion = MINECRAFT_NET_VERSION; + strcpy_s(inviteInfo.hostIP, m_pendingAcceptHostIp.c_str()); + inviteInfo.hostPort = m_pendingAcceptHostPort; + inviteInfo.sessionActive = true; + const std::wstring hostNameWide = m_pendingAcceptHostName.empty() + ? L"LCELive" : Utf8ToWideLocal(m_pendingAcceptHostName); + wcsncpy_s(inviteInfo.hostName, hostNameWide.c_str(), _TRUNCATE); + strcpy_s(inviteInfo.inviteId, m_pendingAcceptInviteId.c_str()); + + const bool success = g_NetworkManager.JoinGameFromInviteInfo(m_iPad, localUsersMask, &inviteInfo); + if (!success) + m_statusMessage = L"Could not join this game."; + else + m_statusMessage = L"Joining game..."; + + m_pendingAcceptInviteId.clear(); + m_pendingAcceptHostIp.clear(); + m_pendingAcceptHostPort = 0; + m_pendingAcceptHostName.clear(); + m_pendingAcceptSignalingSessionId.clear(); + UpdateStatusLabel(); + FetchAndDisplay(); +} + +int UIScene_LceLiveInvites::AcceptInviteConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result) +{ + UIScene_LceLiveInvites *scene = static_cast(pParam); + if (scene == nullptr) + return 0; + + (void)iPad; + + // [NO, YES] — "Decline" is the 2nd option (YES). + if (result == C4JStorage::EMessage_ResultDecline) + scene->ResolvePendingInvite(true); + else + { + scene->m_pendingAcceptInviteId.clear(); + scene->FetchAndDisplay(); + } + + return 0; +} #endif diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.h b/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.h index 28b07bad..3a05c2b2 100644 --- a/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.h +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveInvites.h @@ -46,10 +46,19 @@ private: UI_END_MAP_ELEMENTS_AND_NAMES() #if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD) + // Send-invite mode (in-game, hosting) std::vector m_friends; std::vector m_invitedAccountIds; std::string m_pendingInviteAccountId; std::wstring m_pendingInviteLabel; + + // Receive-invite mode (main menu, not in game) + std::vector m_gameInvites; + std::string m_pendingAcceptInviteId; + std::string m_pendingAcceptHostIp; + int m_pendingAcceptHostPort; + std::string m_pendingAcceptHostName; + std::string m_pendingAcceptSignalingSessionId; #endif std::wstring m_statusMessage; @@ -75,6 +84,7 @@ protected: void handlePress(F64 controlId, F64 childId); private: + bool IsReceiveMode() const; // true when opened from main menu (no active game) void FetchAndDisplay(); void RebuildLists(); void UpdateStatusLabel(); @@ -83,10 +93,17 @@ private: void PerformSelectedAction(); #ifdef _WINDOWS64 + // Send mode void PromptInviteSelectedFriend(); void PromptInviteFriendAtIndex(int friendIndex); void InvitePendingFriend(); bool AlreadyInvited(const std::string &accountId) const; static int InviteFriendConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result); + + // Receive mode + void PromptAcceptSelectedInvite(); + void ResolvePendingInvite(bool accept); + void JoinAcceptedInvite(); + static int AcceptInviteConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result); #endif }; diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.cpp b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.cpp index 1934d37b..d072a01c 100644 --- a/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.cpp @@ -2,6 +2,10 @@ #include "UI.h" #include "UIScene_LceLiveRequests.h" #include "../../Minecraft.h" +#ifdef _WINDOWS64 +#include "../../Windows64/Windows64_LceLiveP2P.h" +#include "../../Windows64/Windows64_LceLiveSignaling.h" +#endif // Fallbacks until string ID headers are regenerated. #ifndef IDS_TITLE_FRIEND_REQUEST @@ -36,37 +40,22 @@ namespace return result; } - std::wstring BuildInviteLabel( - const std::string &senderDisplayName, - const std::string &senderUsername, - const std::string &hostName, - bool sessionActive) + std::wstring BuildRequestLabel(const std::string &displayName, const std::string &username) { - std::wstring line = L"[INVITE] "; - - if (!senderDisplayName.empty()) - line += Utf8ToWideLocal(senderDisplayName); - else if (!senderUsername.empty()) - line += Utf8ToWideLocal(senderUsername); + std::wstring line; + if (!displayName.empty()) + line = Utf8ToWideLocal(displayName); + else if (!username.empty()) + line = Utf8ToWideLocal(username); else - line += L""; + line = L""; - if (!senderUsername.empty()) + if (!username.empty()) { line += L" (@"; - line += Utf8ToWideLocal(senderUsername); + line += Utf8ToWideLocal(username); line += L")"; } - - if (!hostName.empty()) - { - line += L" - "; - line += Utf8ToWideLocal(hostName); - } - - if (!sessionActive) - line += L" [inactive]"; - return line; } } @@ -81,7 +70,7 @@ UIScene_LceLiveRequests::UIScene_LceLiveRequests(int iPad, void *initData, UILay m_requestsList.init(eControl_RequestsList); m_actionsList.init(eControl_ActionsList); - m_labelRequestsTitle.init(L"GAME INVITES"); + m_labelRequestsTitle.init(L"FRIEND REQUESTS"); m_labelActionsTitle.init(L"ACTIONS"); m_labelStatus.init(L""); m_controlRequestsTimer.setVisible(false); @@ -94,10 +83,7 @@ UIScene_LceLiveRequests::UIScene_LceLiveRequests(int iPad, void *initData, UILay m_statusMessage.clear(); m_bDataReady = false; #ifdef _WINDOWS64 - m_pendingInviteId.clear(); - m_pendingInviteHostIp.clear(); - m_pendingInviteHostPort = 0; - m_pendingInviteHostName.clear(); + m_pendingFromAccountId.clear(); #endif doHorizontalResizeCheck(); @@ -220,13 +206,13 @@ void UIScene_LceLiveRequests::FetchAndDisplay() { m_entries.clear(); m_bDataReady = true; - m_statusMessage = L"Sign in to view and manage game invites."; + m_statusMessage = L"Sign in to view friend requests."; RebuildLists(); return; } const int previousSelection = m_requestsList.getCurrentSelection(); - const Win64LceLive::GameInvitesResult result = Win64LceLive::GetGameInvitesSync(); + const Win64LceLive::PendingRequestsResult result = Win64LceLive::GetPendingRequestsSync(); if (!result.success) { m_entries.clear(); @@ -237,28 +223,19 @@ void UIScene_LceLiveRequests::FetchAndDisplay() } m_entries.clear(); - for (const Win64LceLive::GameInviteEntry &entry : result.incoming) + for (const Win64LceLive::SocialEntry &entry : result.incoming) { RequestEntry row = {}; - row.inviteId = entry.inviteId; - row.senderAccountId = entry.senderAccountId; - row.senderUsername = entry.senderUsername; - row.senderDisplayName = entry.senderDisplayName; - row.recipientAccountId = entry.recipientAccountId; - row.recipientUsername = entry.recipientUsername; - row.recipientDisplayName = entry.recipientDisplayName; - row.hostIp = entry.hostIp; - row.hostPort = entry.hostPort; - row.hostName = entry.hostName; - row.status = entry.status; - row.sessionActive = entry.sessionActive; + row.accountId = entry.accountId; + row.username = entry.username; + row.displayName = entry.displayName; m_entries.push_back(row); } m_bDataReady = true; if (m_entries.empty() && m_statusMessage.empty()) - m_statusMessage = L"No pending game invites."; - else if (!m_entries.empty() && m_statusMessage == L"No pending game invites.") + m_statusMessage = L"No pending friend requests."; + else if (!m_entries.empty() && m_statusMessage == L"No pending friend requests.") m_statusMessage.clear(); RebuildLists(); @@ -275,7 +252,7 @@ void UIScene_LceLiveRequests::FetchAndDisplay() #else m_entries.clear(); m_bDataReady = true; - m_statusMessage = L"Game invites are only available on Windows64 builds."; + m_statusMessage = L"Friend requests are only available on Windows64 builds."; RebuildLists(); #endif } @@ -286,7 +263,7 @@ void UIScene_LceLiveRequests::RebuildLists() #ifdef _WINDOWS64 for (const RequestEntry &entry : m_entries) - m_requestsList.addItem(BuildInviteLabel(entry.senderDisplayName, entry.senderUsername, entry.hostName, entry.sessionActive)); + m_requestsList.addItem(BuildRequestLabel(entry.displayName, entry.username)); #endif UpdateStatusLabel(); @@ -304,7 +281,7 @@ void UIScene_LceLiveRequests::UpdateStatusLabel() m_labelStatus.setVisible(true); } - m_labelRequestsTitle.setLabel(L"GAME INVITES", true, true); + m_labelRequestsTitle.setLabel(L"FRIEND REQUESTS", true, true); m_labelActionsTitle.setLabel(L"ACTIONS", true, true); } @@ -335,24 +312,21 @@ void UIScene_LceLiveRequests::PromptResolveSelectedRequest() const int selectedIndex = FocusedRequestIndex(); if (selectedIndex < 0 || selectedIndex >= static_cast(m_entries.size())) { - m_statusMessage = L"Select an invite first."; + m_statusMessage = L"Select a request first."; UpdateStatusLabel(); return; } #ifdef _WINDOWS64 - m_pendingInviteId = m_entries[selectedIndex].inviteId; - m_pendingInviteHostIp = m_entries[selectedIndex].hostIp; - m_pendingInviteHostPort = m_entries[selectedIndex].hostPort; - m_pendingInviteHostName = m_entries[selectedIndex].hostName; + m_pendingFromAccountId = m_entries[selectedIndex].accountId; UINT optionIds[2]; optionIds[0] = IDS_NO; optionIds[1] = IDS_YES; ui.RequestAlertMessage( - IDS_TITLE_SEND_INVITE, - IDS_TEXT_SEND_INVITE_CONFIRMATION, + IDS_TITLE_FRIEND_REQUEST, + IDS_TEXT_ACCEPT_REQUEST_CONFIRMATION, optionIds, 2, m_iPad, @@ -367,7 +341,7 @@ void UIScene_LceLiveRequests::PerformAccept() const int selectedIndex = FocusedRequestIndex(); if (selectedIndex < 0 || selectedIndex >= static_cast(m_entries.size())) { - m_statusMessage = L"Select an invite first."; + m_statusMessage = L"Select a request first."; UpdateStatusLabel(); return; } @@ -381,7 +355,7 @@ void UIScene_LceLiveRequests::PerformDecline() const int selectedIndex = FocusedRequestIndex(); if (selectedIndex < 0 || selectedIndex >= static_cast(m_entries.size())) { - m_statusMessage = L"Select an invite first."; + m_statusMessage = L"Select a request first."; UpdateStatusLabel(); return; } @@ -392,84 +366,29 @@ void UIScene_LceLiveRequests::PerformDecline() #ifdef _WINDOWS64 void UIScene_LceLiveRequests::ResolvePendingRequest(bool accept) { - if (m_pendingInviteId.empty()) + if (m_pendingFromAccountId.empty()) return; - const std::string inviteId = m_pendingInviteId; + const std::string fromAccountId = m_pendingFromAccountId; + m_pendingFromAccountId.clear(); if (accept) { - const Win64LceLive::GameInviteActionResult result = Win64LceLive::AcceptGameInviteSync(inviteId); + const Win64LceLive::SocialActionResult result = Win64LceLive::AcceptFriendRequestSync(fromAccountId); if (result.success) - { - m_pendingInviteHostIp = result.hostIp; - m_pendingInviteHostPort = result.hostPort; - m_pendingInviteHostName = result.hostName; - JoinAcceptedInvite(); - m_pendingInviteId.clear(); - return; - } - - m_statusMessage = Utf8ToWideLocal(result.error); - UpdateStatusLabel(); - FetchAndDisplay(); - m_pendingInviteId.clear(); - return; + m_statusMessage = L"Friend request accepted."; + else + m_statusMessage = Utf8ToWideLocal(result.error); } - - const Win64LceLive::SocialActionResult declineResult = Win64LceLive::DeclineGameInviteSync(inviteId); - if (declineResult.success) - m_statusMessage = L"Game invite declined."; else - m_statusMessage = Utf8ToWideLocal(declineResult.error); - m_pendingInviteId.clear(); - - FetchAndDisplay(); -} - -void UIScene_LceLiveRequests::JoinAcceptedInvite() -{ - if (m_pendingInviteHostIp.empty() || m_pendingInviteHostPort <= 0) { - m_statusMessage = L"The game session is no longer active."; - UpdateStatusLabel(); - FetchAndDisplay(); - return; + const Win64LceLive::SocialActionResult result = Win64LceLive::DeclineFriendRequestSync(fromAccountId); + if (result.success) + m_statusMessage = L"Friend request declined."; + else + m_statusMessage = Utf8ToWideLocal(result.error); } - ProfileManager.SetLockedProfile(m_iPad); - ProfileManager.SetPrimaryPad(m_iPad); - g_NetworkManager.SetLocalGame(false); - ProfileManager.QuerySigninStatus(); - Minecraft::GetInstance()->clearConnectionFailed(); - - int localUsersMask = 0; - for (unsigned int index = 0; index < XUSER_MAX_COUNT; ++index) - { - if (ProfileManager.IsSignedIn(index)) - localUsersMask |= g_NetworkManager.GetLocalPlayerMask(index); - } - - INVITE_INFO inviteInfo = {}; - inviteInfo.netVersion = MINECRAFT_NET_VERSION; - strcpy_s(inviteInfo.hostIP, m_pendingInviteHostIp.c_str()); - inviteInfo.hostPort = m_pendingInviteHostPort; - inviteInfo.sessionActive = true; - const std::wstring hostNameWide = m_pendingInviteHostName.empty() ? L"LCELive" : Utf8ToWideLocal(m_pendingInviteHostName); - wcsncpy_s(inviteInfo.hostName, hostNameWide.c_str(), _TRUNCATE); - strcpy_s(inviteInfo.inviteId, m_pendingInviteId.c_str()); - - const bool success = g_NetworkManager.JoinGameFromInviteInfo(m_iPad, localUsersMask, &inviteInfo); - if (!success) - m_statusMessage = L"Could not join this game."; - else - m_statusMessage = L"Joining game..."; - - m_pendingInviteId.clear(); - m_pendingInviteHostIp.clear(); - m_pendingInviteHostPort = 0; - m_pendingInviteHostName.clear(); - UpdateStatusLabel(); FetchAndDisplay(); } diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h index 169481cb..22aee3d8 100644 --- a/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h @@ -9,7 +9,7 @@ #include "../../Windows64/Windows64_LceLive.h" #endif -// LceLive sub-scene: game invite inbox. +// LceLive sub-scene: incoming friend-request inbox. // Uses the native Start Game style (LoadOrJoinMenu): left list of requests, // right list of actions. @@ -29,18 +29,9 @@ private: struct RequestEntry { - std::string inviteId; - std::string senderAccountId; - std::string senderUsername; - std::string senderDisplayName; - std::string recipientAccountId; - std::string recipientUsername; - std::string recipientDisplayName; - std::string hostIp; - int hostPort; - std::string hostName; - std::string status; - bool sessionActive; + std::string accountId; // sender's account ID + std::string username; + std::string displayName; }; UIControl_ButtonList m_requestsList; @@ -54,10 +45,7 @@ private: std::wstring m_statusMessage; bool m_bDataReady; #ifdef _WINDOWS64 - std::string m_pendingInviteId; - std::string m_pendingInviteHostIp; - int m_pendingInviteHostPort; - std::string m_pendingInviteHostName; + std::string m_pendingFromAccountId; #endif UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene) UI_MAP_ELEMENT(m_requestsList, "SavesList") @@ -100,7 +88,6 @@ private: void PerformDecline(); #ifdef _WINDOWS64 void ResolvePendingRequest(bool accept); - void JoinAcceptedInvite(); static int ResolveRequestConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result); #endif }; diff --git a/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp b/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp index c68ffc70..c65ec309 100644 --- a/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LoadOrJoinMenu.cpp @@ -1273,7 +1273,7 @@ void UIScene_LoadOrJoinMenu::handleInput(int iPad, int key, bool repeat, bool pr #elif defined(_WINDOWS64) if(pressed && !repeat && iPad == ProfileManager.GetPrimaryPad()) { - ui.NavigateToScene(m_iPad, eUIScene_LceLiveRequests); + ui.NavigateToScene(m_iPad, eUIScene_LceLiveInvites); handled = true; } #elif defined(_DURANGO) diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp index bbf993cc..22877ed6 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.cpp @@ -1583,4 +1583,27 @@ DisconnectPacket::eDisconnectReason WinsockNetLayer::GetJoinRejectReason() return s_joinRejectReason; } +std::string WinsockNetLayer::GetLocalIPv4() +{ + char hostname[256] = {}; + if (gethostname(hostname, sizeof(hostname)) != 0) + return "127.0.0.1"; + + struct addrinfo hints = {}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo* result = nullptr; + if (getaddrinfo(hostname, nullptr, &hints, &result) != 0 || result == nullptr) + return "127.0.0.1"; + + char ipBuf[INET_ADDRSTRLEN] = {}; + const struct sockaddr_in* sin = reinterpret_cast(result->ai_addr); + inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf)); + freeaddrinfo(result); + + // If the resolved address is still loopback, return it anyway — caller will handle. + return std::string(ipBuf); +} + #endif diff --git a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h index 5ecd8acf..d23c1e12 100644 --- a/Minecraft.Client/Windows64/Network/WinsockNetLayer.h +++ b/Minecraft.Client/Windows64/Network/WinsockNetLayer.h @@ -123,6 +123,7 @@ public: static std::vector GetDiscoveredSessions(); static int GetHostPort() { return s_hostGamePort; } + static std::string GetLocalIPv4(); private: static DWORD WINAPI AcceptThreadProc(LPVOID param); diff --git a/Minecraft.Client/Windows64/Windows64_LceLive.cpp b/Minecraft.Client/Windows64/Windows64_LceLive.cpp index a832e54b..183c8d1a 100644 --- a/Minecraft.Client/Windows64/Windows64_LceLive.cpp +++ b/Minecraft.Client/Windows64/Windows64_LceLive.cpp @@ -3,6 +3,7 @@ #ifdef _WINDOWS64 #include "Windows64_LceLive.h" +#include "Windows64_Log.h" #include "Windows64_Xuid.h" #include "../../Minecraft.World/StringHelpers.h" @@ -386,12 +387,12 @@ namespace std::vector encrypted; if (!ProtectString(sessionJson.dump(), &encrypted)) { - app.DebugPrintf("LCELive: failed to protect auth blob for local storage\n"); + LCELOG("LCE", "failed to protect auth blob for local storage"); return; } if (!WriteFileBytes(GetAuthBlobPath(), encrypted.data(), encrypted.size())) - app.DebugPrintf("LCELive: failed to write auth blob to disk\n"); + LCELOG("LCE", "failed to write auth blob to disk"); } void ClearSessionLocked() @@ -411,7 +412,7 @@ namespace std::string decrypted; if (!UnprotectBytes(encrypted, &decrypted)) { - app.DebugPrintf("LCELive: unable to decrypt stored auth state, clearing local blob\n"); + LCELOG("LCE", "unable to decrypt stored auth state, clearing local blob"); DeleteFileA(GetAuthBlobPath()); return; } @@ -419,7 +420,7 @@ namespace const Json sessionJson = Json::parse(decrypted, nullptr, false); if (!sessionJson.is_object()) { - app.DebugPrintf("LCELive: stored auth state is invalid JSON, clearing local blob\n"); + LCELOG("LCE", "stored auth state is invalid JSON, clearing local blob"); DeleteFileA(GetAuthBlobPath()); return; } @@ -472,7 +473,7 @@ namespace std::vector pathBuffer; if (!CrackUrl(baseUrl, &components, &hostBuffer, &pathBuffer)) { - app.DebugPrintf("LCELive: WinHttpCrackUrl failed for '%s'\n", baseUrlUtf8.c_str()); + LCELOG("LCE", "WinHttpCrackUrl failed for '%s'", baseUrlUtf8.c_str()); return false; } @@ -1281,7 +1282,7 @@ namespace Win64LceLive if (accessToken.empty()) return { false, "Not signed in to LCELive." }; - app.DebugPrintf("LCELive: sending friend request for username='%s'\n", username.c_str()); + LCELOG("LCE", "sending friend request for username='%s'", username.c_str()); Json bodyJson; bodyJson["username"] = username; @@ -1296,7 +1297,7 @@ namespace Win64LceLive std::string responseBody; if (!PerformJsonRequest(req, &status, &responseBody)) { - app.DebugPrintf("LCELive: friend request transport failure\n"); + LCELOG("LCE", "friend request transport failure"); return { false, "Failed to contact LCELive while sending friend request." }; } @@ -1313,20 +1314,20 @@ namespace Win64LceLive responseBody.clear(); if (!PerformJsonRequest(req, &status, &responseBody)) { - app.DebugPrintf("LCELive: friend request transport failure after refresh\n"); + LCELOG("LCE", "friend request transport failure after refresh"); return { false, "Failed to contact LCELive while sending friend request." }; } } else { - app.DebugPrintf("LCELive: friend request refresh failed: %s\n", refreshError.c_str()); + LCELOG("LCE", "friend request refresh failed: %s", refreshError.c_str()); return { false, refreshError }; } } if (status < 200 || status >= 300) { - app.DebugPrintf("LCELive: friend request HTTP %lu body='%s'\n", + LCELOG("LCE", "friend request HTTP %lu body='%s'", static_cast(status), responseBody.c_str()); const std::string parsed = ParseErrorMessage(responseBody, std::string()); if (!parsed.empty()) @@ -1479,7 +1480,7 @@ namespace Win64LceLive return { true, parseList("incoming"), parseList("outgoing"), std::string() }; } - SocialActionResult SendGameInviteSync(const std::string &recipientAccountId, const std::string &hostIp, int hostPort, const std::string &hostName) + SocialActionResult SendGameInviteSync(const std::string &recipientAccountId, const std::string &hostIp, int hostPort, const std::string &hostName, const std::string &signalingSessionId) { EnsureInitialized(); std::string accessToken; @@ -1500,6 +1501,10 @@ namespace Win64LceLive bodyJson["hostIp"] = hostIp; bodyJson["hostPort"] = hostPort; bodyJson["hostName"] = hostName; + if (!signalingSessionId.empty()) + bodyJson["signalingSessionId"] = signalingSessionId; + else + bodyJson["signalingSessionId"] = nullptr; RequestContext req = {}; req.type = ERequestType::None; @@ -1571,6 +1576,7 @@ namespace Win64LceLive result.hostIp = JsonStringOrEmpty(responseJson, "hostIp"); result.hostPort = JsonIntOrDefault(responseJson, "hostPort", 0); result.hostName = JsonStringOrEmpty(responseJson, "hostName"); + result.signalingSessionId = JsonStringOrEmpty(responseJson, "signalingSessionId"); return result; } diff --git a/Minecraft.Client/Windows64/Windows64_LceLive.h b/Minecraft.Client/Windows64/Windows64_LceLive.h index ec200380..3ed431b8 100644 --- a/Minecraft.Client/Windows64/Windows64_LceLive.h +++ b/Minecraft.Client/Windows64/Windows64_LceLive.h @@ -83,6 +83,7 @@ namespace Win64LceLive bool sessionActive; std::string createdUtc; std::string expiresUtc; + std::string signalingSessionId; // empty if host didn't provide one (Phase 4b+) }; struct GameInvitesResult @@ -100,6 +101,7 @@ namespace Win64LceLive std::string hostIp; int hostPort; std::string hostName; + std::string signalingSessionId; // empty if not a Phase 4b session std::string error; }; @@ -133,7 +135,7 @@ namespace Win64LceLive SocialActionResult RemoveFriendSync(const std::string& accountId); GameInvitesResult GetGameInvitesSync(); - SocialActionResult SendGameInviteSync(const std::string& recipientAccountId, const std::string& hostIp, int hostPort, const std::string& hostName); + SocialActionResult SendGameInviteSync(const std::string& recipientAccountId, const std::string& hostIp, int hostPort, const std::string& hostName, const std::string& signalingSessionId); GameInviteActionResult AcceptGameInviteSync(const std::string& inviteId); SocialActionResult DeclineGameInviteSync(const std::string& inviteId); SocialActionResult DeactivateGameInvitesSync(); diff --git a/Minecraft.Client/Windows64/Windows64_LceLiveP2P.cpp b/Minecraft.Client/Windows64/Windows64_LceLiveP2P.cpp new file mode 100644 index 00000000..8386406e --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLiveP2P.cpp @@ -0,0 +1,909 @@ +#include "stdafx.h" + +#ifdef _WINDOWS64 + +// Winsock2 must come after stdafx.h (which defines WIN32_LEAN_AND_MEAN before +// windows.h, so the old winsock.h v1 is never pulled in — no conflict). +#include +#include + +// UPnP IGD support via Windows native COM interfaces (no third-party lib needed). +// natupnp.h is in the Windows 8.1+ SDK; comdef.h provides _bstr_t/_variant_t helpers. +#include +#include + +#include "Windows64_LceLiveP2P.h" +#include "Windows64_Log.h" +#include "Network/WinsockNetLayer.h" + +#include +#include +#include +#include + +#pragma comment(lib, "Ws2_32.lib") +#pragma comment(lib, "Ole32.lib") +#pragma comment(lib, "OleAut32.lib") + +// ============================================================================ +// Internal implementation +// ============================================================================ + +namespace +{ + // ------------------------------------------------------------------------- + // STUN constants (RFC 5389) + // ------------------------------------------------------------------------- + + static const unsigned long STUN_MAGIC_COOKIE = 0x2112A442UL; + static const unsigned short STUN_BINDING_REQUEST = 0x0001; + static const unsigned short STUN_BINDING_RESPONSE = 0x0101; + static const unsigned short STUN_ATTR_XOR_MAPPED_ADDR = 0x0020; + static const unsigned short STUN_ATTR_MAPPED_ADDR = 0x0001; + + static const char* STUN_HOST_PRIMARY = "stun.l.google.com"; + static const char* STUN_HOST_FALLBACK = "stun1.l.google.com"; + static const int STUN_PORT = 19302; + static const DWORD STUN_TIMEOUT_MS = 5000; + + // Re-send a keepalive STUN binding request every N ms to keep the NAT + // mapping alive. + static const ULONGLONG KEEPALIVE_INTERVAL_MS = 20000ULL; + + // Bring the public EConnMethod into this anonymous namespace for convenience. + using EConnMethod = Win64LceLiveP2P::EConnMethod; + + // ------------------------------------------------------------------------- + // STUN message helpers + // ------------------------------------------------------------------------- + + struct StunRequest + { + unsigned char bytes[20]; // Full 20-byte binding request + unsigned char txId[12]; // Transaction ID (last 12 bytes) + }; + + struct StunResult + { + bool success; + std::string externalIp; + unsigned short externalPort; + std::string errorMessage; + }; + + // Build a 20-byte STUN Binding Request with a fresh random transaction ID. + StunRequest BuildStunRequest() + { + StunRequest req = {}; + + for (int i = 0; i < 12; ++i) + req.txId[i] = static_cast(rand() & 0xFF); + + unsigned char* p = req.bytes; + // Message Type: Binding Request (big-endian) + p[0] = 0x00; p[1] = 0x01; + // Message Length: 0 (no attributes in a request) + p[2] = 0x00; p[3] = 0x00; + // Magic Cookie: 0x2112A442 + p[4] = 0x21; p[5] = 0x12; p[6] = 0xA4; p[7] = 0x42; + // Transaction ID + memcpy(p + 8, req.txId, 12); + + return req; + } + + // Parse a STUN Binding Success Response. + // Looks for XOR-MAPPED-ADDRESS first, falls back to MAPPED-ADDRESS. + // Returns true and fills outIp/outPort on success. + bool ParseStunResponse( + const unsigned char* data, int length, + std::string* outIp, unsigned short* outPort) + { + if (length < 20) + return false; + + // Magic cookie check + if (data[4] != 0x21 || data[5] != 0x12 || data[6] != 0xA4 || data[7] != 0x42) + return false; + + // Must be a Binding Success Response + const unsigned short msgType = + (static_cast(data[0]) << 8) | data[1]; + if (msgType != STUN_BINDING_RESPONSE) + return false; + + const unsigned short msgLen = + (static_cast(data[2]) << 8) | data[3]; + if (static_cast(msgLen) + 20 > length) + return false; + + // Walk the attribute list + int offset = 20; + const int end = 20 + static_cast(msgLen); + + while (offset + 4 <= end) + { + const unsigned short attrType = + (static_cast(data[offset]) << 8) | data[offset + 1]; + const unsigned short attrLen = + (static_cast(data[offset + 2]) << 8) | data[offset + 3]; + + const int valueOffset = offset + 4; + const int attrEnd = valueOffset + static_cast(attrLen); + + // Bounds check before reading the attribute value + if (attrEnd > length) + break; + + if ((attrType == STUN_ATTR_XOR_MAPPED_ADDR || attrType == STUN_ATTR_MAPPED_ADDR) + && attrLen >= 8) + { + const unsigned char family = data[valueOffset + 1]; + if (family != 0x01) // IPv4 only + { + // Skip — IPv6 not supported here + } + else if (attrType == STUN_ATTR_XOR_MAPPED_ADDR) + { + // X-Port = port XOR (magic_cookie >> 16) + // X-Address = address XOR magic_cookie + // Both the packet value and the mask are in network byte order, + // so we can XOR the big-endian values read directly from the bytes. + const unsigned short xport = + (static_cast(data[valueOffset + 2]) << 8) | + data[valueOffset + 3]; + const unsigned short port = + xport ^ static_cast(STUN_MAGIC_COOKIE >> 16); + + const unsigned long xaddr = + (static_cast(data[valueOffset + 4]) << 24) | + (static_cast(data[valueOffset + 5]) << 16) | + (static_cast(data[valueOffset + 6]) << 8) | + data[valueOffset + 7]; + const unsigned long addr = xaddr ^ STUN_MAGIC_COOKIE; + + // addr is in host byte order; convert to network for inet_ntop. + const unsigned long addrNet = htonl(addr); + char ipStr[INET_ADDRSTRLEN] = {}; + inet_ntop(AF_INET, &addrNet, ipStr, sizeof(ipStr)); + + *outIp = ipStr; + *outPort = port; + return true; + } + else // STUN_ATTR_MAPPED_ADDR (no XOR) + { + const unsigned short port = + (static_cast(data[valueOffset + 2]) << 8) | + data[valueOffset + 3]; + const unsigned long addr = + (static_cast(data[valueOffset + 4]) << 24) | + (static_cast(data[valueOffset + 5]) << 16) | + (static_cast(data[valueOffset + 6]) << 8) | + data[valueOffset + 7]; + + const unsigned long addrNet = htonl(addr); + char ipStr[INET_ADDRSTRLEN] = {}; + inet_ntop(AF_INET, &addrNet, ipStr, sizeof(ipStr)); + + *outIp = ipStr; + *outPort = port; + return true; + } + } + + // Advance past this attribute (padded to 4-byte boundary) + const int padded = (static_cast(attrLen) + 3) & ~3; + offset += 4 + padded; + } + + return false; + } + + // ------------------------------------------------------------------------- + // UPnP IGD helpers + // ------------------------------------------------------------------------- + + struct UPnPResult + { + bool success; + std::string externalIp; + int externalPort; // same as localPort we passed in + int localPort; // what we registered + std::string error; + }; + + // Attempts to add a UPnP port mapping (UDP or TCP) via the Windows IUPnPNAT + // COM interface. Handles its own CoInitializeEx/CoUninitialize. + // Blocks for up to a few seconds while UPnP discovery runs. + // Set tcp=false for UDP (P2P socket), tcp=true for TCP (game server port). + UPnPResult TryUPnPMapping(int localPort, bool tcp) + { + UPnPResult result = {}; + result.localPort = localPort; + + // COM must be initialised on this thread (STA for UPnP callbacks). + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + const bool coInitialized = SUCCEEDED(hr) || (hr == RPC_E_CHANGED_MODE); + + IUPnPNAT* pNAT = nullptr; + hr = CoCreateInstance(__uuidof(UPnPNAT), nullptr, CLSCTX_ALL, + __uuidof(IUPnPNAT), reinterpret_cast(&pNAT)); + if (FAILED(hr)) + { + result.error = "UPnP: CoCreateInstance(UPnPNAT) failed hr=0x" + + std::to_string(static_cast(hr)); + if (coInitialized) CoUninitialize(); + return result; + } + + // get_StaticPortMappingCollection blocks until UPnP discovery completes + // (~1-3 s on a cooperative router, full timeout if no IGD present). + IStaticPortMappingCollection* pMappings = nullptr; + hr = pNAT->get_StaticPortMappingCollection(&pMappings); + pNAT->Release(); + + if (FAILED(hr) || pMappings == nullptr) + { + result.error = "UPnP: no IGD found (router may not support UPnP)"; + if (coInitialized) CoUninitialize(); + return result; + } + + // Determine our local IP address (pick first IPv4 from hostname resolution). + char localIp[INET_ADDRSTRLEN] = {}; + { + char hostname[256] = {}; + gethostname(hostname, sizeof(hostname)); + addrinfo hints = {}; + hints.ai_family = AF_INET; + addrinfo* info = nullptr; + if (getaddrinfo(hostname, nullptr, &hints, &info) == 0 && info != nullptr) + { + inet_ntop(AF_INET, + &reinterpret_cast(info->ai_addr)->sin_addr, + localIp, sizeof(localIp)); + freeaddrinfo(info); + } + } + + if (localIp[0] == '\0') + { + pMappings->Release(); + result.error = "UPnP: could not determine local IPv4 address"; + if (coInitialized) CoUninitialize(); + return result; + } + + const wchar_t* protocol = tcp ? L"TCP" : L"UDP"; + const wchar_t* description = tcp ? L"LceLive Game" : L"LceLive P2P"; + + // Remove any stale LceLive mapping on this port (best-effort). + pMappings->Remove(localPort, _bstr_t(protocol)); + + // Convert local IP to wide for the COM API. + wchar_t localIpW[INET_ADDRSTRLEN] = {}; + MultiByteToWideChar(CP_ACP, 0, localIp, -1, localIpW, INET_ADDRSTRLEN); + + IStaticPortMapping* pMapping = nullptr; + hr = pMappings->Add( + localPort, // external port + _bstr_t(protocol), // protocol + localPort, // internal port + _bstr_t(localIpW), // internal client (our LAN IP) + VARIANT_TRUE, // enabled + _bstr_t(description), // description + &pMapping + ); + pMappings->Release(); + + if (FAILED(hr) || pMapping == nullptr) + { + result.error = "UPnP: AddPortMapping failed hr=0x" + + std::to_string(static_cast(hr)); + if (coInitialized) CoUninitialize(); + return result; + } + + // Read back the external IP the router assigned. + BSTR extIpBstr = nullptr; + pMapping->get_ExternalIPAddress(&extIpBstr); + if (extIpBstr != nullptr) + { + char buf[64] = {}; + WideCharToMultiByte(CP_UTF8, 0, extIpBstr, -1, buf, sizeof(buf), nullptr, nullptr); + result.externalIp = buf; + SysFreeString(extIpBstr); + } + pMapping->Release(); + + if (coInitialized) CoUninitialize(); + + // Sanity-check: router must return a non-empty, non-private external IP. + // If the router returns 0.0.0.0 or a private range we can't use it. + if (result.externalIp.empty() || + result.externalIp == "0.0.0.0" || + result.externalIp.substr(0, 3) == "10." || + result.externalIp.substr(0, 8) == "192.168." || + result.externalIp.substr(0, 7) == "172.16." || + result.externalIp.substr(0, 7) == "172.17." || + result.externalIp.substr(0, 7) == "172.18." || + result.externalIp.substr(0, 7) == "172.19." || + result.externalIp.substr(0, 7) == "172.20." || + result.externalIp.substr(0, 7) == "172.21." || + result.externalIp.substr(0, 7) == "172.22." || + result.externalIp.substr(0, 7) == "172.23." || + result.externalIp.substr(0, 7) == "172.24." || + result.externalIp.substr(0, 7) == "172.25." || + result.externalIp.substr(0, 7) == "172.26." || + result.externalIp.substr(0, 7) == "172.27." || + result.externalIp.substr(0, 7) == "172.28." || + result.externalIp.substr(0, 7) == "172.29." || + result.externalIp.substr(0, 7) == "172.30." || + result.externalIp.substr(0, 7) == "172.31.") + { + result.error = "UPnP: router returned unusable external IP (" + + result.externalIp + ")"; + return result; + } + + result.success = true; + result.externalPort = localPort; + return result; + } + + // Remove a previously added UPnP port mapping. + // Call from HostClose() on any thread (handles its own CoInit/Uninit). + // tcp=false removes a UDP mapping, tcp=true removes a TCP mapping. + void RemoveUPnPMapping(int port, bool tcp) + { + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + const bool coInitialized = SUCCEEDED(hr) || (hr == RPC_E_CHANGED_MODE); + + IUPnPNAT* pNAT = nullptr; + hr = CoCreateInstance(__uuidof(UPnPNAT), nullptr, CLSCTX_ALL, + __uuidof(IUPnPNAT), reinterpret_cast(&pNAT)); + if (SUCCEEDED(hr) && pNAT != nullptr) + { + IStaticPortMappingCollection* pMappings = nullptr; + if (SUCCEEDED(pNAT->get_StaticPortMappingCollection(&pMappings)) && pMappings != nullptr) + { + pMappings->Remove(port, _bstr_t(tcp ? L"TCP" : L"UDP")); + pMappings->Release(); + } + pNAT->Release(); + } + + if (coInitialized) CoUninitialize(); + } + + // ------------------------------------------------------------------------- + // Runtime state + // ------------------------------------------------------------------------- + + struct WorkerResult + { + bool success; + EConnMethod method; + std::string externalIp; + int externalPort; + std::string errorMessage; + }; + + struct P2PState + { + bool initialized; + CRITICAL_SECTION lock; + + Win64LceLiveP2P::EP2PState state; + EConnMethod connMethod; // set once worker succeeds + + // The long-lived UDP socket. INVALID_SOCKET when not open. + // Owned by the main thread once discovery completes. + // During discovery, owned by StunWorkerProc. + SOCKET udpSocket; + int localPort; // Local port we bound (host order) + + // UPnP: we keep track of mapped ports so HostClose can remove them. + bool upnpMappingActive; // UDP P2P port + int upnpMappedPort; + bool tcpUpnpMappingActive; // TCP game port + int tcpUpnpMappedPort; + + // Resolved STUN server address — cached to avoid re-DNS on keepalives. + sockaddr_in stunServerAddr; + bool stunServerAddrValid; + + // Discovery results (written by worker, read by main thread after join) + WorkerResult workerResult; + std::string externalIp; + int externalPort; + + // Worker thread + HANDLE workerThread; + bool workerDone; + + std::string lastError; + ULONGLONG nextKeepaliveAt; + }; + + static P2PState g_p2p = {}; + static INIT_ONCE g_initOnce = INIT_ONCE_STATIC_INIT; + + BOOL CALLBACK InitP2PState(PINIT_ONCE, PVOID, PVOID*) + { + // Initialize Winsock once for the lifetime of the process. + // WinHTTP already does this internally, but we do it explicitly so raw + // Winsock calls (WSASocket, getaddrinfo, etc.) are available. + WSADATA wsaData = {}; + WSAStartup(MAKEWORD(2, 2), &wsaData); + + InitializeCriticalSection(&g_p2p.lock); + g_p2p.state = Win64LceLiveP2P::EP2PState::Idle; + g_p2p.udpSocket = INVALID_SOCKET; + g_p2p.initialized = true; + return TRUE; + } + + void EnsureInitialized() + { + InitOnceExecuteOnce(&g_initOnce, &InitP2PState, nullptr, nullptr); + } + + // ------------------------------------------------------------------------- + // Resolve the STUN server address. Returns true on success. + // ------------------------------------------------------------------------- + bool ResolveStunServer(const char* host, sockaddr_in* outAddr) + { + addrinfo hints = {}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_DGRAM; + + char portStr[16] = {}; + sprintf_s(portStr, "%d", STUN_PORT); + + addrinfo* info = nullptr; + if (getaddrinfo(host, portStr, &hints, &info) != 0 || info == nullptr) + return false; + + *outAddr = *reinterpret_cast(info->ai_addr); + freeaddrinfo(info); + return true; + } + + // ------------------------------------------------------------------------- + // Discovery worker thread + // + // Attempt order: + // 1. UPnP IGD port mapping — no hole-punching needed, works on ~60% of home routers + // 2. STUN external endpoint — requires hole-punching, works on ~95% of the rest + // + // In both cases the long-lived UDP socket stays open for the life of the + // host session (keepalives via STUN, actual data via KCP later). + // ------------------------------------------------------------------------- + + DWORD WINAPI DiscoveryWorkerProc(LPVOID) + { + // ---- Open and bind the long-lived UDP socket ---- + SOCKET sock = WSASocket(AF_INET, SOCK_DGRAM, IPPROTO_UDP, nullptr, 0, 0); + if (sock == INVALID_SOCKET) + { + WorkerResult r = {}; + r.errorMessage = "P2P: failed to create UDP socket (WSA " + + std::to_string(WSAGetLastError()) + ")"; + EnterCriticalSection(&g_p2p.lock); + g_p2p.workerResult = r; + g_p2p.workerDone = true; + LeaveCriticalSection(&g_p2p.lock); + return 0; + } + + sockaddr_in bindAddr = {}; + bindAddr.sin_family = AF_INET; + bindAddr.sin_addr.s_addr = INADDR_ANY; + bindAddr.sin_port = 0; + if (::bind(sock, reinterpret_cast(&bindAddr), sizeof(bindAddr)) != 0) + { + closesocket(sock); + WorkerResult r = {}; + r.errorMessage = "P2P: failed to bind UDP socket"; + EnterCriticalSection(&g_p2p.lock); + g_p2p.workerResult = r; + g_p2p.workerDone = true; + LeaveCriticalSection(&g_p2p.lock); + return 0; + } + + sockaddr_in local = {}; + int localLen = sizeof(local); + getsockname(sock, reinterpret_cast(&local), &localLen); + const int localPort = ntohs(local.sin_port); + + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&STUN_TIMEOUT_MS), sizeof(STUN_TIMEOUT_MS)); + + // ---- Step 1a: UPnP IGD — UDP (P2P socket) ---- + LCELOG("P2P", "trying UPnP IGD UDP (local port %d)", localPort); + const UPnPResult upnp = TryUPnPMapping(localPort, false /*udp*/); + + // ---- Step 1b: UPnP IGD — TCP (game server port) ---- + // Always attempt regardless of UDP outcome: the router maps UDP and TCP + // independently. A successful TCP mapping means joiners can reach the game + // server directly over the internet without port-forwarding. + const int tcpGamePort = WinsockNetLayer::GetHostPort(); + UPnPResult tcpUpnp = {}; + if (tcpGamePort > 0) + { + LCELOG("P2P", "trying UPnP IGD TCP (game port %d)", tcpGamePort); + tcpUpnp = TryUPnPMapping(tcpGamePort, true /*tcp*/); + if (tcpUpnp.success) + LCELOG("P2P", "UPnP TCP game port %d mapped", tcpGamePort); + else + LCELOG("P2P", "UPnP TCP failed (%s)", tcpUpnp.error.c_str()); + } + + if (upnp.success) + { + LCELOG("P2P", "UPnP UDP mapped — external %s:%d local port %d", + upnp.externalIp.c_str(), upnp.externalPort, localPort); + + WorkerResult r = {}; + r.success = true; + r.method = EConnMethod::UPnP; + r.externalIp = upnp.externalIp; + r.externalPort = upnp.externalPort; + + EnterCriticalSection(&g_p2p.lock); + g_p2p.udpSocket = sock; + g_p2p.localPort = localPort; + g_p2p.stunServerAddrValid = false; // keepalives still done via STUN below + g_p2p.upnpMappingActive = true; + g_p2p.upnpMappedPort = localPort; + g_p2p.tcpUpnpMappingActive = tcpUpnp.success; + g_p2p.tcpUpnpMappedPort = tcpUpnp.success ? tcpGamePort : 0; + g_p2p.workerResult = r; + g_p2p.workerDone = true; + LeaveCriticalSection(&g_p2p.lock); + return 0; + } + + LCELOG("P2P", "UPnP UDP failed (%s) — falling back to STUN", upnp.error.c_str()); + + // ---- Step 2: STUN ---- + const char* hosts[2] = { STUN_HOST_PRIMARY, STUN_HOST_FALLBACK }; + WorkerResult result = {}; + sockaddr_in serverAddr = {}; + bool serverAddrValid = false; + + for (int h = 0; h < 2 && !result.success; ++h) + { + const char* stunHost = hosts[h]; + + if (!ResolveStunServer(stunHost, &serverAddr)) + { + result.errorMessage = std::string("P2P: DNS failed for ") + stunHost; + LCELOG("P2P", "STUN DNS lookup failed for %s", stunHost); + continue; + } + + serverAddrValid = true; + + // Try up to 2 transmissions per server + for (int attempt = 0; attempt < 2 && !result.success; ++attempt) + { + const StunRequest req = BuildStunRequest(); + + const int sent = sendto( + sock, + reinterpret_cast(req.bytes), sizeof(req.bytes), + 0, + reinterpret_cast(&serverAddr), sizeof(serverAddr)); + + if (sent != static_cast(sizeof(req.bytes))) + { + result.errorMessage = "P2P: STUN sendto failed"; + continue; + } + + unsigned char buf[512] = {}; + sockaddr_in fromAddr = {}; + int fromLen = sizeof(fromAddr); + + const int received = recvfrom( + sock, + reinterpret_cast(buf), sizeof(buf), + 0, + reinterpret_cast(&fromAddr), &fromLen); + + if (received < 20) + { + result.errorMessage = "P2P: STUN timeout or empty response"; + continue; + } + + std::string ip = {}; + unsigned short port = 0; + if (ParseStunResponse(buf, received, &ip, &port)) + { + result.success = true; + result.method = EConnMethod::STUN; + result.externalIp = ip; + result.externalPort = port; + } + else + { + result.errorMessage = "P2P: STUN response parse failed"; + } + } + + if (!result.success) + LCELOG("P2P", "STUN failed for %s — %s", stunHost, result.errorMessage.c_str()); + } + + EnterCriticalSection(&g_p2p.lock); + if (result.success) + { + g_p2p.udpSocket = sock; + g_p2p.localPort = localPort; + g_p2p.stunServerAddr = serverAddr; + g_p2p.stunServerAddrValid = serverAddrValid; + // TCP UPnP was already attempted at the top (Step 1b) — record result. + g_p2p.tcpUpnpMappingActive = tcpUpnp.success; + g_p2p.tcpUpnpMappedPort = tcpUpnp.success ? tcpGamePort : 0; + } + else + { + closesocket(sock); + } + g_p2p.workerResult = result; + g_p2p.workerDone = true; + LeaveCriticalSection(&g_p2p.lock); + + return 0; + } + + // ------------------------------------------------------------------------- + // Keepalive — sends a STUN binding request to keep the NAT mapping warm. + // Called from P2PTick() outside the critical section. + // Uses the cached server address; no DNS lookup. + // ------------------------------------------------------------------------- + + void SendKeepalive(SOCKET sock, const sockaddr_in& serverAddr) + { + const StunRequest req = BuildStunRequest(); + sendto( + sock, + reinterpret_cast(req.bytes), sizeof(req.bytes), + 0, + reinterpret_cast(&serverAddr), sizeof(serverAddr)); + + LCELOG("P2P", "keepalive sent"); + } + +} // anonymous namespace + +// ============================================================================ +// Public API +// ============================================================================ + +namespace Win64LceLiveP2P +{ + bool HostOpen() + { + EnsureInitialized(); + + EnterCriticalSection(&g_p2p.lock); + + if (g_p2p.state != EP2PState::Idle) + { + LeaveCriticalSection(&g_p2p.lock); + return false; // Already running — caller should HostClose() first. + } + + g_p2p.state = EP2PState::Discovering; + g_p2p.workerDone = false; + g_p2p.connMethod = EConnMethod::None; + g_p2p.lastError.clear(); + g_p2p.externalIp.clear(); + g_p2p.externalPort = 0; + g_p2p.localPort = 0; + g_p2p.upnpMappingActive = false; + g_p2p.upnpMappedPort = 0; + g_p2p.tcpUpnpMappingActive = false; + g_p2p.tcpUpnpMappedPort = 0; + + g_p2p.workerThread = CreateThread(nullptr, 0, &DiscoveryWorkerProc, nullptr, 0, nullptr); + if (g_p2p.workerThread == nullptr) + { + g_p2p.state = EP2PState::Failed; + g_p2p.lastError = "P2P: failed to create discovery thread."; + LeaveCriticalSection(&g_p2p.lock); + return false; + } + + LeaveCriticalSection(&g_p2p.lock); + LCELOG("P2P", "discovery started (UPnP -> STUN fallback)"); + return true; + } + + void HostClose() + { + EnsureInitialized(); + + // Read state we need without blocking the worker. + HANDLE threadToWait = nullptr; + bool removeUpnp = false; + int upnpPortToRemove = 0; + bool removeTcpUpnp = false; + int tcpUpnpPortToRemove = 0; + + EnterCriticalSection(&g_p2p.lock); + threadToWait = g_p2p.workerThread; + removeUpnp = g_p2p.upnpMappingActive; + upnpPortToRemove = g_p2p.upnpMappedPort; + removeTcpUpnp = g_p2p.tcpUpnpMappingActive; + tcpUpnpPortToRemove = g_p2p.tcpUpnpMappedPort; + LeaveCriticalSection(&g_p2p.lock); + + // Wait for in-flight discovery thread BEFORE taking the lock again, to + // avoid deadlocking (the worker also takes the lock at the end of its run). + if (threadToWait != nullptr) + { + WaitForSingleObject(threadToWait, 8000); + CloseHandle(threadToWait); + } + + // Remove UPnP mappings while we still have the info (before we clear state). + if (removeUpnp && upnpPortToRemove != 0) + { + LCELOG("P2P", "removing UPnP UDP mapping for port %d", upnpPortToRemove); + RemoveUPnPMapping(upnpPortToRemove, false /*udp*/); + } + if (removeTcpUpnp && tcpUpnpPortToRemove != 0) + { + LCELOG("P2P", "removing UPnP TCP mapping for port %d", tcpUpnpPortToRemove); + RemoveUPnPMapping(tcpUpnpPortToRemove, true /*tcp*/); + } + + EnterCriticalSection(&g_p2p.lock); + g_p2p.workerThread = nullptr; + + if (g_p2p.udpSocket != INVALID_SOCKET) + { + closesocket(g_p2p.udpSocket); + g_p2p.udpSocket = INVALID_SOCKET; + } + + g_p2p.state = EP2PState::Idle; + g_p2p.connMethod = EConnMethod::None; + g_p2p.localPort = 0; + g_p2p.externalIp.clear(); + g_p2p.externalPort = 0; + g_p2p.stunServerAddrValid = false; + g_p2p.lastError.clear(); + g_p2p.workerDone = false; + g_p2p.upnpMappingActive = false; + g_p2p.upnpMappedPort = 0; + g_p2p.tcpUpnpMappingActive = false; + g_p2p.tcpUpnpMappedPort = 0; + + LeaveCriticalSection(&g_p2p.lock); + LCELOG("P2P", "host socket closed"); + } + + void P2PTick() + { + EnsureInitialized(); + + EnterCriticalSection(&g_p2p.lock); + + // Integrate a completed discovery + if (g_p2p.state == EP2PState::Discovering && g_p2p.workerDone) + { + HANDLE t = g_p2p.workerThread; + g_p2p.workerThread = nullptr; + LeaveCriticalSection(&g_p2p.lock); + + if (t != nullptr) + { + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + } + + EnterCriticalSection(&g_p2p.lock); + const WorkerResult& r = g_p2p.workerResult; + if (r.success) + { + g_p2p.state = EP2PState::Ready; + g_p2p.connMethod = r.method; + g_p2p.externalIp = r.externalIp; + g_p2p.externalPort = r.externalPort; + g_p2p.nextKeepaliveAt = GetTickCount64() + KEEPALIVE_INTERVAL_MS; + + const char* methodName = (r.method == EConnMethod::UPnP) ? "UPnP" : "STUN"; + LCELOG("P2P", "ready via %s — external %s:%d local port %d", + methodName, r.externalIp.c_str(), r.externalPort, g_p2p.localPort); + } + else + { + g_p2p.state = EP2PState::Failed; + g_p2p.lastError = r.errorMessage; + LCELOG("P2P", "all discovery methods failed — %s", r.errorMessage.c_str()); + } + } + + // Check whether a STUN keepalive is due. + // UPnP sessions also send keepalives to keep the STUN mapping warm for + // potential fallback, but only if we have a STUN server address cached. + bool doKeepalive = false; + SOCKET keepaliveSock = INVALID_SOCKET; + sockaddr_in keepaliveAddr = {}; + + if (g_p2p.state == EP2PState::Ready && + g_p2p.stunServerAddrValid && + GetTickCount64() >= g_p2p.nextKeepaliveAt) + { + doKeepalive = true; + keepaliveSock = g_p2p.udpSocket; + keepaliveAddr = g_p2p.stunServerAddr; + g_p2p.nextKeepaliveAt = GetTickCount64() + KEEPALIVE_INTERVAL_MS; + } + + LeaveCriticalSection(&g_p2p.lock); + + if (doKeepalive && keepaliveSock != INVALID_SOCKET) + SendKeepalive(keepaliveSock, keepaliveAddr); + } + + P2PSnapshot GetP2PSnapshot() + { + EnsureInitialized(); + P2PTick(); + + P2PSnapshot snap = {}; + + EnterCriticalSection(&g_p2p.lock); + snap.state = g_p2p.state; + snap.connMethod = g_p2p.connMethod; + snap.externalIp = g_p2p.externalIp; + snap.externalPort = g_p2p.externalPort; + snap.localPort = g_p2p.localPort; + snap.tcpPortMapped = g_p2p.tcpUpnpMappingActive; + + switch (g_p2p.state) + { + case EP2PState::Idle: + snap.statusMessage = L"P2P: idle."; + break; + + case EP2PState::Discovering: + snap.statusMessage = L"P2P: discovering external endpoint (UPnP \u2192 STUN)..."; + break; + + case EP2PState::Ready: + { + const wchar_t* method = (g_p2p.connMethod == EConnMethod::UPnP) ? L"UPnP" : L"STUN"; + wchar_t buf[256] = {}; + swprintf_s(buf, + L"P2P ready via %s. External %hs:%d (local port %d)", + method, + g_p2p.externalIp.c_str(), g_p2p.externalPort, g_p2p.localPort); + snap.statusMessage = buf; + break; + } + + case EP2PState::Failed: + snap.statusMessage = L"P2P: discovery failed (UPnP + STUN). " + L"Manual port forwarding may be required."; + snap.errorMessage = std::wstring( + g_p2p.lastError.begin(), g_p2p.lastError.end()); + break; + } + + LeaveCriticalSection(&g_p2p.lock); + return snap; + } + +} // namespace Win64LceLiveP2P + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_LceLiveP2P.h b/Minecraft.Client/Windows64/Windows64_LceLiveP2P.h new file mode 100644 index 00000000..3440c8f9 --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLiveP2P.h @@ -0,0 +1,65 @@ +#pragma once + +#ifdef _WINDOWS64 + +#include + +namespace Win64LceLiveP2P +{ + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + enum class EP2PState + { + Idle, // No socket open; call HostOpen() to start. + Discovering, // Discovery in progress (UPnP → STUN) on a background thread. + Ready, // External endpoint known; socket is live and kept warm. + Failed, // All discovery methods failed; see P2PSnapshot::errorMessage. + }; + + // How the external endpoint was obtained once state == Ready. + enum class EConnMethod + { + None, // Not yet known. + UPnP, // UPnP IGD port mapping — joiner can connect directly. + STUN, // STUN-derived endpoint — requires UDP hole punching. + }; + + struct P2PSnapshot + { + EP2PState state; + EConnMethod connMethod; // None until Ready. + std::string externalIp; // Empty until Ready. + int externalPort; // 0 until Ready. + int localPort; // UDP port we bound on this machine. + bool tcpPortMapped; // true if UPnP also mapped the TCP game port. + std::wstring statusMessage; // Human-readable; always set. + std::wstring errorMessage; // Non-empty only on Failed. + }; + + // ------------------------------------------------------------------------- + // API + // ------------------------------------------------------------------------- + + // Open the long-lived host UDP socket and begin STUN discovery. + // Returns true if discovery was successfully kicked off. + // Non-blocking; transition to Discovering happens immediately. + // Call P2PTick() regularly to integrate the result. + // Returns false if already open/discovering/ready (call HostClose first). + bool HostOpen(); + + // Close the host socket and reset to Idle. + // Blocks briefly to join the discovery thread if it is still running. + void HostClose(); + + // Drive the state machine: integrate completed STUN results, send keepalives. + // Call once per frame from the game loop, same cadence as Win64LceLive::Tick(). + void P2PTick(); + + // Thread-safe snapshot of current state. + // Calls P2PTick() internally so you can call this without a separate Tick call. + P2PSnapshot GetP2PSnapshot(); +} + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_LceLiveRelay.cpp b/Minecraft.Client/Windows64/Windows64_LceLiveRelay.cpp new file mode 100644 index 00000000..e9b40888 --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLiveRelay.cpp @@ -0,0 +1,962 @@ +#include "stdafx.h" + +#ifdef _WINDOWS64 + +// Winsock2 must appear before any Windows.h inclusion; the PCH has already +// pulled in windows.h, so undef any byte-order macros it may have injected +// (they conflict with the BIGENDIAN/LITTLEENDIAN enum in Definitions.h). +#ifdef BIGENDIAN +#undef BIGENDIAN +#endif +#ifdef LITTLEENDIAN +#undef LITTLEENDIAN +#endif +#include +#include +#include +#include + +#include "Windows64_LceLiveRelay.h" +#include "Windows64_LceLive.h" +#include "Windows64_Log.h" + +#include +#include +#include +#include +#include + +#pragma comment(lib, "Ws2_32.lib") +#pragma comment(lib, "Winhttp.lib") + +// Global: joiner sets this to the relay proxy port when it tries direct TCP first. +// The main Tick automatically retries via relay if the direct attempt fails. +int g_LceLiveRelayFallbackPort = 0; + +// ============================================================================ +// Internal implementation +// ============================================================================ + +namespace +{ + // ------------------------------------------------------------------------- + // URL / string helpers (mirrored from signaling) + // ------------------------------------------------------------------------- + + std::wstring Utf8ToWide(const std::string& s) + { + if (s.empty()) return L""; + const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); + if (n <= 0) return L""; + std::wstring out(static_cast(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &out[0], n); + if (!out.empty() && out.back() == L'\0') out.pop_back(); + return out; + } + + std::string GetBaseUrl() + { + char envValue[512] = {}; + if (GetEnvironmentVariableA("LCELIVE_API_BASE_URL", envValue, sizeof(envValue)) > 0) + return std::string(envValue); + + char exePath[MAX_PATH] = {}; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string props(exePath); + const size_t lastSlash = props.find_last_of("\\/"); + if (lastSlash != std::string::npos) props = props.substr(0, lastSlash + 1); + props += "lcelive.properties"; + + FILE* f = nullptr; + if (fopen_s(&f, props.c_str(), "rb") == 0 && f != nullptr) + { + char line[512] = {}; + while (fgets(line, sizeof(line), f) != nullptr) + { + std::string s(line); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back(); + if (s.substr(0, 12) == "api_base_url") + { + const size_t eq = s.find('='); + if (eq != std::string::npos) { fclose(f); return s.substr(eq + 1); } + } + } + fclose(f); + } + return "http://localhost:5187"; + } + + std::string UrlEncode(const std::string& s) + { + std::string out; + for (unsigned char c : s) + { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + out += static_cast(c); + else { char hex[4] = {}; snprintf(hex, sizeof(hex), "%%%02X", c); out += hex; } + } + return out; + } + + // ------------------------------------------------------------------------- + // Recv helpers + // ------------------------------------------------------------------------- + + // Reliable recv: keeps reading until 'len' bytes arrive or an error occurs. + bool RecvExact(SOCKET sock, void* buf, int len) + { + char* p = static_cast(buf); + int remaining = len; + while (remaining > 0) + { + const int r = recv(sock, p, remaining, 0); + if (r <= 0) return false; + p += r; + remaining -= r; + } + return true; + } + + // ------------------------------------------------------------------------- + // WebSocket connection helper — opens a WinHTTP WS to the relay endpoint. + // Returns the HINTERNET handle on success, nullptr on failure. + // Caller owns the handle and must close it. + // Also returns hSession/hConnect so caller can clean them up. + // ------------------------------------------------------------------------- + + HINTERNET OpenRelayWebSocket( + const std::string& sessionId, + bool isHost, + const std::string& accessToken, + const std::string& baseUrl, + HINTERNET* outSession, + HINTERNET* outConnect) + { + *outSession = nullptr; + *outConnect = nullptr; + + const std::wstring baseUrlW = Utf8ToWide(baseUrl); + + std::vector hostBuf(256, 0); + std::vector pathBuf(2048, 0); + + URL_COMPONENTSW comp = {}; + comp.dwStructSize = sizeof(comp); + comp.lpszHostName = hostBuf.data(); + comp.dwHostNameLength = static_cast(hostBuf.size()); + comp.lpszUrlPath = pathBuf.data(); + comp.dwUrlPathLength = static_cast(pathBuf.size()); + + if (!WinHttpCrackUrl(baseUrlW.c_str(), 0, 0, &comp)) + return nullptr; + + const bool secure = (comp.nScheme == INTERNET_SCHEME_HTTPS); + const std::wstring hostW(comp.lpszHostName, comp.dwHostNameLength); + const std::wstring basePath = comp.lpszUrlPath + ? std::wstring(comp.lpszUrlPath, comp.dwUrlPathLength) : L""; + + const std::wstring wsPath = basePath + L"/api/relay/ws" + + L"?sessionId=" + Utf8ToWide(UrlEncode(sessionId)) + + L"&role=" + (isHost ? L"host" : L"joiner"); + + LCELOG("RELAY", "connecting %s%ls", + secure ? "wss://" : "ws://", (hostW + wsPath).c_str()); + + HINTERNET hSession = WinHttpOpen(L"MCLCE-LceLive/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return nullptr; + + WinHttpSetTimeouts(hSession, 10000, 10000, 30000, 30000); + + HINTERNET hConnect = WinHttpConnect(hSession, hostW.c_str(), comp.nPort, 0); + if (!hConnect) { WinHttpCloseHandle(hSession); return nullptr; } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", wsPath.c_str(), + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + secure ? WINHTTP_FLAG_SECURE : 0); + if (!hRequest) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return nullptr; } + + WinHttpSetOption(hRequest, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0); + + if (!accessToken.empty()) + { + const std::wstring auth = L"Authorization: Bearer " + Utf8ToWide(accessToken); + WinHttpAddRequestHeaders(hRequest, auth.c_str(), static_cast(auth.size()), + WINHTTP_ADDREQ_FLAG_ADD); + } + + if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return nullptr; + } + + if (!WinHttpReceiveResponse(hRequest, nullptr)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return nullptr; + } + + HINTERNET hWs = WinHttpWebSocketCompleteUpgrade(hRequest, 0); + WinHttpCloseHandle(hRequest); + + if (!hWs) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return nullptr; } + + *outSession = hSession; + *outConnect = hConnect; + return hWs; + } + + // ------------------------------------------------------------------------- + // Per-direction forwarding thread params + // ------------------------------------------------------------------------- + + struct ForwardWsToTcpParams + { + HINTERNET wsHandle; + SOCKET tcpSocket; + std::atomic* stop; + }; + + struct ForwardTcpToWsParams + { + SOCKET tcpSocket; + HINTERNET wsHandle; + CRITICAL_SECTION* wsSendLock; + std::atomic* stop; + }; + + // ------------------------------------------------------------------------- + // WS → TCP forwarding thread + // ------------------------------------------------------------------------- + + DWORD WINAPI ForwardWsToTcpProc(LPVOID param) + { + auto* p = static_cast(param); + std::vector buf(65536); + + while (!p->stop->load()) + { + DWORD bytesRead = 0; + WINHTTP_WEB_SOCKET_BUFFER_TYPE bufType = WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE; + + const DWORD err = WinHttpWebSocketReceive( + p->wsHandle, + buf.data(), + static_cast(buf.size()), + &bytesRead, + &bufType); + + if (err != ERROR_SUCCESS) + break; + + // Only forward binary frames; ignore text (control) frames. + if (bufType != WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE && + bufType != WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE) + continue; + + if (bytesRead == 0) + continue; + + // Write all bytes to the TCP socket. + const char* src = reinterpret_cast(buf.data()); + DWORD remaining = bytesRead; + while (remaining > 0 && !p->stop->load()) + { + const int sent = send(p->tcpSocket, src, static_cast(remaining), 0); + if (sent <= 0) { p->stop->store(true); break; } + src += sent; + remaining -= static_cast(sent); + } + } + + p->stop->store(true); + delete p; + return 0; + } + + // ------------------------------------------------------------------------- + // TCP → WS forwarding thread + // ------------------------------------------------------------------------- + + DWORD WINAPI ForwardTcpToWsProc(LPVOID param) + { + auto* p = static_cast(param); + std::vector buf(65536); + + while (!p->stop->load()) + { + const int received = recv(p->tcpSocket, buf.data(), static_cast(buf.size()), 0); + if (received <= 0) + break; + + EnterCriticalSection(p->wsSendLock); + const DWORD err = WinHttpWebSocketSend( + p->wsHandle, + WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE, + buf.data(), + static_cast(received)); + LeaveCriticalSection(p->wsSendLock); + + if (err != ERROR_SUCCESS) + break; + } + + p->stop->store(true); + delete p; + return 0; + } + + // ------------------------------------------------------------------------- + // Global relay state + // ------------------------------------------------------------------------- + + struct RelayState + { + bool initialized = false; + CRITICAL_SECTION lock; + + Win64LceLiveRelay::ERelayState state = Win64LceLiveRelay::ERelayState::Idle; + std::string lastError; + + // Active handles — closed in Close(). + HINTERNET wsHandle = nullptr; + HINTERNET wsSession = nullptr; + HINTERNET wsConnect = nullptr; + SOCKET tcpSocket = INVALID_SOCKET; + SOCKET listenSocket = INVALID_SOCKET; // Joiner only. + + // Forwarding threads. + HANDLE wsToTcpThread = nullptr; + HANDLE tcpToWsThread = nullptr; + std::atomic stopForwarding{false}; + + CRITICAL_SECTION wsSendLock; + }; + + static RelayState g_relay; + static INIT_ONCE g_initOnce = INIT_ONCE_STATIC_INIT; + + BOOL CALLBACK InitRelayState(PINIT_ONCE, PVOID, PVOID*) + { + InitializeCriticalSection(&g_relay.lock); + InitializeCriticalSection(&g_relay.wsSendLock); + g_relay.state = Win64LceLiveRelay::ERelayState::Idle; + g_relay.initialized = true; + return TRUE; + } + + void EnsureInitialized() + { + InitOnceExecuteOnce(&g_initOnce, &InitRelayState, nullptr, nullptr); + } + + void CloseHandlesLocked() + { + g_relay.stopForwarding.store(true); + + if (g_relay.wsHandle != nullptr) + { + WinHttpWebSocketClose(g_relay.wsHandle, + WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0); + WinHttpCloseHandle(g_relay.wsHandle); + g_relay.wsHandle = nullptr; + } + if (g_relay.wsConnect != nullptr) { WinHttpCloseHandle(g_relay.wsConnect); g_relay.wsConnect = nullptr; } + if (g_relay.wsSession != nullptr) { WinHttpCloseHandle(g_relay.wsSession); g_relay.wsSession = nullptr; } + if (g_relay.tcpSocket != INVALID_SOCKET) + { + closesocket(g_relay.tcpSocket); + g_relay.tcpSocket = INVALID_SOCKET; + } + if (g_relay.listenSocket != INVALID_SOCKET) + { + closesocket(g_relay.listenSocket); + g_relay.listenSocket = INVALID_SOCKET; + } + + // Wait for forwarding threads (they read from now-closed sockets, so they'll exit). + auto waitThread = [](HANDLE& h) { + if (h != nullptr) + { + WaitForSingleObject(h, 3000); + CloseHandle(h); + h = nullptr; + } + }; + LeaveCriticalSection(&g_relay.lock); // unlock while waiting (threads may need the lock) + waitThread(g_relay.wsToTcpThread); + waitThread(g_relay.tcpToWsThread); + EnterCriticalSection(&g_relay.lock); + } + + // ------------------------------------------------------------------------- + // Start the two forwarding threads once both WS and TCP are ready. + // ------------------------------------------------------------------------- + + void StartForwarding() + { + g_relay.stopForwarding.store(false); + + auto* wsToTcpP = new ForwardWsToTcpParams(); + wsToTcpP->wsHandle = g_relay.wsHandle; + wsToTcpP->tcpSocket = g_relay.tcpSocket; + wsToTcpP->stop = &g_relay.stopForwarding; + + auto* tcpToWsP = new ForwardTcpToWsParams(); + tcpToWsP->tcpSocket = g_relay.tcpSocket; + tcpToWsP->wsHandle = g_relay.wsHandle; + tcpToWsP->wsSendLock = &g_relay.wsSendLock; + tcpToWsP->stop = &g_relay.stopForwarding; + + g_relay.wsToTcpThread = CreateThread(nullptr, 0, ForwardWsToTcpProc, wsToTcpP, 0, nullptr); + g_relay.tcpToWsThread = CreateThread(nullptr, 0, ForwardTcpToWsProc, tcpToWsP, 0, nullptr); + + g_relay.state = Win64LceLiveRelay::ERelayState::Relaying; + LCELOG("RELAY", "forwarding active — data flowing"); + } + + // ------------------------------------------------------------------------- + // Worker thread context + // ------------------------------------------------------------------------- + + struct WorkerContext + { + bool isHost; + std::string sessionId; + int tcpPort; // Host: game server port. Joiner: listen port (filled by worker). + std::string accessToken; + std::string baseUrl; + }; + + // ------------------------------------------------------------------------- + // Host worker: WS open → wait for joiner's first packet → lazy TCP connect + // + // The TCP connection to the local game server is opened LAZILY — only once + // the first binary frame arrives from the relay server (the joiner's JOIN + // packet). Connecting eagerly (before the joiner arrives) leaves a TCP + // socket idle for up to ~20 s, long enough for the game server to time it + // out and close it, causing the join to silently fail. + // ------------------------------------------------------------------------- + + DWORD WINAPI HostWorkerProc(LPVOID param) + { + auto* ctx = static_cast(param); + + // 1. Open WebSocket to the relay server. + HINTERNET hSession = nullptr, hConnect = nullptr; + HINTERNET hWs = OpenRelayWebSocket(ctx->sessionId, true, + ctx->accessToken, ctx->baseUrl, &hSession, &hConnect); + + if (!hWs) + { + EnterCriticalSection(&g_relay.lock); + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (host): failed to open WebSocket"; + LeaveCriticalSection(&g_relay.lock); + LCELOG("RELAY", "host WS open failed"); + delete ctx; + return 0; + } + + // Publish the WS handle immediately so Close() can abort the blocking + // receive below (closing the handle makes WinHttpWebSocketReceive return). + EnterCriticalSection(&g_relay.lock); + g_relay.wsHandle = hWs; + g_relay.wsSession = hSession; + g_relay.wsConnect = hConnect; + LeaveCriticalSection(&g_relay.lock); + + LCELOG("RELAY", "host WS open — session %s, game port %d — waiting for joiner", + ctx->sessionId.c_str(), ctx->tcpPort); + + // 2. Wait for the relay server to signal that the joiner's WS has connected. + // The server sends a TEXT frame "peer_connected" the instant the joiner + // arrives, BEFORE any game data flows. We do NOT open the TCP socket to + // the game server until we receive this signal: opening it eagerly leaves + // an idle socket the game server can time out in the ~15-20 s before the + // joiner arrives. + // The server also sends this before its own ForwardLoopAsync, so the signal + // is guaranteed to arrive before any binary game-data frames. However, we + // buffer any binary data that sneaks in early and forward it after TCP opens. + std::vector recvBuf(65536); + std::vector earlyBinary; // binary frames received before the signal (rare) + bool signalReceived = false; + + while (!signalReceived) + { + DWORD bytesRead = 0; + WINHTTP_WEB_SOCKET_BUFFER_TYPE bufType = + WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE; + + const DWORD err = WinHttpWebSocketReceive( + hWs, recvBuf.data(), static_cast(recvBuf.size()), + &bytesRead, &bufType); + + if (err != ERROR_SUCCESS) + { + // WS closed — session ended or Close() was called. + delete ctx; + return 0; + } + + if (bufType == WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE && bytesRead > 0) + { + const std::string text(reinterpret_cast(recvBuf.data()), bytesRead); + if (text == "peer_connected") + signalReceived = true; + // Unknown text frames are ignored. + } + else if (bufType == WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE || + bufType == WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE) + { + // Unexpected binary data before the signal — buffer it. + earlyBinary.insert(earlyBinary.end(), + recvBuf.begin(), recvBuf.begin() + bytesRead); + // A complete binary message means the joiner is definitely here. + if (bufType == WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE) + signalReceived = true; + } + } + + // 3. NOW connect TCP to the game server — the connection is fresh and the + // game server's first packet will arrive immediately (no idle-timeout risk). + LCELOG("RELAY", "host peer_connected signal — connecting to game port %d", ctx->tcpPort); + + SOCKET tcpSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (tcpSock == INVALID_SOCKET) + { + EnterCriticalSection(&g_relay.lock); + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (host): failed to create TCP socket"; + LeaveCriticalSection(&g_relay.lock); + delete ctx; + return 0; + } + + sockaddr_in sa = {}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + sa.sin_port = htons(static_cast(ctx->tcpPort)); + + // Retry briefly in case the game server isn't listening yet. + bool connected = false; + for (int attempt = 0; attempt < 8 && !connected; ++attempt) + { + if (connect(tcpSock, reinterpret_cast(&sa), sizeof(sa)) == 0) + connected = true; + else + Sleep(250); + } + + if (!connected) + { + closesocket(tcpSock); + EnterCriticalSection(&g_relay.lock); + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (host): could not connect to local game server"; + LeaveCriticalSection(&g_relay.lock); + LCELOG("RELAY", "host TCP connect to 127.0.0.1:%d failed", ctx->tcpPort); + delete ctx; + return 0; + } + + // 4. Forward any binary data buffered before the signal (normally none). + if (!earlyBinary.empty()) + { + const char* src = reinterpret_cast(earlyBinary.data()); + int remain = static_cast(earlyBinary.size()); + while (remain > 0) + { + const int sent = send(tcpSock, src, remain, 0); + if (sent <= 0) + { + closesocket(tcpSock); + LCELOG("RELAY", "host TCP send of early-buffered data failed"); + delete ctx; + return 0; + } + src += sent; + remain -= sent; + } + } + + LCELOG("RELAY", "host WS+TCP ready — session %s, game port %d", + ctx->sessionId.c_str(), ctx->tcpPort); + + // 5. Start the forwarding threads for the rest of the session. + EnterCriticalSection(&g_relay.lock); + g_relay.tcpSocket = tcpSock; // wsHandle already stored in step 1 + StartForwarding(); + LeaveCriticalSection(&g_relay.lock); + + delete ctx; + return 0; + } + + // ------------------------------------------------------------------------- + // Joiner worker: listen on local port, WS→accept connection from game + // ------------------------------------------------------------------------- + + DWORD WINAPI JoinerWorkerProc(LPVOID param) + { + auto* ctx = static_cast(param); + + // 1. Bind a local TCP listen socket on a random OS-assigned port. + SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listenSock == INVALID_SOCKET) + { + EnterCriticalSection(&g_relay.lock); + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (joiner): failed to create listen socket"; + LeaveCriticalSection(&g_relay.lock); + delete ctx; + return 0; + } + + sockaddr_in bindAddr = {}; + bindAddr.sin_family = AF_INET; + bindAddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bindAddr.sin_port = 0; // OS picks the port. + + if (::bind(listenSock, reinterpret_cast(&bindAddr), sizeof(bindAddr)) != 0 || + listen(listenSock, 1) != 0) + { + closesocket(listenSock); + EnterCriticalSection(&g_relay.lock); + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (joiner): bind/listen failed"; + LeaveCriticalSection(&g_relay.lock); + delete ctx; + return 0; + } + + // Read back the assigned port and publish it. + sockaddr_in actual = {}; + int actualLen = sizeof(actual); + getsockname(listenSock, reinterpret_cast(&actual), &actualLen); + const int localPort = ntohs(actual.sin_port); + + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = listenSock; + // Store port in lastError field as a temporary channel (read by JoinerOpen). + // We piggyback ctx->tcpPort for the return value. + LeaveCriticalSection(&g_relay.lock); + + ctx->tcpPort = localPort; + + LCELOG("RELAY", "joiner proxy listening on 127.0.0.1:%d", localPort); + + // 2. Connect relay WebSocket (in parallel with waiting for the game to connect). + HINTERNET hSession = nullptr, hConnect = nullptr; + HINTERNET hWs = OpenRelayWebSocket(ctx->sessionId, false, + ctx->accessToken, ctx->baseUrl, &hSession, &hConnect); + + if (!hWs) + { + closesocket(listenSock); + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = INVALID_SOCKET; + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (joiner): failed to open WebSocket"; + LeaveCriticalSection(&g_relay.lock); + LCELOG("RELAY", "joiner WS open failed"); + delete ctx; + return 0; + } + + // 3. Accept the local TCP connection from the game. + // Set a generous timeout on the listen socket (game may take a few seconds to call BeginJoinGame). + DWORD timeout = 30000; + setsockopt(listenSock, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout), sizeof(timeout)); + + const SOCKET gameSock = accept(listenSock, nullptr, nullptr); + closesocket(listenSock); + + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = INVALID_SOCKET; + LeaveCriticalSection(&g_relay.lock); + + if (gameSock == INVALID_SOCKET) + { + WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0); + WinHttpCloseHandle(hWs); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); + EnterCriticalSection(&g_relay.lock); + g_relay.state = Win64LceLiveRelay::ERelayState::Failed; + g_relay.lastError = "Relay (joiner): game did not connect to proxy port"; + LeaveCriticalSection(&g_relay.lock); + LCELOG("RELAY", "joiner accept() timed out — game never connected to proxy port"); + delete ctx; + return 0; + } + + LCELOG("RELAY", "joiner WS+TCP ready — forwarding started"); + + EnterCriticalSection(&g_relay.lock); + g_relay.wsHandle = hWs; + g_relay.wsSession = hSession; + g_relay.wsConnect = hConnect; + g_relay.tcpSocket = gameSock; + StartForwarding(); + LeaveCriticalSection(&g_relay.lock); + + delete ctx; + return 0; + } + +} // anonymous namespace + +// ============================================================================ +// Public API +// ============================================================================ + +namespace Win64LceLiveRelay +{ + bool HostOpen(const std::string& sessionId, int tcpGamePort) + { + EnsureInitialized(); + + EnterCriticalSection(&g_relay.lock); + if (g_relay.state != ERelayState::Idle) + { + LeaveCriticalSection(&g_relay.lock); + return false; // Already active. + } + g_relay.state = ERelayState::Connecting; + g_relay.lastError.clear(); + LeaveCriticalSection(&g_relay.lock); + + auto* ctx = new WorkerContext(); + ctx->isHost = true; + ctx->sessionId = sessionId; + ctx->tcpPort = tcpGamePort; + ctx->accessToken = Win64LceLive::GetAccessToken(); + ctx->baseUrl = GetBaseUrl(); + + HANDLE t = CreateThread(nullptr, 0, HostWorkerProc, ctx, 0, nullptr); + if (!t) + { + delete ctx; + EnterCriticalSection(&g_relay.lock); + g_relay.state = ERelayState::Failed; + g_relay.lastError = "Relay: failed to create host worker thread"; + LeaveCriticalSection(&g_relay.lock); + return false; + } + CloseHandle(t); // Thread is detached; lifetime managed by its own logic. + + LCELOG("RELAY", "host open — session %s, game port %d", sessionId.c_str(), tcpGamePort); + return true; + } + + int JoinerOpen(const std::string& sessionId) + { + EnsureInitialized(); + + EnterCriticalSection(&g_relay.lock); + if (g_relay.state != ERelayState::Idle) + { + LeaveCriticalSection(&g_relay.lock); + return 0; + } + g_relay.state = ERelayState::Connecting; + g_relay.lastError.clear(); + LeaveCriticalSection(&g_relay.lock); + + // We run the joiner worker SYNCHRONOUSLY for the bind/listen phase so we + // can return the local port to the caller before the game calls JoinGame. + // The rest (WS connect + accept) runs on the worker thread. + // + // To do this we do the bind ourselves here, publish the port, then hand off. + + SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listenSock == INVALID_SOCKET) + { + EnterCriticalSection(&g_relay.lock); + g_relay.state = ERelayState::Failed; + g_relay.lastError = "Relay (joiner): socket() failed"; + LeaveCriticalSection(&g_relay.lock); + return 0; + } + + sockaddr_in bindAddr = {}; + bindAddr.sin_family = AF_INET; + bindAddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bindAddr.sin_port = 0; + + if (::bind(listenSock, reinterpret_cast(&bindAddr), sizeof(bindAddr)) != 0 || + listen(listenSock, 1) != 0) + { + closesocket(listenSock); + EnterCriticalSection(&g_relay.lock); + g_relay.state = ERelayState::Failed; + g_relay.lastError = "Relay (joiner): bind/listen failed"; + LeaveCriticalSection(&g_relay.lock); + return 0; + } + + sockaddr_in actual = {}; + int actualLen = sizeof(actual); + getsockname(listenSock, reinterpret_cast(&actual), &actualLen); + const int localPort = ntohs(actual.sin_port); + + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = listenSock; + LeaveCriticalSection(&g_relay.lock); + + LCELOG("RELAY", "joiner open — proxy port %d, session %s", localPort, sessionId.c_str()); + + // Hand the already-bound listen socket to the worker thread via ctx. + auto* ctx = new WorkerContext(); + ctx->isHost = false; + ctx->sessionId = sessionId; + ctx->tcpPort = localPort; + ctx->accessToken = Win64LceLive::GetAccessToken(); + ctx->baseUrl = GetBaseUrl(); + + // Spawn a simplified version of JoinerWorkerProc that skips the bind step + // (we already did it and stored listenSock in g_relay.listenSocket). + // We use a lambda-like approach via a separate proc. + + struct AcceptCtx { + WorkerContext* ctx; + SOCKET listenSock; + }; + + auto* actx = new AcceptCtx(); + actx->ctx = ctx; + actx->listenSock = listenSock; + + HANDLE t = CreateThread(nullptr, 0, [](LPVOID param) -> DWORD { + auto* actx = static_cast(param); + WorkerContext* ctx = actx->ctx; + SOCKET listenSock = actx->listenSock; + delete actx; + + // Connect relay WebSocket. + HINTERNET hSession = nullptr, hConnect = nullptr; + HINTERNET hWs = OpenRelayWebSocket(ctx->sessionId, false, + ctx->accessToken, ctx->baseUrl, &hSession, &hConnect); + + if (!hWs) + { + closesocket(listenSock); + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = INVALID_SOCKET; + g_relay.state = ERelayState::Failed; + g_relay.lastError = "Relay (joiner): WebSocket open failed"; + LeaveCriticalSection(&g_relay.lock); + LCELOG("RELAY", "joiner WS failed"); + delete ctx; + return 0; + } + + // Accept the game's connection. + DWORD timeout = 30000; + setsockopt(listenSock, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout), sizeof(timeout)); + + const SOCKET gameSock = accept(listenSock, nullptr, nullptr); + closesocket(listenSock); + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = INVALID_SOCKET; + LeaveCriticalSection(&g_relay.lock); + + if (gameSock == INVALID_SOCKET) + { + WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0); + WinHttpCloseHandle(hWs); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); + EnterCriticalSection(&g_relay.lock); + g_relay.state = ERelayState::Failed; + g_relay.lastError = "Relay (joiner): game never connected to proxy"; + LeaveCriticalSection(&g_relay.lock); + LCELOG("RELAY", "joiner accept() timed out"); + delete ctx; + return 0; + } + + LCELOG("RELAY", "joiner WS+TCP ready — forwarding"); + + EnterCriticalSection(&g_relay.lock); + g_relay.wsHandle = hWs; + g_relay.wsSession = hSession; + g_relay.wsConnect = hConnect; + g_relay.tcpSocket = gameSock; + StartForwarding(); + LeaveCriticalSection(&g_relay.lock); + + delete ctx; + return 0; + }, actx, 0, nullptr); + + if (!t) + { + delete actx; // Also deletes ctx + closesocket(listenSock); + EnterCriticalSection(&g_relay.lock); + g_relay.listenSocket = INVALID_SOCKET; + g_relay.state = ERelayState::Failed; + g_relay.lastError = "Relay: failed to create joiner worker thread"; + LeaveCriticalSection(&g_relay.lock); + return 0; + } + CloseHandle(t); + + return localPort; + } + + void Close() + { + EnsureInitialized(); + + EnterCriticalSection(&g_relay.lock); + CloseHandlesLocked(); + g_relay.state = ERelayState::Closed; + g_relay.lastError.clear(); + LeaveCriticalSection(&g_relay.lock); + + LCELOG("RELAY", "closed"); + } + + RelaySnapshot GetSnapshot() + { + EnsureInitialized(); + + RelaySnapshot snap = {}; + + EnterCriticalSection(&g_relay.lock); + snap.state = g_relay.state; + + switch (g_relay.state) + { + case ERelayState::Idle: + snap.statusMessage = L"Relay: idle."; + break; + case ERelayState::Connecting: + snap.statusMessage = L"Relay: connecting to relay server..."; + break; + case ERelayState::Relaying: + snap.statusMessage = L"Relay: active (routing via server)."; + break; + case ERelayState::Failed: + snap.statusMessage = L"Relay: failed."; + snap.errorMessage = std::wstring(g_relay.lastError.begin(), g_relay.lastError.end()); + break; + case ERelayState::Closed: + snap.statusMessage = L"Relay: closed."; + break; + } + + LeaveCriticalSection(&g_relay.lock); + return snap; + } + +} // namespace Win64LceLiveRelay + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_LceLiveRelay.h b/Minecraft.Client/Windows64/Windows64_LceLiveRelay.h new file mode 100644 index 00000000..13e31d0c --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLiveRelay.h @@ -0,0 +1,72 @@ +#pragma once + +#ifdef _WINDOWS64 + +#include + +// ============================================================================ +// Win64LceLiveRelay — TCP-over-WebSocket relay client +// +// Routes Minecraft game traffic through the LCELive relay server when direct +// TCP is blocked (symmetric NAT, campus/hotel WiFi, etc.). +// +// Host side: +// Call HostOpen(sessionId, tcpPort) right after signaling HostConnect(). +// The relay client connects to the relay WebSocket as "host", then connects +// a TCP socket to 127.0.0.1:tcpPort (the running game server). +// All data is forwarded bidirectionally between the two. +// +// Joiner side: +// Call JoinerOpen(sessionId) before JoinGameFromInviteInfo(). +// Returns a local TCP port. Tell the game to connect to 127.0.0.1:. +// The relay client accepts that connection, connects to the relay WebSocket +// as "joiner", and forwards all data bidirectionally. +// ============================================================================ + +namespace Win64LceLiveRelay +{ + enum class ERelayState + { + Idle, // Not started. + Connecting, // Opening WebSocket + TCP connections. + Relaying, // Fully active; data is flowing. + Failed, // Fatal error — see snapshot.errorMessage. + Closed, // Cleanly torn down. + }; + + struct RelaySnapshot + { + ERelayState state; + std::wstring statusMessage; + std::wstring errorMessage; + }; + + // ---- Host side ---------------------------------------------------------- + + // Open the host relay for the given signaling session. + // tcpGamePort: the local TCP port the game server is listening on (usually 25565). + // Returns false if a relay is already active (call Close() first). + bool HostOpen(const std::string& sessionId, int tcpGamePort); + + // ---- Joiner side -------------------------------------------------------- + + // Open the joiner relay for the given signaling session. + // Returns the local TCP port the game should connect to, or 0 on error. + int JoinerOpen(const std::string& sessionId); + + // ---- Common ------------------------------------------------------------- + + // Tear down any active relay connections and reset to Idle. + void Close(); + + // Thread-safe snapshot of current relay state. + RelaySnapshot GetSnapshot(); +} + +// Set by the joiner when it tries direct TCP but has a relay proxy port ready as +// fallback. The main Tick monitors WinsockNetLayer::GetJoinState(); if the direct +// attempt fails it retries automatically via this port (mirrors Xbox Live's TURN +// fallback when direct UDP hole-punch fails). Cleared after the fallback fires. +extern int g_LceLiveRelayFallbackPort; + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_LceLiveSignaling.cpp b/Minecraft.Client/Windows64/Windows64_LceLiveSignaling.cpp new file mode 100644 index 00000000..f8ee7628 --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLiveSignaling.cpp @@ -0,0 +1,724 @@ +#include "stdafx.h" + +#ifdef _WINDOWS64 + +#include +#include +#include +#include // CoCreateGuid + +#include "Windows64_LceLiveSignaling.h" +#include "Windows64_LceLive.h" +#include "Windows64_LceLiveP2P.h" +#include "Windows64_Log.h" +#include "../../../Minecraft.Server/vendor/nlohmann/json.hpp" + +#include +#include +#include + +#pragma comment(lib, "Winhttp.lib") + +// ============================================================================ +// Internal implementation +// ============================================================================ + +namespace +{ + using Json = nlohmann::json; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + std::wstring Utf8ToWideLocal(const std::string& s) + { + if (s.empty()) return L""; + const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); + if (n <= 0) return L""; + std::wstring out(static_cast(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &out[0], n); + if (!out.empty() && out.back() == L'\0') out.pop_back(); + return out; + } + + std::string WideToUtf8Local(const std::wstring& s) + { + if (s.empty()) return ""; + const int n = WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, nullptr, 0, nullptr, nullptr); + if (n <= 0) return ""; + std::string out(static_cast(n), '\0'); + WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, &out[0], n, nullptr, nullptr); + if (!out.empty() && out.back() == '\0') out.pop_back(); + return out; + } + + // Generate a lowercase hyphenated UUID string (e.g. "550e8400-e29b-41d4-a716-446655440000"). + std::string GenerateUuid() + { + GUID guid = {}; + CoCreateGuid(&guid); + + char buf[40] = {}; + snprintf(buf, sizeof(buf), + "%08lx-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + guid.Data1, guid.Data2, guid.Data3, + guid.Data4[0], guid.Data4[1], + guid.Data4[2], guid.Data4[3], guid.Data4[4], + guid.Data4[5], guid.Data4[6], guid.Data4[7]); + return buf; + } + + // Read the API base URL the same way Windows64_LceLive.cpp does: + // LCELIVE_API_BASE_URL env var → lcelive.properties → localhost:5187. + std::string GetBaseUrl() + { + char envValue[512] = {}; + const DWORD len = GetEnvironmentVariableA("LCELIVE_API_BASE_URL", envValue, + static_cast(sizeof(envValue))); + if (len > 0 && len < sizeof(envValue)) + return std::string(envValue); + + // Try lcelive.properties next to the .exe + char exePath[MAX_PATH] = {}; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string props(exePath); + const size_t lastSlash = props.find_last_of("\\/"); + if (lastSlash != std::string::npos) + props = props.substr(0, lastSlash + 1); + props += "lcelive.properties"; + + FILE* f = nullptr; + if (fopen_s(&f, props.c_str(), "rb") == 0 && f != nullptr) + { + char line[512] = {}; + while (fgets(line, sizeof(line), f) != nullptr) + { + std::string s(line); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back(); + if (s.substr(0, 12) == "api_base_url") + { + const size_t eq = s.find('='); + if (eq != std::string::npos) { fclose(f); return s.substr(eq + 1); } + } + } + fclose(f); + } + + return "http://localhost:5187"; + } + + // URL-percent-encode a string (for safe use in query parameters). + std::string UrlEncode(const std::string& s) + { + std::string out; + out.reserve(s.size() * 3); + for (unsigned char c : s) + { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + out += static_cast(c); + else + { + char hex[4] = {}; + snprintf(hex, sizeof(hex), "%%%02X", c); + out += hex; + } + } + return out; + } + + // Build the JSON candidate message we send over the signaling channel. + std::string BuildCandidateJson(const std::string& ip, int port, + Win64LceLiveP2P::EConnMethod method) + { + const char* methodStr = + (method == Win64LceLiveP2P::EConnMethod::UPnP) ? "upnp" : "stun"; + + Json j; + j["type"] = "candidate"; + j["ip"] = ip; + j["port"] = port; + j["method"] = methodStr; + return j.dump(); + } + + // ------------------------------------------------------------------------- + // Worker context — passed to the background thread + // ------------------------------------------------------------------------- + + struct WorkerContext + { + bool isHost; + std::string sessionId; + std::string ourIp; + int ourPort; + Win64LceLiveP2P::EConnMethod ourMethod; + std::string accessToken; + std::string baseUrl; + + // Written by worker to signal results back to main thread. + volatile bool workerDone; + bool wsConnected; // WebSocket opened successfully + bool peerReceived; // Peer candidate decoded + std::string peerIp; + int peerPort; + bool peerNeedsHolePunch; + std::string errorMessage; + + // Handle the main thread can close to unblock a stuck WinHttpWebSocketReceive. + volatile HINTERNET wsHandle; + }; + + // ------------------------------------------------------------------------- + // Runtime state + // ------------------------------------------------------------------------- + + struct SignalingState + { + bool initialized; + CRITICAL_SECTION lock; + + Win64LceLiveSignaling::ESignalingState state; + std::string sessionId; + std::string peerIp; + int peerPort; + bool peerNeedsHolePunch; + std::string lastError; + + // Set by PrepareJoin(); cleared when JoinerConnect() fires. + std::string pendingJoinerSessionId; + + HANDLE workerThread; + WorkerContext* workerCtx; + }; + + static SignalingState g_sig = {}; + static INIT_ONCE g_initOnce = INIT_ONCE_STATIC_INIT; + + BOOL CALLBACK InitSignalingState(PINIT_ONCE, PVOID, PVOID*) + { + InitializeCriticalSection(&g_sig.lock); + g_sig.state = Win64LceLiveSignaling::ESignalingState::Idle; + g_sig.initialized = true; + return TRUE; + } + + void EnsureInitialized() + { + InitOnceExecuteOnce(&g_initOnce, &InitSignalingState, nullptr, nullptr); + } + + // ------------------------------------------------------------------------- + // WebSocket worker thread + // ------------------------------------------------------------------------- + + DWORD WINAPI SignalingWorkerProc(LPVOID param) + { + WorkerContext* ctx = static_cast(param); + + // ---- Parse the base URL ---- + const std::wstring baseUrlW = Utf8ToWideLocal(ctx->baseUrl); + + std::vector hostBuf(256, 0); + std::vector pathBuf(2048, 0); + + URL_COMPONENTSW components = {}; + components.dwStructSize = sizeof(components); + components.lpszHostName = hostBuf.data(); + components.dwHostNameLength = static_cast(hostBuf.size()); + components.lpszUrlPath = pathBuf.data(); + components.dwUrlPathLength = static_cast(pathBuf.size()); + + if (!WinHttpCrackUrl(baseUrlW.c_str(), static_cast(baseUrlW.size()), 0, &components)) + { + ctx->errorMessage = "Signaling: WinHttpCrackUrl failed for base URL"; + ctx->workerDone = true; + return 0; + } + + const bool secure = (components.nScheme == INTERNET_SCHEME_HTTPS); + const std::wstring hostW(components.lpszHostName, components.dwHostNameLength); + + // Build the WebSocket path including query params. + // Auth goes in the Authorization header (server prefers that over ?token=). + const std::wstring basePath = components.lpszUrlPath + ? std::wstring(components.lpszUrlPath, components.dwUrlPathLength) + : L""; + + const std::wstring wsPath = basePath + L"/api/signaling/ws" + + L"?sessionId=" + Utf8ToWideLocal(ctx->sessionId) + + L"&role=" + Utf8ToWideLocal(ctx->isHost ? "host" : "joiner"); + + LCELOG("SIG", "connecting to %s%ls (role=%s sessionId=%s)", + secure ? "wss://" : "ws://", + (hostW + wsPath).c_str(), + ctx->isHost ? "host" : "joiner", + ctx->sessionId.c_str()); + + // ---- Open WinHTTP session ---- + HINTERNET hSession = WinHttpOpen( + L"MCLCE-LceLive/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + + if (hSession == nullptr) + { + ctx->errorMessage = "Signaling: WinHttpOpen failed"; + ctx->workerDone = true; + return 0; + } + + WinHttpSetTimeouts(hSession, 10000, 10000, 30000, 30000); + + HINTERNET hConnect = WinHttpConnect(hSession, hostW.c_str(), components.nPort, 0); + if (hConnect == nullptr) + { + WinHttpCloseHandle(hSession); + ctx->errorMessage = "Signaling: WinHttpConnect failed"; + ctx->workerDone = true; + return 0; + } + + // ---- Open GET request (will be upgraded to WebSocket) ---- + const DWORD flags = secure ? WINHTTP_FLAG_SECURE : 0; + HINTERNET hRequest = WinHttpOpenRequest( + hConnect, + L"GET", + wsPath.c_str(), + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + flags); + + if (hRequest == nullptr) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + ctx->errorMessage = "Signaling: WinHttpOpenRequest failed"; + ctx->workerDone = true; + return 0; + } + + // Mark this request for WebSocket upgrade BEFORE sending. + WinHttpSetOption(hRequest, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0); + + // Add Authorization header. + if (!ctx->accessToken.empty()) + { + const std::wstring authHeader = + L"Authorization: Bearer " + Utf8ToWideLocal(ctx->accessToken); + WinHttpAddRequestHeaders(hRequest, + authHeader.c_str(), + static_cast(authHeader.size()), + WINHTTP_ADDREQ_FLAG_ADD); + } + + // Send the upgrade request. + if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + ctx->errorMessage = "Signaling: WinHttpSendRequest failed (WSA " + + std::to_string(GetLastError()) + ")"; + ctx->workerDone = true; + return 0; + } + + if (!WinHttpReceiveResponse(hRequest, nullptr)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + ctx->errorMessage = "Signaling: WinHttpReceiveResponse failed — server may be down"; + ctx->workerDone = true; + return 0; + } + + // ---- Complete WebSocket upgrade ---- + HINTERNET hWs = WinHttpWebSocketCompleteUpgrade(hRequest, 0); + WinHttpCloseHandle(hRequest); + + if (hWs == nullptr) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + ctx->errorMessage = "Signaling: WebSocket upgrade failed — server returned non-101"; + ctx->workerDone = true; + return 0; + } + + // Make the handle visible to the main thread so Close() can abort the receive. + ctx->wsHandle = hWs; + ctx->wsConnected = true; + + LCELOG("SIG", "WebSocket connected (session %s)", ctx->sessionId.c_str()); + + // ---- Send our P2P candidate ---- + const std::string candidateJson = + BuildCandidateJson(ctx->ourIp, ctx->ourPort, ctx->ourMethod); + + DWORD sendResult = WinHttpWebSocketSend( + hWs, + WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE, + const_cast(reinterpret_cast(candidateJson.c_str())), + static_cast(candidateJson.size())); + + if (sendResult != ERROR_SUCCESS) + { + WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0); + WinHttpCloseHandle(hWs); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + ctx->wsHandle = nullptr; + ctx->errorMessage = "Signaling: failed to send candidate"; + ctx->workerDone = true; + return 0; + } + + LCELOG("SIG", "candidate sent %s", candidateJson.c_str()); + + // ---- Receive loop: wait for peer's candidate ---- + // We also pass through joiner_connected notifications (host only) so the + // log shows when the joiner arrives. + std::vector recvBuf(8192); + bool done = false; + + while (!done) + { + DWORD bytesRead = 0; + WINHTTP_WEB_SOCKET_BUFFER_TYPE bufType = WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE; + + const DWORD recvErr = WinHttpWebSocketReceive( + hWs, + recvBuf.data(), + static_cast(recvBuf.size() - 1), + &bytesRead, + &bufType); + + if (recvErr != ERROR_SUCCESS) + { + // Closed from main thread (Close() called) or network error. + LCELOG("SIG", "receive ended (%lu)", recvErr); + break; + } + + if (bufType != WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE && + bufType != WINHTTP_WEB_SOCKET_UTF8_FRAGMENT_BUFFER_TYPE) + continue; // binary or close frame — ignore + + recvBuf[bytesRead] = 0; + const std::string msg(reinterpret_cast(recvBuf.data()), bytesRead); + + LCELOG("SIG", "recv %s", msg.c_str()); + + try + { + const Json j = Json::parse(msg); + const std::string type = j.value("type", ""); + + if (type == "joiner_connected") + { + // Host-only: the joiner has arrived on the signaling channel. + // Their candidate will follow shortly. + LCELOG("SIG", "joiner connected on session %s", ctx->sessionId.c_str()); + } + else if (type == "candidate") + { + const std::string peerIp = j.value("ip", ""); + const int peerPort = j.value("port", 0); + const std::string methodStr = j.value("method", "stun"); + const bool holePunch = (methodStr == "stun"); + + if (!peerIp.empty() && peerPort > 0) + { + ctx->peerIp = peerIp; + ctx->peerPort = peerPort; + ctx->peerNeedsHolePunch = holePunch; + ctx->peerReceived = true; + + LCELOG("SIG", "peer endpoint %s:%d (method=%s)", + peerIp.c_str(), peerPort, methodStr.c_str()); + + done = true; // Both candidates exchanged — we're done here. + } + } + } + catch (...) {} // Malformed JSON — skip + } + + // ---- Clean up ---- + WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0); + WinHttpCloseHandle(hWs); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + ctx->wsHandle = nullptr; + ctx->workerDone = true; + return 0; + } + + // ------------------------------------------------------------------------- + // Common start helper (host & joiner share 90% of setup) + // ------------------------------------------------------------------------- + + bool StartWorker(bool isHost, + const std::string& sessionId, + const std::string& externalIp, int externalPort, + Win64LceLiveP2P::EConnMethod method) + { + EnsureInitialized(); + + EnterCriticalSection(&g_sig.lock); + if (g_sig.state != Win64LceLiveSignaling::ESignalingState::Idle) + { + LeaveCriticalSection(&g_sig.lock); + return false; + } + + WorkerContext* ctx = new WorkerContext(); + ctx->isHost = isHost; + ctx->sessionId = sessionId; + ctx->ourIp = externalIp; + ctx->ourPort = externalPort; + ctx->ourMethod = method; + ctx->accessToken = Win64LceLive::GetAccessToken(); + ctx->baseUrl = GetBaseUrl(); + ctx->workerDone = false; + ctx->wsConnected = false; + ctx->peerReceived = false; + ctx->peerPort = 0; + ctx->peerNeedsHolePunch = false; + ctx->wsHandle = nullptr; + + g_sig.state = Win64LceLiveSignaling::ESignalingState::Connecting; + g_sig.sessionId = sessionId; + g_sig.peerPort = 0; + g_sig.lastError.clear(); + g_sig.peerIp.clear(); + g_sig.workerCtx = ctx; + + g_sig.workerThread = CreateThread(nullptr, 0, &SignalingWorkerProc, ctx, 0, nullptr); + if (g_sig.workerThread == nullptr) + { + g_sig.state = Win64LceLiveSignaling::ESignalingState::Failed; + g_sig.lastError = "Signaling: failed to create worker thread"; + delete ctx; + g_sig.workerCtx = nullptr; + LeaveCriticalSection(&g_sig.lock); + return false; + } + + LeaveCriticalSection(&g_sig.lock); + return true; + } + +} // anonymous namespace + +// ============================================================================ +// Public API +// ============================================================================ + +namespace Win64LceLiveSignaling +{ + bool HostConnect(const std::string& externalIp, int externalPort, + Win64LceLiveP2P::EConnMethod method) + { + const std::string sessionId = GenerateUuid(); + LCELOG("SIG", "hosting session %s endpoint %s:%d", + sessionId.c_str(), externalIp.c_str(), externalPort); + return StartWorker(true, sessionId, externalIp, externalPort, method); + } + + bool JoinerConnect(const std::string& sessionId, + const std::string& externalIp, int externalPort, + Win64LceLiveP2P::EConnMethod method) + { + // Clear the pending ID — we're acting on it now. + EnterCriticalSection(&g_sig.lock); + g_sig.pendingJoinerSessionId.clear(); + LeaveCriticalSection(&g_sig.lock); + + LCELOG("SIG", "joining session %s our endpoint %s:%d", + sessionId.c_str(), externalIp.c_str(), externalPort); + return StartWorker(false, sessionId, externalIp, externalPort, method); + } + + void PrepareJoin(const std::string& sessionId) + { + EnsureInitialized(); + EnterCriticalSection(&g_sig.lock); + g_sig.pendingJoinerSessionId = sessionId; + LeaveCriticalSection(&g_sig.lock); + LCELOG("SIG", "joiner session ID stored (%s) — waiting for P2P", sessionId.c_str()); + } + + std::string GetPendingJoinerSessionId() + { + EnsureInitialized(); + EnterCriticalSection(&g_sig.lock); + const std::string id = g_sig.pendingJoinerSessionId; + LeaveCriticalSection(&g_sig.lock); + return id; + } + + void Tick() + { + EnsureInitialized(); + + EnterCriticalSection(&g_sig.lock); + + if (g_sig.workerCtx == nullptr || + (g_sig.state != ESignalingState::Connecting && + g_sig.state != ESignalingState::Connected)) + { + LeaveCriticalSection(&g_sig.lock); + return; + } + + WorkerContext* ctx = g_sig.workerCtx; + + // Promote Connecting → Connected once WebSocket is open. + if (g_sig.state == ESignalingState::Connecting && ctx->wsConnected) + g_sig.state = ESignalingState::Connected; + + // Integrate completed peer exchange. + if (ctx->workerDone) + { + HANDLE t = g_sig.workerThread; + g_sig.workerThread = nullptr; + LeaveCriticalSection(&g_sig.lock); + + if (t != nullptr) + { + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + } + + EnterCriticalSection(&g_sig.lock); + if (ctx->peerReceived) + { + g_sig.state = ESignalingState::PeerKnown; + g_sig.peerIp = ctx->peerIp; + g_sig.peerPort = ctx->peerPort; + g_sig.peerNeedsHolePunch = ctx->peerNeedsHolePunch; + LCELOG("SIG", "peer known %s:%d (holePunch=%d)", + ctx->peerIp.c_str(), ctx->peerPort, ctx->peerNeedsHolePunch ? 1 : 0); + } + else + { + g_sig.state = ESignalingState::Failed; + g_sig.lastError = ctx->errorMessage.empty() + ? "Signaling: connection closed before peer candidate received" + : ctx->errorMessage; + LCELOG("SIG", "failed — %s", g_sig.lastError.c_str()); + } + + delete ctx; + g_sig.workerCtx = nullptr; + } + + LeaveCriticalSection(&g_sig.lock); + } + + SignalingSnapshot GetSnapshot() + { + EnsureInitialized(); + Tick(); + + SignalingSnapshot snap = {}; + + EnterCriticalSection(&g_sig.lock); + snap.state = g_sig.state; + snap.sessionId = g_sig.sessionId; + snap.peerIp = g_sig.peerIp; + snap.peerPort = g_sig.peerPort; + snap.peerNeedsHolePunch = g_sig.peerNeedsHolePunch; + + switch (g_sig.state) + { + case ESignalingState::Idle: + snap.statusMessage = L"Signaling: idle."; + break; + case ESignalingState::Connecting: + snap.statusMessage = L"Signaling: connecting to relay..."; + break; + case ESignalingState::Connected: + snap.statusMessage = L"Signaling: waiting for peer..."; + break; + case ESignalingState::PeerKnown: + { + wchar_t buf[256] = {}; + swprintf_s(buf, L"Signaling: peer at %hs:%d (%s)", + g_sig.peerIp.c_str(), g_sig.peerPort, + g_sig.peerNeedsHolePunch ? L"hole punch" : L"direct"); + snap.statusMessage = buf; + break; + } + case ESignalingState::Failed: + snap.statusMessage = L"Signaling: failed."; + snap.errorMessage = std::wstring(g_sig.lastError.begin(), g_sig.lastError.end()); + break; + case ESignalingState::Closed: + snap.statusMessage = L"Signaling: closed."; + break; + } + + LeaveCriticalSection(&g_sig.lock); + return snap; + } + + void Close() + { + EnsureInitialized(); + + // Unblock any in-progress WinHttpWebSocketReceive by closing the handle. + HINTERNET wsToClose = nullptr; + EnterCriticalSection(&g_sig.lock); + if (g_sig.workerCtx != nullptr) + wsToClose = g_sig.workerCtx->wsHandle; + LeaveCriticalSection(&g_sig.lock); + + if (wsToClose != nullptr) + { + // Sending a Close frame unblocks the receive on the worker thread. + WinHttpWebSocketClose(wsToClose, + WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0); + } + + // Now wait for the worker to finish. + HANDLE t = nullptr; + EnterCriticalSection(&g_sig.lock); + t = g_sig.workerThread; + LeaveCriticalSection(&g_sig.lock); + + if (t != nullptr) + { + WaitForSingleObject(t, 5000); + CloseHandle(t); + } + + EnterCriticalSection(&g_sig.lock); + if (g_sig.workerCtx != nullptr) + { + delete g_sig.workerCtx; + g_sig.workerCtx = nullptr; + } + g_sig.workerThread = nullptr; + g_sig.state = ESignalingState::Closed; + g_sig.sessionId.clear(); + g_sig.peerIp.clear(); + g_sig.peerPort = 0; + g_sig.peerNeedsHolePunch = false; + g_sig.lastError.clear(); + g_sig.pendingJoinerSessionId.clear(); + LeaveCriticalSection(&g_sig.lock); + + LCELOG("SIG", "closed"); + } + +} // namespace Win64LceLiveSignaling + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_LceLiveSignaling.h b/Minecraft.Client/Windows64/Windows64_LceLiveSignaling.h new file mode 100644 index 00000000..ebf9830f --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLiveSignaling.h @@ -0,0 +1,75 @@ +#pragma once + +#ifdef _WINDOWS64 + +#include +#include "Windows64_LceLiveP2P.h" + +namespace Win64LceLiveSignaling +{ + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + enum class ESignalingState + { + Idle, + Connecting, // Worker thread in progress; WebSocket not yet open. + Connected, // WebSocket open; own candidate sent; waiting for peer. + PeerKnown, // Peer candidate received; ready for hole punching. + Failed, // Unrecoverable error; see SignalingSnapshot::errorMessage. + Closed, // Cleanly closed. + }; + + struct SignalingSnapshot + { + ESignalingState state; + std::string sessionId; // UUID; set once HostConnect() is called. + std::string peerIp; // Set once PeerKnown. + int peerPort; // Set once PeerKnown. 0 until then. + bool peerNeedsHolePunch; // true = STUN; false = UPnP direct connect. + std::wstring statusMessage; // Human-readable; always set. + std::wstring errorMessage; // Non-empty only on Failed. + }; + + // ------------------------------------------------------------------------- + // API + // ------------------------------------------------------------------------- + + // Host: generate a session UUID, connect to the signaling server, publish + // our P2P endpoint. Non-blocking — transition to Connecting is immediate. + // Returns false if already active (call Close() first). + bool HostConnect(const std::string& externalIp, int externalPort, + Win64LceLiveP2P::EConnMethod method); + + // Joiner: connect to an existing signaling session, exchange endpoints. + // sessionId comes from the game invite. Non-blocking. + // Returns false if already active. + bool JoinerConnect(const std::string& sessionId, + const std::string& externalIp, int externalPort, + Win64LceLiveP2P::EConnMethod method); + + // Joiner pre-connect: store a session ID so that the frame-loop Tick + // can call JoinerConnect automatically once P2P discovery finishes. + // Call this immediately after accepting an invite that carries a session ID. + void PrepareJoin(const std::string& sessionId); + + // Returns the pending joiner session ID set by PrepareJoin(), or empty if + // none is pending. Cleared automatically when JoinerConnect() is called. + std::string GetPendingJoinerSessionId(); + + // Drive the state machine. Call once per frame from the game loop, + // same cadence as Win64LceLiveP2P::P2PTick(). + void Tick(); + + // Thread-safe snapshot of current state. + // Calls Tick() internally. + SignalingSnapshot GetSnapshot(); + + // Close the signaling connection and reset to Idle. + // Blocks briefly to join the worker thread. + void Close(); + +} // namespace Win64LceLiveSignaling + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_Log.cpp b/Minecraft.Client/Windows64/Windows64_Log.cpp new file mode 100644 index 00000000..09c7e661 --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_Log.cpp @@ -0,0 +1,97 @@ +#include "stdafx.h" + +#ifdef _WINDOWS64 + +#include "Windows64_Log.h" + +#include +#include +#include + +namespace LceLog +{ + +static FILE* s_logFile = nullptr; + +// --------------------------------------------------------------------------- +// Init — open latest.log (rotating the previous one). +// --------------------------------------------------------------------------- +void Init() +{ + // Rotate: latest → previous + rename("latest.log", "previous.log"); + + if (fopen_s(&s_logFile, "latest.log", "w") != 0 || !s_logFile) + { + s_logFile = nullptr; + OutputDebugStringA("[LceLog] Failed to open latest.log\n"); + return; + } + + // Write header so it's easy to tell sessions apart. + SYSTEMTIME st = {}; + GetLocalTime(&st); + char header[128]; + snprintf(header, sizeof(header), + "=== LCELive session started %04d-%02d-%02d %02d:%02d:%02d ===\n", + st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond); + fputs(header, s_logFile); + fflush(s_logFile); +} + +// --------------------------------------------------------------------------- +// Shutdown — flush and close. +// --------------------------------------------------------------------------- +void Shutdown() +{ + if (!s_logFile) + return; + + fputs("=== session ended ===\n", s_logFile); + fflush(s_logFile); + fclose(s_logFile); + s_logFile = nullptr; +} + +// --------------------------------------------------------------------------- +// Write — timestamped log entry. Safe to call before Init (silently skipped) +// and from any thread (FILE* writes are internally serialised on MSVC CRT). +// --------------------------------------------------------------------------- +void Write(const char* fmt, ...) +{ + if (!s_logFile && !IsDebuggerPresent()) + return; + + // Build timestamp + SYSTEMTIME st = {}; + GetLocalTime(&st); + char timeBuf[24]; + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d.%03d", + st.wHour, st.wMinute, st.wSecond, st.wMilliseconds); + + // Format the caller's message + char msgBuf[2048]; + va_list args; + va_start(args, fmt); + vsnprintf(msgBuf, sizeof(msgBuf) - 1, fmt, args); + va_end(args); + msgBuf[sizeof(msgBuf) - 1] = '\0'; + + // Full line: "[HH:MM:SS.mmm] message\n" + char line[2048 + 32]; + snprintf(line, sizeof(line), "[%s] %s\n", timeBuf, msgBuf); + + if (s_logFile) + { + fputs(line, s_logFile); + fflush(s_logFile); // flush every write — we want to see crashes + } + + // Also send to VS Output window when a debugger is attached. + OutputDebugStringA(line); +} + +} // namespace LceLog + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_Log.h b/Minecraft.Client/Windows64/Windows64_Log.h new file mode 100644 index 00000000..24fae1a6 --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_Log.h @@ -0,0 +1,37 @@ +#pragma once + +#ifdef _WINDOWS64 + +// ============================================================================ +// LceLog — lightweight file logger +// +// Writes a timestamped latest.log next to the exe on every run. +// Any previous run's log is renamed to previous.log. +// +// All writes use fputs/fflush directly (never printf) so the logger works in +// every build configuration, including _FINAL_BUILD where printf is disabled. +// Stdout is also redirected to the file so existing printf debug output in +// non-final builds lands in the log automatically. +// +// Usage: +// LceLog::Init(); // call once, early in WinMain +// LceLog::Write("Connected to %s", ip); // any time +// LceLog::Shutdown(); // call once, at exit +// +// Convenience macro with a fixed category tag: +// LCELOG("RELAY", "host opened for session %s", sid.c_str()); +// ============================================================================ + +#include + +namespace LceLog +{ + void Init(); + void Shutdown(); + void Write(const char* fmt, ...); +} + +#define LCELOG(category, fmt, ...) \ + LceLog::Write("[" category "] " fmt, ##__VA_ARGS__) + +#endif // _WINDOWS64 diff --git a/Minecraft.Client/Windows64/Windows64_Minecraft.cpp b/Minecraft.Client/Windows64/Windows64_Minecraft.cpp index 875717f6..a22b35e3 100644 --- a/Minecraft.Client/Windows64/Windows64_Minecraft.cpp +++ b/Minecraft.Client/Windows64/Windows64_Minecraft.cpp @@ -48,6 +48,11 @@ #include "../GameRenderer.h" #include "Network/WinsockNetLayer.h" #include "Windows64_Xuid.h" +#include "Windows64_LceLive.h" +#include "Windows64_LceLiveP2P.h" +#include "Windows64_LceLiveSignaling.h" +#include "Windows64_LceLiveRelay.h" +#include "Windows64_Log.h" #include "Common/UI/UI.h" // Forward-declare the internal Renderer class and its global instance from 4J_Render_PC_d.lib. @@ -1321,6 +1326,10 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, if (pSlash) { *(pSlash + 1) = '\0'; SetCurrentDirectoryA(szExeDir); } } + // Open latest.log next to the exe so diagnostics are available in any + // build type without needing a debugger attached. + LceLog::Init(); + // Declare DPI awareness so GetSystemMetrics returns physical pixels SetProcessDPIAware(); // Use the native monitor resolution for the window and swap chain, @@ -1656,6 +1665,94 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, g_NetworkManager.DoWork(); PIXEndNamedEvent(); + // LceLive platform service ticks — keep auth token fresh, drive P2P + signaling. + Win64LceLive::Tick(); + Win64LceLiveP2P::P2PTick(); + Win64LceLiveSignaling::Tick(); + + // Auto-connect signaling once P2P discovery completes. + // Host path: HostOpen() is called from PlatformNetworkManagerStub on HostGame(). + // On the first Ready frame while the session is active → HostConnect(). + // Joiner path: HostOpen() + PrepareJoin(sessionId) are called from + // UIScene_LceLiveRequests when the invite is accepted. + // JoinerConnect() fires on every Ready frame until the pending ID is + // consumed — deliberately NOT gated on IsActive() because the relay TCP + // connection may still be completing at the exact frame STUN finishes + // (1-frame race that previously caused the signaling exchange to be + // skipped entirely, leaving the host's WS to time out and drop the relay). + { + static Win64LceLiveP2P::EP2PState s_lastP2PState = Win64LceLiveP2P::EP2PState::Idle; + const Win64LceLiveP2P::P2PSnapshot p2pSnap = Win64LceLiveP2P::GetP2PSnapshot(); + + if (p2pSnap.state == Win64LceLiveP2P::EP2PState::Ready) + { + const Win64LceLiveSignaling::ESignalingState sigState = + Win64LceLiveSignaling::GetSnapshot().state; + + // Host: edge-trigger on first Ready frame (requires active session). + if (WinsockNetLayer::IsHosting() && + WinsockNetLayer::IsActive() && + s_lastP2PState != Win64LceLiveP2P::EP2PState::Ready && + sigState == Win64LceLiveSignaling::ESignalingState::Idle) + { + Win64LceLiveSignaling::HostConnect( + p2pSnap.externalIp, p2pSnap.externalPort, p2pSnap.connMethod); + + // Always open the relay channel — same as Xbox Live's TURN allocation. + // If the joiner connects directly (UPnP worked on both sides) no game + // data ever flows through the relay and the WebSocket sits idle until + // the session ends. If the joiner's direct TCP fails the relay is + // already waiting; the joiner Tick auto-retries through it without any + // user interaction. Cost when unused: ~30 s keep-alive pings only. + { + const std::string relaySid = Win64LceLiveSignaling::GetSnapshot().sessionId; + if (!relaySid.empty()) + Win64LceLiveRelay::HostOpen(relaySid, WinsockNetLayer::GetHostPort()); + } + } + // Joiner: level-trigger — fires every Ready frame until pending ID consumed. + // No IsActive() guard: the game may still be mid-TCP-handshake through the + // relay proxy at the exact millisecond STUN resolves. + else if (!WinsockNetLayer::IsHosting() && + sigState == Win64LceLiveSignaling::ESignalingState::Idle) + { + const std::string pendingId = + Win64LceLiveSignaling::GetPendingJoinerSessionId(); + if (!pendingId.empty()) + { + Win64LceLiveSignaling::JoinerConnect( + pendingId, + p2pSnap.externalIp, p2pSnap.externalPort, + p2pSnap.connMethod); + } + } + } + + s_lastP2PState = p2pSnap.state; + } + + // Relay auto-fallback (joiner side only). + // Mirrors Xbox Live's TURN fallback: if the direct TCP attempt to the host + // fails and we pre-opened a relay proxy port, automatically retry through + // the relay — no user interaction required. + { + static WinsockNetLayer::eJoinState s_lastJoinState = WinsockNetLayer::eJoinState_Idle; + const WinsockNetLayer::eJoinState joinState = WinsockNetLayer::GetJoinState(); + + if (joinState == WinsockNetLayer::eJoinState_Failed && + s_lastJoinState != WinsockNetLayer::eJoinState_Failed && + g_LceLiveRelayFallbackPort > 0) + { + // Direct TCP failed — silently retry via the relay proxy that was + // already opened when the invite was accepted. + const int fallbackPort = g_LceLiveRelayFallbackPort; + g_LceLiveRelayFallbackPort = 0; + WinsockNetLayer::BeginJoinGame("127.0.0.1", fallbackPort); + } + + s_lastJoinState = joinState; + } + // LeaderboardManager::Instance()->Tick(); // Render game graphics. if(app.GetGameStarted()) @@ -1952,6 +2049,10 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, } // Free resources, unregister custom classes, and exit. + Win64LceLiveSignaling::Close(); // close signaling WS if still open + Win64LceLiveP2P::HostClose(); // joins background STUN thread if still running + Win64LceLiveRelay::Close(); // tears down relay forwarding threads + LceLog::Shutdown(); // flush and close latest.log // app.Uninit(); g_pd3dDevice->Release(); } diff --git a/Minecraft.Client/cmake/sources/Windows.cmake b/Minecraft.Client/cmake/sources/Windows.cmake index 5f64c7f7..5d806f54 100644 --- a/Minecraft.Client/cmake/sources/Windows.cmake +++ b/Minecraft.Client/cmake/sources/Windows.cmake @@ -352,6 +352,14 @@ set(_MINECRAFT_CLIENT_WINDOWS_WINDOWS64 "${BASE_DIR}/Windows64_App.h" "${BASE_DIR}/Windows64_LceLive.cpp" "${BASE_DIR}/Windows64_LceLive.h" + "${BASE_DIR}/Windows64_LceLiveP2P.cpp" + "${BASE_DIR}/Windows64_LceLiveP2P.h" + "${BASE_DIR}/Windows64_LceLiveSignaling.cpp" + "${BASE_DIR}/Windows64_LceLiveSignaling.h" + "${BASE_DIR}/Windows64_LceLiveRelay.cpp" + "${BASE_DIR}/Windows64_LceLiveRelay.h" + "${BASE_DIR}/Windows64_Log.cpp" + "${BASE_DIR}/Windows64_Log.h" "${BASE_DIR}/Windows64_UIController.cpp" "${BASE_DIR}/Windows64_UIController.h" "${BASE_DIR}/KeyboardMouseInput.cpp" diff --git a/build-release-exclude.txt b/build-release-exclude.txt new file mode 100644 index 00000000..a4d72e37 --- /dev/null +++ b/build-release-exclude.txt @@ -0,0 +1,4 @@ +.pdb +.ilk +.lib +.exp diff --git a/build-release.bat b/build-release.bat new file mode 100644 index 00000000..883ac3fc --- /dev/null +++ b/build-release.bat @@ -0,0 +1,42 @@ +@echo off +setlocal + +set "DEST=E:\Minecraft.Client" +set "DEBUG_DIR=%~dp0build\windows64\Minecraft.Client\Debug" +set "RELEASE_EXE=%~dp0build\windows64\Minecraft.Client\Release\Minecraft.Client.exe" + +echo [1/3] Building Minecraft.Client (Release)... +call "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat" -arch=x64 >nul 2>&1 +cmake --build --preset windows64-release --target Minecraft.Client +if %errorlevel% neq 0 ( + echo. + echo BUILD FAILED. + pause + exit /b 1 +) + +echo. +echo [2/3] Syncing game data to %DEST%... +if not exist "%DEST%" mkdir "%DEST%" + +xcopy "%DEBUG_DIR%\*" "%DEST%\" /E /Y /I /EXCLUDE:%~dp0build-release-exclude.txt >nul +if %errorlevel% neq 0 ( + echo. + echo XCOPY FAILED. + pause + exit /b 1 +) + +echo. +echo [3/3] Copying Release exe over Debug exe... +copy /y "%RELEASE_EXE%" "%DEST%\Minecraft.Client.exe" +if %errorlevel% neq 0 ( + echo. + echo COPY FAILED. + pause + exit /b 1 +) + +echo. +echo Done. Game folder ready at %DEST% +pause