From d33dfadefb34902f5437b79257855fcee95804f4 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 16 May 2026 12:28:44 +0300 Subject: [PATCH 1/3] feat: Bug fixes on Networking stack --- .../Matchmaking/JoinSessionCoordinator.cs | 13 +- .../Matchmaking/JoinSessionMessenger.cs | 12 +- SSMP/Game/SteamManager.cs | 59 ++++++-- .../Matchmaking/Host/MmsHostSessionService.cs | 51 +++++-- .../Matchmaking/Host/MmsWebSocketHandler.cs | 8 +- .../Matchmaking/Join/MmsJoinCoordinator.cs | 34 ++++- SSMP/Networking/Matchmaking/MmsClient.cs | 4 + .../Matchmaking/Parsing/MmsResponseParser.cs | 38 +++--- .../Matchmaking/Protocol/MmsFields.cs | 3 - .../Matchmaking/Protocol/MmsModels.cs | 3 + .../HolePunchEncryptedTransportServer.cs | 61 +++++++-- SSMP/Ui/ConnectInterface.cs | 127 +++++++++++------- 12 files changed, 293 insertions(+), 120 deletions(-) diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs index b006820f..db3c08a2 100644 --- a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -122,7 +122,7 @@ public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) { /// The external port observed by the server. /// Propagates notification that the operation should be cancelled. public async Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) { - if (!_store.TryGetDiscoveryMetadata(token, out var metadata) || metadata == null) + if (!_store.TryGetDiscoveryMetadata(token, out var metadata)) return; if (!_store.TrySetDiscoveredPort(token, port)) @@ -390,9 +390,8 @@ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken can // Matchmaking lobbies store ConnectionData as "IP:Port". // Steam lobbies are filtered out at session creation. var hostIp = lobby.ConnectionData.Split(':')[0]; - var startTimeMs = DateTimeOffset.UtcNow - .AddMilliseconds(MatchmakingProtocol.PunchTimingOffsetMs) - .ToUnixTimeMilliseconds(); + var serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var startTimeMs = serverTimeMs + MatchmakingProtocol.PunchTimingOffsetMs; try { var hostSent = await JoinSessionMessenger.SendStartPunchToHostAsync( @@ -402,7 +401,8 @@ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken can session.ClientExternalPort.Value, lobby.ExternalPort.Value, startTimeMs, - cancellationToken + cancellationToken, + serverTimeMs ); if (!hostSent) { await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken); @@ -414,7 +414,8 @@ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken can lobby.ExternalPort.Value, hostIp, startTimeMs, - cancellationToken + cancellationToken, + serverTimeMs ); if (!clientSent) { await FailJoinSessionAsync(joinId, "client_disconnected", cancellationToken); diff --git a/MMS/Services/Matchmaking/JoinSessionMessenger.cs b/MMS/Services/Matchmaking/JoinSessionMessenger.cs index 00e87f14..99e3911a 100644 --- a/MMS/Services/Matchmaking/JoinSessionMessenger.cs +++ b/MMS/Services/Matchmaking/JoinSessionMessenger.cs @@ -90,7 +90,8 @@ public static async Task SendStartPunchToClientAsync( int hostPort, string hostIp, long startTimeMs, - CancellationToken cancellationToken + CancellationToken cancellationToken, + long serverTimeMs ) { if (session.ClientWebSocket is not { State: WebSocketState.Open } ws) return false; @@ -102,7 +103,8 @@ await WebSocketMessenger.SendAsync( joinId = session.JoinId, hostIp, hostPort, - startTimeMs + startTimeMs, + serverTimeMs }, cancellationToken ); @@ -121,7 +123,8 @@ public static async Task SendStartPunchToHostAsync( int clientPort, int hostPort, long startTimeMs, - CancellationToken cancellationToken + CancellationToken cancellationToken, + long serverTimeMs ) { if (lobby.HostWebSocket is not { State: WebSocketState.Open } hostWs) return false; @@ -134,7 +137,8 @@ await WebSocketMessenger.SendAsync( clientIp, clientPort, hostPort, - startTimeMs + startTimeMs, + serverTimeMs }, cancellationToken ); diff --git a/SSMP/Game/SteamManager.cs b/SSMP/Game/SteamManager.cs index 9b6bcf96..dd40b32d 100644 --- a/SSMP/Game/SteamManager.cs +++ b/SSMP/Game/SteamManager.cs @@ -71,6 +71,11 @@ public static class SteamManager { /// private static ELobbyType _pendingLobbyType; + /// + /// The visibility type of the currently active lobby. + /// + private static ELobbyType _currentLobbyType = DefaultLobbyType; + /// /// Callback timer interval in milliseconds (~60Hz). /// @@ -86,7 +91,6 @@ public static class SteamManager { /// private const string LobbyKeyName = "name"; private const string LobbyKeyVersion = "version"; - /// /// Cached mod version string from assembly metadata. /// @@ -229,6 +233,7 @@ public static void LeaveLobby() { var lobbyToLeave = CurrentLobbyId; CurrentLobbyId = NilLobbyId; IsHostingLobby = false; + _currentLobbyType = DefaultLobbyType; // Clear Rich Presence so friends no longer see "Join Game" option SteamFriends.ClearRichPresence(); @@ -256,6 +261,43 @@ public static void OpenInviteDialog() { SteamFriends.ActivateGameOverlayInviteDialog(CurrentLobbyId); } + /// + /// Marks the hosted lobby as ready or not ready for inbound players. + /// Steam join links stay disabled until the host is actually listening. + /// + /// Whether the host has finished startup and can accept joins. + public static void SetLobbyReady(bool isReady) { + if (!IsInitialized) { + Logger.Warn("Cannot update Steam lobby readiness: Steam is not initialized"); + return; + } + + if (CurrentLobbyId == NilLobbyId || !IsHostingLobby) { + Logger.Warn("Cannot update Steam lobby readiness: no hosted lobby is active"); + return; + } + + SteamMatchmaking.SetLobbyJoinable(CurrentLobbyId, isReady); + + // Clear first so stale connect keys are not left around while the host is still booting. + SteamFriends.ClearRichPresence(); + + if (!isReady) { + SteamFriends.SetRichPresence("status", "Starting Lobby"); + Logger.Info($"Steam lobby {CurrentLobbyId} marked not ready"); + return; + } + + if (_currentLobbyType != ELobbyType.k_ELobbyTypePrivate) { + SteamFriends.SetRichPresence("connect", CurrentLobbyId.m_SteamID.ToString()); + SteamFriends.SetRichPresence("status", "In Lobby"); + Logger.Info($"Steam lobby {CurrentLobbyId} marked ready with connect={CurrentLobbyId.m_SteamID}"); + } else { + SteamFriends.SetRichPresence("status", "In Private Lobby"); + Logger.Info($"Steam private lobby {CurrentLobbyId} marked ready"); + } + } + /// /// Shuts down the Steam API. /// Should be called on application exit if Steam was initialized. @@ -347,6 +389,7 @@ private static void OnLobbyCreated(LobbyCreated_t callback, bool ioFailure) { var lobbyId = new CSteamID(callback.m_ulSteamIDLobby); CurrentLobbyId = lobbyId; IsHostingLobby = true; + _currentLobbyType = _pendingLobbyType; Logger.Info($"Steam lobby created successfully: {lobbyId}"); @@ -357,19 +400,7 @@ private static void OnLobbyCreated(LobbyCreated_t callback, bool ioFailure) { var steamName = SteamFriends.GetPersonaName(); SteamMatchmaking.SetLobbyData(lobbyId, LobbyKeyName, $"{steamName}'s Lobby"); SteamMatchmaking.SetLobbyData(lobbyId, LobbyKeyVersion, ModVersion); - - // Set Rich Presence based on lobby type - // Private lobbies: NO connect key (truly invite-only, no "Join Game" button) - // Public/Friends: Set connect key so friends can "Join Game" from Steam - if (_pendingLobbyType != ELobbyType.k_ELobbyTypePrivate) { - SteamFriends.SetRichPresence("connect", lobbyId.m_SteamID.ToString()); - SteamFriends.SetRichPresence("status", "In Lobby"); - Logger.Info($"Rich Presence set with connect={lobbyId.m_SteamID}"); - } else { - // Private lobby: set status only, use /invite command to send invites - SteamFriends.SetRichPresence("status", "In Private Lobby"); - Logger.Info("Private lobby - use /invite to send Steam invites"); - } + SetLobbyReady(false); // Fire event for listeners LobbyCreatedEvent?.Invoke(lobbyId, username ?? "Unknown"); diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs index a8618e2c..c8b6e780 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -41,6 +41,11 @@ internal sealed class MmsHostSessionService : IDisposable { /// private string? _currentLobbyId; + /// + /// The most recent host discovery token returned by MMS for the active matchmaking lobby. + /// + private string? _currentHostDiscoveryToken; + /// MMS lobby keep-alive timer. private Timer? _heartbeatTimer; @@ -60,7 +65,7 @@ internal sealed class MmsHostSessionService : IDisposable { /// null when no refresh is running. /// private CancellationTokenSource? _hostDiscoveryRefreshCts; - + /// WebSocket handler that receives real-time MMS server events. public MmsWebSocketHandler WebSocket { get; } @@ -130,7 +135,9 @@ public async ); if (effectiveHostIpOverride != null) { - Logger.Info($"MmsHostSessionService: Using HostIpOverride {effectiveHostIpOverride} for lobby creation."); + Logger.Info( + $"MmsHostSessionService: Using HostIpOverride {effectiveHostIpOverride} for lobby creation." + ); if (isPublic && NetworkingUtil.IsPrivateIpv4(effectiveHostIpOverride)) { Logger.Warn( $"MmsHostSessionService: Public lobby is advertising private HostIpOverride {effectiveHostIpOverride}. " + @@ -140,7 +147,9 @@ public async } if (effectiveHostLanIpOverride != null) - Logger.Info($"MmsHostSessionService: Using HostLanIpOverride {effectiveHostLanIpOverride} for lobby creation."); + Logger.Info( + $"MmsHostSessionService: Using HostLanIpOverride {effectiveHostLanIpOverride} for lobby creation." + ); var jsonString = MmsJsonParser.FormatCreateLobbyJson( hostPort, @@ -272,6 +281,26 @@ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action + /// Starts host UDP discovery using the token returned at lobby creation time, if one exists. + /// This primes MMS with the host's live socket mapping before any join arrives. + /// + /// Callback that writes raw bytes through the host's live UDP socket. + public void StartInitialHostDiscoveryRefresh(Action sendRawAction) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + var token = _currentHostDiscoveryToken; + if (string.IsNullOrEmpty(token)) { + Logger.Info( + "MmsHostSessionService: no initial host discovery token available; skipping startup discovery." + ); + return; + } + + Logger.Info("MmsHostSessionService: starting initial host discovery refresh."); + StartHostDiscoveryRefresh(token, sendRawAction); + } + /// /// Cancels the active UDP discovery refresh task, if any. /// Safe to call when no refresh is running. @@ -297,12 +326,14 @@ public void StopHostDiscoveryRefresh() { /// Game version string for compatibility filtering. /// A JSON string ready to POST to the MMS lobby endpoint. private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, string gameVersion) => - $"{{\"{MmsFields.ConnectionDataRequest}\":\"{JsonConvert.ToString(steamLobbyId)}\"," + - $"\"{MmsFields.IsPublicRequest}\":{isPublic.ToString().ToLower()}," + - $"\"{MmsFields.GameVersionRequest}\":\"{JsonConvert.ToString(gameVersion)}\"," + - $"\"{MmsFields.LobbyTypeRequest}\":\"steam\"}}"; - - + JsonConvert.SerializeObject( + new { + ConnectionData = steamLobbyId, + IsPublic = isPublic, + GameVersion = gameVersion, + LobbyType = "steam" + }); + /// /// Captures the current session token and lobby ID, then clears both fields. /// Called during to ensure the delete request uses @@ -318,6 +349,7 @@ private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, st var snapshot = (_hostToken!, _currentLobbyId); _hostToken = null; _currentLobbyId = null; + _currentHostDiscoveryToken = null; return snapshot; } @@ -355,6 +387,7 @@ out hostDiscoveryToken _hostToken = hostToken; _currentLobbyId = lobbyId; + _currentHostDiscoveryToken = hostDiscoveryToken; _heartbeatFailureCount = 0; StartHeartbeat(); } diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs index cf5c51cf..65aea6ab 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -44,7 +44,7 @@ internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable { /// /// Raised when MMS signals both sides to start simultaneous hole-punch. - /// Arguments: joinId, clientIp, clientPort, hostPort, startTimeMs. + /// Arguments: joinId, clientIp, clientPort, hostPort, serverTimeMs, startTimeMs. /// /// Handlers are always invoked on the that was /// active when this handler was constructed (typically the Unity main thread). @@ -59,7 +59,7 @@ internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable { /// order, but may execute across different frames. /// /// - public event Action? StartPunchRequested; + public event Action? StartPunchRequested; /// Raised on mapping confirmation. Marshaled to construction thread. public event Action? HostMappingReceived; @@ -351,17 +351,19 @@ private void HandleStartPunch(string message, EventQueue eq) { var clientIp = MmsJsonParser.ExtractValue(message, MmsFields.ClientIp); var clientPortStr = MmsJsonParser.ExtractValue(message, MmsFields.ClientPort); var hostPortStr = MmsJsonParser.ExtractValue(message, MmsFields.HostPort); + var serverTimeStr = MmsJsonParser.ExtractValue(message, MmsFields.ServerTimeMs); var startTimeStr = MmsJsonParser.ExtractValue(message, MmsFields.StartTimeMs); if (joinId == null || clientIp == null || !int.TryParse(clientPortStr, out var clientPort) || !int.TryParse(hostPortStr, out var hostPort) || + !long.TryParse(serverTimeStr, out var serverTimeMs) || !long.TryParse(startTimeStr, out var startTimeMs)) return; Logger.Info($"MmsWebSocketHandler: {MmsActions.StartPunch} for join {joinId} -> {clientIp}:{clientPort}"); - eq.Enqueue(() => StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, startTimeMs)); + eq.Enqueue(() => StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, serverTimeMs, startTimeMs)); } /// diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs index 0aff0032..c064c052 100644 --- a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs +++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs @@ -115,6 +115,8 @@ Action onJoinFailed DiscoverySession discovery, Action onJoinFailed ) { + discovery.ObserveServerTime(message); + var action = MmsJsonParser.ExtractValue(message, MmsFields.Action); if (action == null) { Logger.Debug("MmsWebSocketHandler: invalid message, no defined action"); @@ -173,7 +175,9 @@ Action onJoinFailed return null; } - await DelayUntilAsync(joinStart.StartTimeMs, timeoutCts.Token); + var localNowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var estimatedServerOffsetMs = discovery.MinObservedServerOffsetMs ?? (localNowMs - joinStart.ServerTimeMs); + await DelayUntilAsync(joinStart.StartTimeMs, estimatedServerOffsetMs, timeoutCts.Token); return joinStart; } @@ -209,9 +213,12 @@ Action onJoinFailed /// is far in the future, the delay will simply block until fires. /// /// Target time expressed as milliseconds since the Unix epoch (UTC). + /// Best estimate of local-minus-MMS clock offset in milliseconds. /// Cancellation token that can abort the wait early. - private static async Task DelayUntilAsync(long targetUnixMs, CancellationToken ct) { - var delayMs = targetUnixMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + private static async Task DelayUntilAsync(long targetUnixMs, long estimatedServerOffsetMs, CancellationToken ct) { + var localNowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var targetLocalTimeMs = targetUnixMs + estimatedServerOffsetMs; + var delayMs = targetLocalTimeMs - localNowMs; if (delayMs > 0) await Task.Delay(TimeSpan.FromMilliseconds(delayMs), ct); } @@ -237,6 +244,27 @@ private sealed class DiscoverySession : IDisposable { /// public CancellationTokenSource? Cts; + /// + /// Best observed mapping from MMS server time to local UTC clock. + /// Uses the minimum observed local-minus-server delta to reduce network-latency bias. + /// + public long? MinObservedServerOffsetMs { get; private set; } + + /// + /// Updates the best-known server-to-local clock offset from a message that includes server time. + /// + public void ObserveServerTime(string message) { + var serverTimeStr = MmsJsonParser.ExtractValue(message, MmsFields.ServerTimeMs); + if (!long.TryParse(serverTimeStr, out var serverTimeMs)) { + return; + } + + var offsetMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - serverTimeMs; + MinObservedServerOffsetMs = MinObservedServerOffsetMs.HasValue + ? System.Math.Min(MinObservedServerOffsetMs.Value, offsetMs) + : offsetMs; + } + /// Cancels without disposing it. public void Cancel() { Cts?.Cancel(); diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 63b61dd5..0d12ffb6 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -133,6 +133,10 @@ public MmsClient( public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) => HostSession.StartHostDiscoveryRefresh(hostDiscoveryToken, sendRawAction); + /// Starts startup-time host UDP discovery using the current lobby's initial token, if present. + public void StartInitialHostDiscoveryRefresh(Action sendRawAction) => + HostSession.StartInitialHostDiscoveryRefresh(sendRawAction); + /// Stops active host discovery refresh loop. public void StopHostDiscoveryRefresh() => HostSession.StopHostDiscoveryRefresh(); diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs index 84498abe..93c0b656 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -28,11 +28,11 @@ public static bool TryParseLobbyActivation( out string? hostDiscoveryToken ) { var root = ParseJsonObject(response); - lobbyId = root?.Value(MmsFields.ConnectionData); - hostToken = root?.Value(MmsFields.HostToken); - lobbyName = root?.Value(MmsFields.LobbyName); - lobbyCode = root?.Value(MmsFields.LobbyCode); - hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken); + lobbyId = root?.Value(MmsFields.ConnectionData); + hostToken = root?.Value(MmsFields.HostToken); + lobbyName = root?.Value(MmsFields.LobbyName); + lobbyCode = root?.Value(MmsFields.LobbyCode); + hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken); return lobbyId != null && hostToken != null && lobbyName != null && lobbyCode != null; } @@ -47,8 +47,8 @@ out string? hostDiscoveryToken return null; } - var connectionData = root.Value(MmsFields.ConnectionData); - var lobbyTypeString = root.Value(MmsFields.LobbyType); + var connectionData = root.Value(MmsFields.ConnectionData); + var lobbyTypeString = root.Value(MmsFields.LobbyType); if (connectionData == null || lobbyTypeString == null) { return null; @@ -86,30 +86,34 @@ public static List ParsePublicLobbies(string response) { return null; } - var hostIp = root.Value(MmsFields.HostIp); - var hostPort = root.Value(MmsFields.HostPort); - var startTime = root.Value(MmsFields.StartTimeMs); + var hostIp = root.Value(MmsFields.HostIp); + var hostPort = root.Value(MmsFields.HostPort); + var serverTime = root.Value(MmsFields.ServerTimeMs); + var startTime = root.Value(MmsFields.StartTimeMs); - if (hostIp == null || hostPort == null || startTime == null) { + if (hostIp == null || hostPort == null || serverTime == null || startTime == null) { return null; } return new MatchmakingJoinStartResult { - HostIp = hostIp, - HostPort = hostPort.Value, - StartTimeMs = startTime.Value + HostIp = hostIp, + HostPort = hostPort.Value, + ServerTimeMs = serverTime.Value, + StartTimeMs = startTime.Value }; } /// Normalizes lobby-list payloads so callers can always iterate a JSON array. private static JArray ParseLobbiesAsArray(string response) { return JToken.Parse(response) switch { - JArray array => array, - var other => LogAndReturnEmpty(other) + JArray array => array, + var other => LogAndReturnEmpty(other) }; static JArray LogAndReturnEmpty(JToken other) { - Logger.Debug($"MmsResponseParser: Unexpected lobby payload token type '{other.Type}', expected array or object."); + Logger.Debug( + $"MmsResponseParser: Unexpected lobby payload token type '{other.Type}', expected array or object." + ); return []; } } diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs index 2341e624..f9e65f96 100644 --- a/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs +++ b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs @@ -81,9 +81,6 @@ internal static class MmsFields { /// The LAN IP address of the host (Request field). public const string HostLanIpRequest = "HostLanIp"; - /// Opaque connection data (Request field). - public const string ConnectionDataRequest = "ConnectionData"; - /// The IP address of the client (Request field). public const string ClientIpRequest = "ClientIp"; diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs index 014f2097..4d3da49c 100644 --- a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs +++ b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs @@ -71,6 +71,9 @@ internal sealed class MatchmakingJoinStartResult { /// The resolved public port of the host. public required int HostPort { get; init; } + /// The MMS server time when the start-punch message was emitted. + public required long ServerTimeMs { get; init; } + /// The Unix timestamp (ms) when both sides should start punching. public required long StartTimeMs { get; init; } } diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs index 107bc806..f537e614 100644 --- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs +++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs @@ -53,6 +53,12 @@ internal class HolePunchEncryptedTransportServer : IEncryptedTransportServer { /// private readonly ConcurrentDictionary _clients; + /// + /// Best observed mapping from MMS server time to the host's local UTC clock. + /// Uses the minimum observed local-minus-server delta to reduce network-latency bias. + /// + private long? _minObservedServerOffsetMs; + /// public event Action? ClientConnectedEvent; @@ -72,13 +78,14 @@ public void Start(int port) { _mmsClient.HostSession.WebSocket.HostMappingReceived += OnHostMappingReceived; _mmsClient.HostSession.WebSocket.StartPunchRequested += OnStartPunchRequested; } - + var socket = PreBoundSocket; PreBoundSocket = null; - + _dtlsServer.Start(port, socket); _mmsClient?.StartWebSocketConnection(); + _mmsClient?.StartInitialHostDiscoveryRefresh((data, endpoint) => _dtlsServer.SendRaw(data, endpoint)); } /// @@ -100,22 +107,30 @@ public void Stop() { /// /// Called when MMS notifies us of a client needing punch-back. /// - private void OnStartPunchRequested(string joinId, string clientIp, int clientPort, int hostPort, long startTimeMs) { + private void OnStartPunchRequested( + string joinId, + string clientIp, + int clientPort, + int hostPort, + long serverTimeMs, + long startTimeMs + ) { _mmsClient?.StopHostDiscoveryRefresh(); + ObserveServerTime(serverTimeMs); if (!IPAddress.TryParse(clientIp, out var ip)) { Logger.Warn($"HolePunch Server: Invalid client IP: {clientIp}"); return; } // Run the PunchToClientAsync method asynchronously, but don't wait for the result - _ = PunchToClientAsync(new IPEndPoint(ip, clientPort), startTimeMs); + _ = PunchToClientAsync(new IPEndPoint(ip, clientPort), serverTimeMs, startTimeMs); } /// public void DisconnectClient(IEncryptedTransportClient client) { if (client is not HolePunchEncryptedTransportClient hpClient) return; - + _dtlsServer.DisconnectClient(hpClient.EndPoint); _clients.TryRemove(hpClient.EndPoint, out _); } @@ -124,21 +139,26 @@ public void DisconnectClient(IEncryptedTransportClient client) { /// Callback method for when data is received from a server client. /// private void OnClientDataReceived(DtlsServerClient dtlsClient, byte[] data, int length) { - var client = _clients.GetOrAdd(dtlsClient.EndPoint, _ => { - var newClient = new HolePunchEncryptedTransportClient(dtlsClient); - ClientConnectedEvent?.Invoke(newClient); - return newClient; - }); + var client = _clients.GetOrAdd( + dtlsClient.EndPoint, _ => { + var newClient = new HolePunchEncryptedTransportClient(dtlsClient); + ClientConnectedEvent?.Invoke(newClient); + return newClient; + } + ); client.RaiseDataReceived(data, length); } - + /// /// Called when MMS asks the host to refresh its matchmaking mapping on the live UDP server socket. /// private void OnHostMappingRefreshRequested(string joinId, string hostDiscoveryToken, long serverTimeMs) { Logger.Info($"HolePunch Server: Refreshing host mapping for join {joinId}"); - _mmsClient?.StartHostDiscoveryRefresh(hostDiscoveryToken, (data, endpoint) => _dtlsServer.SendRaw(data, endpoint)); + ObserveServerTime(serverTimeMs); + _mmsClient?.StartHostDiscoveryRefresh( + hostDiscoveryToken, (data, endpoint) => _dtlsServer.SendRaw(data, endpoint) + ); } /// @@ -154,10 +174,13 @@ private void OnHostMappingReceived() { /// spaced ms apart, starting at . /// Exceptions are caught and logged rather than propagated, since this runs fire-and-forget. /// - private async Task PunchToClientAsync(IPEndPoint clientEndpoint, long startTimeMs) { + private async Task PunchToClientAsync(IPEndPoint clientEndpoint, long serverTimeMs, long startTimeMs) { try { Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}"); - var delay = startTimeMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var localNowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var estimatedServerOffsetMs = _minObservedServerOffsetMs ?? (localNowMs - serverTimeMs); + var targetLocalTimeMs = startTimeMs + estimatedServerOffsetMs; + var delay = targetLocalTimeMs - localNowMs; if (delay > 0) await Task.Delay(TimeSpan.FromMilliseconds(delay)); for (var i = 0; i < PunchPacketCount; i++) { @@ -170,4 +193,14 @@ private async Task PunchToClientAsync(IPEndPoint clientEndpoint, long startTimeM Logger.Error($"HolePunch Server: Punch to {clientEndpoint} failed – {ex.Message}"); } } + + /// + /// Updates the best-known MMS-server-to-local clock offset. + /// + private void ObserveServerTime(long serverTimeMs) { + var offsetMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - serverTimeMs; + _minObservedServerOffsetMs = _minObservedServerOffsetMs.HasValue + ? System.Math.Min(_minObservedServerOffsetMs.Value, offsetMs) + : offsetMs; + } } diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs index 58377fa0..95373d37 100644 --- a/SSMP/Ui/ConnectInterface.cs +++ b/SSMP/Ui/ConnectInterface.cs @@ -476,6 +476,16 @@ internal class ConnectInterface { // ReSharper disable once NotAccessedField.Local private IButtonComponent? _joinFriendButton; + /// + /// Steam lobby ID for a newly created hosted Steam lobby that still needs post-start finalization. + /// + private string? _pendingHostedSteamLobbyId; + + /// + /// Whether the pending hosted Steam lobby should be registered with MMS after the host is ready. + /// + private bool _pendingHostedSteamLobbyIsPublic; + // Direct IP tab components /// /// Input field for the server IP address. @@ -532,6 +542,11 @@ internal class ConnectInterface { /// private string? _pendingHolePunchRetryLobbyId; + /// + /// Whether a matchmaking lobby join is already in progress. + /// + private bool _isLobbyJoinInProgress; + /// /// Public accessor for the MMS client. /// Used by server manager to pass to HolePunch transport for lobby cleanup. @@ -717,7 +732,6 @@ public ConnectInterface(ModSettings modSettings, ComponentGroup connectGroup) { /// Subscribes to Steam lobby-related events if Steam is available. /// private void SubscribeToSteamEvents() { - SteamManager.LobbyCreatedEvent += OnSteamLobbyCreated; SteamManager.LobbyListReceivedEvent += OnLobbyListReceived; SteamManager.LobbyJoinedEvent += OnLobbyJoined; } @@ -1193,6 +1207,11 @@ private void OnLobbyConnectButtonPressed() { return; } + if (_isLobbyJoinInProgress) { + ShowFeedback(Color.yellow, "Already connecting..."); + return; + } + if (!ValidateUsername(out var username)) { return; } @@ -1206,6 +1225,7 @@ private void OnLobbyConnectButtonPressed() { // Arm the one-time retry for a hole-punch failure _pendingHolePunchRetryLobbyId = lobbyId; + SetLobbyJoinInProgress(); ShowFeedback(Color.yellow, "Connecting..."); MonoBehaviourUtil.Instance.StartCoroutine(JoinLobbyCoroutine(lobbyId, username)); } @@ -1226,6 +1246,7 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { if (!task.IsCompletedSuccessfully) { CleanupHolePunchSocket(holePunchSocket); + ResetConnectionButtons(); Logger.Error( $"ConnectInterface: JoinLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}" ); @@ -1236,6 +1257,7 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { var lobbyInfo = task.Result; if (lobbyInfo == null) { CleanupHolePunchSocket(holePunchSocket); + ResetConnectionButtons(); if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { ActivateMatchmakingVersionBlock(); @@ -1254,10 +1276,11 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { // Handle connection based on lobby type if (lobbyType == PublicLobbyType.Steam) { CleanupHolePunchSocket(holePunchSocket); - ConnectToSteamLobby(connectionData, username); + ConnectToSteamLobby(connectionData); } else { if (string.IsNullOrEmpty(joinId)) { CleanupHolePunchSocket(holePunchSocket); + ResetConnectionButtons(); ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); yield break; } @@ -1271,6 +1294,7 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { if (!joinTask.IsCompletedSuccessfully) { CleanupHolePunchSocket(holePunchSocket); + ResetConnectionButtons(); Logger.Error( $"ConnectInterface: CoordinateMatchmakingJoinAsync failed: {joinTask.Exception?.GetBaseException().Message ?? "cancelled"}" ); @@ -1281,6 +1305,7 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { var joinStart = joinTask.Result; if (joinStart == null) { CleanupHolePunchSocket(holePunchSocket); + ResetConnectionButtons(); if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { ActivateMatchmakingVersionBlock(); @@ -1371,46 +1396,42 @@ private void CreateSteamLobbyWithConfig(LobbyVisibility visibility) { return; // Subscribe to lobby created event (one-time) - void OnLobbyCreatedCallback(CSteamID steamLobbyId, string hostName) { + void OnLobbyCreatedCallback(CSteamID steamLobbyId, string _) { // Unsubscribe immediately SteamManager.LobbyCreatedEvent -= OnLobbyCreatedCallback; - // Only PUBLIC Steam lobbies register with MMS for browser visibility - // Private and Friends-Only lobbies use Steam's native discovery only - if (isPublic) { - MonoBehaviourUtil.Instance.StartCoroutine( - RegisterSteamLobbyForBrowserCoroutine(steamLobbyId.m_SteamID.ToString(), username) - ); - } else { - ShowFeedback(Color.green, "Steam lobby created!"); - StartHostButtonPressed?.Invoke("0.0.0.0", 0, username, TransportType.Steam, null); - } + _pendingHostedSteamLobbyId = steamLobbyId.m_SteamID.ToString(); + _pendingHostedSteamLobbyIsPublic = isPublic; + + ShowFeedback(Color.yellow, "Steam lobby created. Starting host..."); + StartHostButtonPressed?.Invoke("0.0.0.0", 0, username, TransportType.Steam, null); } } /// - /// Registers a public Steam lobby with MMS for browser visibility (no invite code). + /// Finalizes a hosted Steam lobby after the local host has fully connected. + /// Public lobbies are registered with MMS only after the Steam P2P server is actually live. /// - private IEnumerator RegisterSteamLobbyForBrowserCoroutine( - string steamLobbyId, - string username - ) { - var task = MmsClient.RegisterSteamLobbyAsync( - steamLobbyId, - isPublic: true, - gameVersion: Application.version - ); + private IEnumerator FinalizeHostedSteamLobbyCoroutine(string steamLobbyId, bool isPublic) { + if (isPublic) { + var task = MmsClient.RegisterSteamLobbyAsync( + steamLobbyId, + isPublic: true, + gameVersion: Application.version + ); - yield return new WaitUntil(() => task.IsCompleted); + yield return new WaitUntil(() => task.IsCompleted); - // Don't show invite code for Steam lobbies - they use Steam's native join flow - if (task.Result == null) { - ShowFeedback(Color.yellow, "Steam lobby created (browser listing failed)"); + if (!task.IsCompletedSuccessfully || task.Result == null) { + ShowFeedback(Color.yellow, "Steam lobby created (browser listing failed)"); + } else { + ShowFeedback(Color.green, "Steam lobby created!"); + } } else { ShowFeedback(Color.green, "Steam lobby created!"); } - StartHostButtonPressed?.Invoke("0.0.0.0", 0, username, TransportType.Steam, null); + SteamManager.SetLobbyReady(true); } @@ -1694,20 +1715,6 @@ private void OnStartButtonPressed() { #region Steam Event Callbacks - /// - /// Called when a Steam lobby is successfully created. - /// Displays success message and triggers server hosting. - /// - /// The unique Steam ID of the created lobby. - /// The username of the lobby host. - private void OnSteamLobbyCreated(CSteamID lobbyId, string username) { - Logger.Info($"Lobby created: {lobbyId}"); - ShowFeedback(Color.green, "Lobby created! Friends can join via Steam overlay."); - - // Start hosting with Steam transport (port 0 as it's not used for Steam P2P) - StartHostButtonPressed?.Invoke("", 0, username, TransportType.Steam, null); - } - /// /// Called when the list of available Steam lobbies is received. /// Auto-joins the first lobby if any are found. @@ -1747,14 +1754,20 @@ private void OnLobbyJoined(CSteamID lobbyId) { /// /// Handles connection to a Steam lobby. /// - private void ConnectToSteamLobby(string connectionData, string username) { + private void ConnectToSteamLobby(string connectionData) { if (!SteamManager.IsInitialized) { ShowFeedback(Color.red, "Steam is not initialized"); return; } - ShowFeedback(Color.green, "Joining Steam lobby..."); - ConnectButtonPressed?.Invoke(connectionData, 0, username, TransportType.Steam, null); + if (!ulong.TryParse(connectionData, out var steamLobbyId)) { + ShowFeedback(Color.red, "Invalid Steam lobby ID."); + Logger.Warn($"ConnectInterface: MMS returned invalid Steam lobby ID '{connectionData}'"); + return; + } + + ShowFeedback(Color.yellow, "Joining Steam lobby..."); + SteamManager.JoinLobby(new CSteamID(steamLobbyId)); } #endregion @@ -1766,6 +1779,16 @@ private void ConnectToSteamLobby(string connectionData, string username) { /// Resets UI state and displays success message. /// public void OnSuccessfulConnect() { + if (SteamManager.IsHostingLobby && _pendingHostedSteamLobbyId != null) { + var steamLobbyId = _pendingHostedSteamLobbyId; + var isPublic = _pendingHostedSteamLobbyIsPublic; + + _pendingHostedSteamLobbyId = null; + _pendingHostedSteamLobbyIsPublic = false; + + MonoBehaviourUtil.Instance.StartCoroutine(FinalizeHostedSteamLobbyCoroutine(steamLobbyId, isPublic)); + } + ShowFeedback(Color.green, MsgConnected); ResetConnectionButtons(); } @@ -1797,8 +1820,6 @@ public void OnFailedConnect(ConnectionFailedResult result, string? fallbackAddre return; } - ResetConnectionButtons(); - // If this was a timeout and we have a pending retry, consume it and re-run the full connect flow. if (_pendingHolePunchRetryLobbyId != null && result.Reason == ConnectionFailedReason.TimedOut) { var lobbyIdToRetry = _pendingHolePunchRetryLobbyId; @@ -1806,12 +1827,14 @@ public void OnFailedConnect(ConnectionFailedResult result, string? fallbackAddre if (ValidateUsername(out var username)) { Logger.Info($"ConnectInterface: Connection timed out. Retrying full join flow for lobby {lobbyIdToRetry} once."); + SetLobbyJoinInProgress(); ShowFeedback(Color.yellow, "Connection timed out. Retrying..."); MonoBehaviourUtil.Instance.StartCoroutine(JoinLobbyCoroutine(lobbyIdToRetry, username)); return; } } + ResetConnectionButtons(); _pendingHolePunchRetryLobbyId = null; var message = GetFailureMessage(result); @@ -1977,9 +2000,19 @@ private void SetMatchmakingStatusFeedback(string message, Color color) { /// Resets the connection buttons to their default state after a connection attempt. /// private void ResetConnectionButtons() { + _isLobbyJoinInProgress = false; ConnectInterfaceHelpers.ResetConnectButtons(_directConnectButton, _lobbyConnectButton); } + /// + /// Marks the matchmaking lobby join path as busy and disables duplicate join presses. + /// + private void SetLobbyJoinInProgress() { + _isLobbyJoinInProgress = true; + _lobbyConnectButton.SetText(ConnectingText); + _lobbyConnectButton.SetInteractable(false); + } + /// /// Converts a connection failure result into a user-friendly error message. /// From 5bb07c3983dd2a91581fe0bb5dfca731f2bea6be Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 16 May 2026 13:16:05 +0300 Subject: [PATCH 2/3] feat: Fixed loopback matchmaking problem --- SSMP/Ui/ConnectInterface.cs | 22 ++++++++++++++-------- SSMP/Util/NetworkingUtil.cs | 11 +++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs index 95373d37..f4750873 100644 --- a/SSMP/Ui/ConnectInterface.cs +++ b/SSMP/Ui/ConnectInterface.cs @@ -1826,7 +1826,9 @@ public void OnFailedConnect(ConnectionFailedResult result, string? fallbackAddre _pendingHolePunchRetryLobbyId = null; if (ValidateUsername(out var username)) { - Logger.Info($"ConnectInterface: Connection timed out. Retrying full join flow for lobby {lobbyIdToRetry} once."); + Logger.Info( + $"ConnectInterface: Connection timed out. Retrying full join flow for lobby {lobbyIdToRetry} once." + ); SetLobbyJoinInProgress(); ShowFeedback(Color.yellow, "Connection timed out. Retrying..."); MonoBehaviourUtil.Instance.StartCoroutine(JoinLobbyCoroutine(lobbyIdToRetry, username)); @@ -2103,15 +2105,19 @@ bool preferLanFastPath return null; // Prefer LAN if available, using public as the fallback relay - if (preferLanFastPath && - !string.IsNullOrEmpty(lanConnectionData) && - TryParseConnectionData(lanConnectionData, out var lanIp, out var lanPort)) { - return new ConnectionInfo( - lanIp, lanPort, $"{publicIp}:{publicPort}", $"Connecting to LAN {lanIp}:{lanPort}..." - ); + if (!preferLanFastPath || string.IsNullOrEmpty(lanConnectionData) || + !TryParseConnectionData(lanConnectionData, out var lanIp, out var lanPort)) { + return new ConnectionInfo(publicIp, publicPort, null, $"Connecting to {publicIp}:{publicPort}..."); } - return new ConnectionInfo(publicIp, publicPort, null, $"Connecting to {publicIp}:{publicPort}..."); + // If the LAN endpoint resolves to one of this machine's own IPv4 addresses, + // the client and host are running on the same device. In that case, prefer + // loopback instead of connecting back through the LAN adapter, since some + // network setups handle that path inconsistently. + // The public endpoint is still kept as the fallback if the local attempt fails. + return NetworkingUtil.IsLocalInterfaceIpv4(lanIp) + ? new ConnectionInfo("127.0.0.1", lanPort, $"{publicIp}:{publicPort}", $"Connecting locally on 127.0.0.1:{lanPort}...") + : new ConnectionInfo(lanIp, lanPort, $"{publicIp}:{publicPort}", $"Connecting to LAN {lanIp}:{lanPort}..."); } /// diff --git a/SSMP/Util/NetworkingUtil.cs b/SSMP/Util/NetworkingUtil.cs index a183286f..5461c4bb 100644 --- a/SSMP/Util/NetworkingUtil.cs +++ b/SSMP/Util/NetworkingUtil.cs @@ -72,6 +72,17 @@ public static IPAddress ResolveBindAddress(string? bindIpAddress) { return IPAddress.Any; } + /// + /// Returns when is an IPv4 address assigned + /// to this machine, including loopback. + /// + public static bool IsLocalInterfaceIpv4(string? value) { + return TryNormalizeIpv4(value, out var normalized) && + IPAddress.TryParse(normalized, out var address) && + address.AddressFamily == AddressFamily.InterNetwork && + IsLocalInterfaceAddress(address); + } + #endregion #region Private Helpers From 4660bbffa0642e28426df3ca6bee1e56d3144b23 Mon Sep 17 00:00:00 2001 From: Extremelyd1 <10898310+Extremelyd1@users.noreply.github.com> Date: Sat, 30 May 2026 10:54:45 +0200 Subject: [PATCH 3/3] Format brackets --- SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs index c8b6e780..645241b7 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -332,8 +332,9 @@ private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, st IsPublic = isPublic, GameVersion = gameVersion, LobbyType = "steam" - }); - + } + ); + /// /// Captures the current session token and lobby ID, then clears both fields. /// Called during to ensure the delete request uses