Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions MMS/Services/Matchmaking/JoinSessionCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) {
/// <param name="port">The external port observed by the server.</param>
/// <param name="cancellationToken">Propagates notification that the operation should be cancelled.</param>
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))
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -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);
Expand Down
12 changes: 8 additions & 4 deletions MMS/Services/Matchmaking/JoinSessionMessenger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ public static async Task<bool> SendStartPunchToClientAsync(
int hostPort,
string hostIp,
long startTimeMs,
CancellationToken cancellationToken
CancellationToken cancellationToken,
long serverTimeMs
) {
if (session.ClientWebSocket is not { State: WebSocketState.Open } ws)
return false;
Expand All @@ -102,7 +103,8 @@ await WebSocketMessenger.SendAsync(
joinId = session.JoinId,
hostIp,
hostPort,
startTimeMs
startTimeMs,
serverTimeMs
},
cancellationToken
);
Expand All @@ -121,7 +123,8 @@ public static async Task<bool> SendStartPunchToHostAsync(
int clientPort,
int hostPort,
long startTimeMs,
CancellationToken cancellationToken
CancellationToken cancellationToken,
long serverTimeMs
) {
if (lobby.HostWebSocket is not { State: WebSocketState.Open } hostWs)
return false;
Expand All @@ -134,7 +137,8 @@ await WebSocketMessenger.SendAsync(
clientIp,
clientPort,
hostPort,
startTimeMs
startTimeMs,
serverTimeMs
},
cancellationToken
);
Expand Down
59 changes: 45 additions & 14 deletions SSMP/Game/SteamManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ public static class SteamManager {
/// </summary>
private static ELobbyType _pendingLobbyType;

/// <summary>
/// The visibility type of the currently active lobby.
/// </summary>
private static ELobbyType _currentLobbyType = DefaultLobbyType;

/// <summary>
/// Callback timer interval in milliseconds (~60Hz).
/// </summary>
Expand All @@ -86,7 +91,6 @@ public static class SteamManager {
/// </summary>
private const string LobbyKeyName = "name";
private const string LobbyKeyVersion = "version";

/// <summary>
/// Cached mod version string from assembly metadata.
/// </summary>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -256,6 +261,43 @@ public static void OpenInviteDialog() {
SteamFriends.ActivateGameOverlayInviteDialog(CurrentLobbyId);
}

/// <summary>
/// Marks the hosted lobby as ready or not ready for inbound players.
/// Steam join links stay disabled until the host is actually listening.
/// </summary>
/// <param name="isReady">Whether the host has finished startup and can accept joins.</param>
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");
}
}

/// <summary>
/// Shuts down the Steam API.
/// Should be called on application exit if Steam was initialized.
Expand Down Expand Up @@ -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}");

Expand All @@ -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");
Expand Down
50 changes: 42 additions & 8 deletions SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ internal sealed class MmsHostSessionService : IDisposable {
/// </summary>
private string? _currentLobbyId;

/// <summary>
/// The most recent host discovery token returned by MMS for the active matchmaking lobby.
/// </summary>
private string? _currentHostDiscoveryToken;

/// <summary>MMS lobby keep-alive timer.</summary>
private Timer? _heartbeatTimer;

Expand All @@ -60,7 +65,7 @@ internal sealed class MmsHostSessionService : IDisposable {
/// <c>null</c> when no refresh is running.
/// </summary>
private CancellationTokenSource? _hostDiscoveryRefreshCts;

/// <summary>WebSocket handler that receives real-time MMS server events.</summary>
public MmsWebSocketHandler WebSocket { get; }

Expand Down Expand Up @@ -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}. " +
Expand All @@ -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,
Expand Down Expand Up @@ -272,6 +281,26 @@ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action<byte[],
);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="sendRawAction">Callback that writes raw bytes through the host's live UDP socket.</param>
public void StartInitialHostDiscoveryRefresh(Action<byte[], IPEndPoint> 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);
}

/// <summary>
/// Cancels the active UDP discovery refresh task, if any.
/// Safe to call when no refresh is running.
Expand All @@ -297,11 +326,14 @@ public void StopHostDiscoveryRefresh() {
/// <param name="gameVersion">Game version string for compatibility filtering.</param>
/// <returns>A JSON string ready to POST to the MMS lobby endpoint.</returns>
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"
}
);

/// <summary>
/// Captures the current session token and lobby ID, then clears both fields.
Expand All @@ -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;
}

Expand Down Expand Up @@ -355,6 +388,7 @@ out hostDiscoveryToken

_hostToken = hostToken;
_currentLobbyId = lobbyId;
_currentHostDiscoveryToken = hostDiscoveryToken;
_heartbeatFailureCount = 0;
StartHeartbeat();
}
Expand Down
8 changes: 5 additions & 3 deletions SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable {

/// <summary>
/// Raised when MMS signals both sides to start simultaneous hole-punch.
/// Arguments: joinId, clientIp, clientPort, hostPort, startTimeMs.
/// Arguments: joinId, clientIp, clientPort, hostPort, serverTimeMs, startTimeMs.
/// <para>
/// Handlers are always invoked on the <see cref="SynchronizationContext"/> that was
/// active when this handler was constructed (typically the Unity main thread).
Expand All @@ -59,7 +59,7 @@ internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable {
/// order, but may execute across different frames.
/// </para>
/// </summary>
public event Action<string, string, int, int, long>? StartPunchRequested;
public event Action<string, string, int, int, long, long>? StartPunchRequested;

/// <summary>Raised on mapping confirmation. Marshaled to construction thread.</summary>
public event Action? HostMappingReceived;
Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down
34 changes: 31 additions & 3 deletions SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Action<string> onJoinFailed
DiscoverySession discovery,
Action<string> onJoinFailed
) {
discovery.ObserveServerTime(message);

var action = MmsJsonParser.ExtractValue(message, MmsFields.Action);
if (action == null) {
Logger.Debug("MmsWebSocketHandler: invalid message, no defined action");
Expand Down Expand Up @@ -173,7 +175,9 @@ Action<string> 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;
}

Expand Down Expand Up @@ -209,9 +213,12 @@ Action<string> onJoinFailed
/// is far in the future, the delay will simply block until <paramref name="ct"/> fires.
/// </summary>
/// <param name="targetUnixMs">Target time expressed as milliseconds since the Unix epoch (UTC).</param>
/// <param name="estimatedServerOffsetMs">Best estimate of local-minus-MMS clock offset in milliseconds.</param>
/// <param name="ct">Cancellation token that can abort the wait early.</param>
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);
}

Expand All @@ -237,6 +244,27 @@ private sealed class DiscoverySession : IDisposable {
/// </summary>
public CancellationTokenSource? Cts;

/// <summary>
/// Best observed mapping from MMS server time to local UTC clock.
/// Uses the minimum observed local-minus-server delta to reduce network-latency bias.
/// </summary>
public long? MinObservedServerOffsetMs { get; private set; }

/// <summary>
/// Updates the best-known server-to-local clock offset from a message that includes server time.
/// </summary>
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;
}

/// <summary>Cancels <see cref="Cts"/> without disposing it.</summary>
public void Cancel() {
Cts?.Cancel();
Expand Down
Loading
Loading