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..645241b7 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,11 +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.
@@ -318,6 +350,7 @@ private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, st
var snapshot = (_hostToken!, _currentLobbyId);
_hostToken = null;
_currentLobbyId = null;
+ _currentHostDiscoveryToken = null;
return snapshot;
}
@@ -355,6 +388,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..f4750873 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,21 +1820,23 @@ 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;
_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));
return;
}
}
+ ResetConnectionButtons();
_pendingHolePunchRetryLobbyId = null;
var message = GetFailureMessage(result);
@@ -1977,9 +2002,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.
///
@@ -2070,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