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.
This commit is contained in:
veroxsity
2026-04-17 23:47:32 +01:00
parent 0281311e79
commit de125e5275
23 changed files with 3519 additions and 171 deletions

View File

@@ -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)

View File

@@ -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<unsigned int>(i));
}
}

View File

@@ -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<int>(controlId) == eControl_FriendsList)
{
m_friendsList.updateChildFocus(static_cast<int>(childId));
PromptInviteSelectedFriend();
#ifdef _WINDOWS64
if (IsReceiveMode())
PromptAcceptSelectedInvite();
else
PromptInviteSelectedFriend();
#endif
}
else if (static_cast<int>(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<int>(m_gameInvites.size()))
sel = static_cast<int>(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"<unknown>";
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<int>(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<UIScene_LceLiveInvites *>(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

View File

@@ -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<Win64LceLive::SocialEntry> m_friends;
std::vector<std::string> m_invitedAccountIds;
std::string m_pendingInviteAccountId;
std::wstring m_pendingInviteLabel;
// Receive-invite mode (main menu, not in game)
std::vector<Win64LceLive::GameInviteEntry> 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
};

View File

@@ -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"<unknown>";
line = L"<unknown>";
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<int>(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<int>(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<int>(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();
}

View File

@@ -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
};

View File

@@ -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)

View File

@@ -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<const struct sockaddr_in*>(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

View File

@@ -123,6 +123,7 @@ public:
static std::vector<Win64LANSession> GetDiscoveredSessions();
static int GetHostPort() { return s_hostGamePort; }
static std::string GetLocalIPv4();
private:
static DWORD WINAPI AcceptThreadProc(LPVOID param);

View File

@@ -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<unsigned char> 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<wchar_t> 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<unsigned long>(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;
}

View File

@@ -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();

View File

@@ -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 <winsock2.h>
#include <ws2tcpip.h>
// 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 <natupnp.h>
#include <comdef.h>
#include "Windows64_LceLiveP2P.h"
#include "Windows64_Log.h"
#include "Network/WinsockNetLayer.h"
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <string>
#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<unsigned char>(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<unsigned short>(data[0]) << 8) | data[1];
if (msgType != STUN_BINDING_RESPONSE)
return false;
const unsigned short msgLen =
(static_cast<unsigned short>(data[2]) << 8) | data[3];
if (static_cast<int>(msgLen) + 20 > length)
return false;
// Walk the attribute list
int offset = 20;
const int end = 20 + static_cast<int>(msgLen);
while (offset + 4 <= end)
{
const unsigned short attrType =
(static_cast<unsigned short>(data[offset]) << 8) | data[offset + 1];
const unsigned short attrLen =
(static_cast<unsigned short>(data[offset + 2]) << 8) | data[offset + 3];
const int valueOffset = offset + 4;
const int attrEnd = valueOffset + static_cast<int>(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<unsigned short>(data[valueOffset + 2]) << 8) |
data[valueOffset + 3];
const unsigned short port =
xport ^ static_cast<unsigned short>(STUN_MAGIC_COOKIE >> 16);
const unsigned long xaddr =
(static_cast<unsigned long>(data[valueOffset + 4]) << 24) |
(static_cast<unsigned long>(data[valueOffset + 5]) << 16) |
(static_cast<unsigned long>(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<unsigned short>(data[valueOffset + 2]) << 8) |
data[valueOffset + 3];
const unsigned long addr =
(static_cast<unsigned long>(data[valueOffset + 4]) << 24) |
(static_cast<unsigned long>(data[valueOffset + 5]) << 16) |
(static_cast<unsigned long>(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<int>(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<void**>(&pNAT));
if (FAILED(hr))
{
result.error = "UPnP: CoCreateInstance(UPnPNAT) failed hr=0x"
+ std::to_string(static_cast<unsigned long>(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<sockaddr_in*>(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<unsigned long>(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<void**>(&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<const sockaddr_in*>(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<const sockaddr*>(&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<sockaddr*>(&local), &localLen);
const int localPort = ntohs(local.sin_port);
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&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<const char*>(req.bytes), sizeof(req.bytes),
0,
reinterpret_cast<const sockaddr*>(&serverAddr), sizeof(serverAddr));
if (sent != static_cast<int>(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<char*>(buf), sizeof(buf),
0,
reinterpret_cast<sockaddr*>(&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<const char*>(req.bytes), sizeof(req.bytes),
0,
reinterpret_cast<const sockaddr*>(&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

View File

@@ -0,0 +1,65 @@
#pragma once
#ifdef _WINDOWS64
#include <string>
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

View File

@@ -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 <winsock2.h>
#include <ws2tcpip.h>
#include <winhttp.h>
#include <objbase.h>
#include "Windows64_LceLiveRelay.h"
#include "Windows64_LceLive.h"
#include "Windows64_Log.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <atomic>
#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<size_t>(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<char>(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<char*>(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<wchar_t> hostBuf(256, 0);
std::vector<wchar_t> pathBuf(2048, 0);
URL_COMPONENTSW comp = {};
comp.dwStructSize = sizeof(comp);
comp.lpszHostName = hostBuf.data();
comp.dwHostNameLength = static_cast<DWORD>(hostBuf.size());
comp.lpszUrlPath = pathBuf.data();
comp.dwUrlPathLength = static_cast<DWORD>(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<DWORD>(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<bool>* stop;
};
struct ForwardTcpToWsParams
{
SOCKET tcpSocket;
HINTERNET wsHandle;
CRITICAL_SECTION* wsSendLock;
std::atomic<bool>* stop;
};
// -------------------------------------------------------------------------
// WS → TCP forwarding thread
// -------------------------------------------------------------------------
DWORD WINAPI ForwardWsToTcpProc(LPVOID param)
{
auto* p = static_cast<ForwardWsToTcpParams*>(param);
std::vector<BYTE> 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<DWORD>(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<const char*>(buf.data());
DWORD remaining = bytesRead;
while (remaining > 0 && !p->stop->load())
{
const int sent = send(p->tcpSocket, src, static_cast<int>(remaining), 0);
if (sent <= 0) { p->stop->store(true); break; }
src += sent;
remaining -= static_cast<DWORD>(sent);
}
}
p->stop->store(true);
delete p;
return 0;
}
// -------------------------------------------------------------------------
// TCP → WS forwarding thread
// -------------------------------------------------------------------------
DWORD WINAPI ForwardTcpToWsProc(LPVOID param)
{
auto* p = static_cast<ForwardTcpToWsParams*>(param);
std::vector<char> buf(65536);
while (!p->stop->load())
{
const int received = recv(p->tcpSocket, buf.data(), static_cast<int>(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<DWORD>(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<bool> 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<WorkerContext*>(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<BYTE> recvBuf(65536);
std::vector<BYTE> 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<DWORD>(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<const char*>(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<u_short>(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<sockaddr*>(&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<const char*>(earlyBinary.data());
int remain = static_cast<int>(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<WorkerContext*>(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<sockaddr*>(&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<sockaddr*>(&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<const char*>(&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<sockaddr*>(&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<sockaddr*>(&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<AcceptCtx*>(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<const char*>(&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

View File

@@ -0,0 +1,72 @@
#pragma once
#ifdef _WINDOWS64
#include <string>
// ============================================================================
// 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:<port>.
// 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

View File

@@ -0,0 +1,724 @@
#include "stdafx.h"
#ifdef _WINDOWS64
#include <winsock2.h>
#include <ws2tcpip.h>
#include <winhttp.h>
#include <objbase.h> // 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 <cstdio>
#include <string>
#include <vector>
#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<size_t>(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<size_t>(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<DWORD>(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<char>(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<WorkerContext*>(param);
// ---- Parse the base URL ----
const std::wstring baseUrlW = Utf8ToWideLocal(ctx->baseUrl);
std::vector<wchar_t> hostBuf(256, 0);
std::vector<wchar_t> pathBuf(2048, 0);
URL_COMPONENTSW components = {};
components.dwStructSize = sizeof(components);
components.lpszHostName = hostBuf.data();
components.dwHostNameLength = static_cast<DWORD>(hostBuf.size());
components.lpszUrlPath = pathBuf.data();
components.dwUrlPathLength = static_cast<DWORD>(pathBuf.size());
if (!WinHttpCrackUrl(baseUrlW.c_str(), static_cast<DWORD>(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<DWORD>(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<PVOID>(reinterpret_cast<const void*>(candidateJson.c_str())),
static_cast<DWORD>(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<BYTE> 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<DWORD>(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<char*>(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

View File

@@ -0,0 +1,75 @@
#pragma once
#ifdef _WINDOWS64
#include <string>
#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

View File

@@ -0,0 +1,97 @@
#include "stdafx.h"
#ifdef _WINDOWS64
#include "Windows64_Log.h"
#include <cstdio>
#include <cstdarg>
#include <cstring>
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

View File

@@ -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 <cstdarg>
namespace LceLog
{
void Init();
void Shutdown();
void Write(const char* fmt, ...);
}
#define LCELOG(category, fmt, ...) \
LceLog::Write("[" category "] " fmt, ##__VA_ARGS__)
#endif // _WINDOWS64

View File

@@ -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();
}

View File

@@ -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"

View File

@@ -0,0 +1,4 @@
.pdb
.ilk
.lib
.exp

42
build-release.bat Normal file
View File

@@ -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