diff --git a/MMS/MMS.csproj b/MMS/MMS.csproj
new file mode 100644
index 0000000..8db86c8
--- /dev/null
+++ b/MMS/MMS.csproj
@@ -0,0 +1,12 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/MMS/Models/Lobby.cs b/MMS/Models/Lobby.cs
new file mode 100644
index 0000000..21eac91
--- /dev/null
+++ b/MMS/Models/Lobby.cs
@@ -0,0 +1,58 @@
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+
+namespace MMS.Models;
+
+///
+/// Client waiting for NAT hole-punch.
+///
+public record PendingClient(string ClientIp, int ClientPort, DateTime RequestedAt);
+
+///
+/// Game lobby. ConnectionData serves as both identifier and connection info.
+/// Steam: ConnectionData = Steam lobby ID. Matchmaking: ConnectionData = IP:Port.
+///
+public class Lobby(
+ string connectionData,
+ string hostToken,
+ string lobbyCode,
+ string lobbyName,
+ string lobbyType = "matchmaking",
+ string? hostLanIp = null,
+ bool isPublic = true
+) {
+ /// Connection data: Steam lobby ID for Steam, IP:Port for matchmaking.
+ public string ConnectionData { get; } = connectionData;
+
+ /// Secret token for host authentication.
+ public string HostToken { get; } = hostToken;
+
+ /// Human-readable 6-character invite code.
+ public string LobbyCode { get; } = lobbyCode;
+
+ /// Display name of the lobby.
+ public string LobbyName { get; } = lobbyName;
+
+ /// Lobby type: "steam" or "matchmaking".
+ public string LobbyType { get; } = lobbyType;
+
+ /// Optional LAN IP for local network discovery.
+ public string? HostLanIp { get; } = hostLanIp;
+
+ /// Whether the lobby should appear in public browser listings.
+ public bool IsPublic { get; } = isPublic;
+
+ /// Timestamp of the last heartbeat from the host.
+ public DateTime LastHeartbeat { get; set; } = DateTime.UtcNow;
+
+ /// Queue of clients waiting for NAT hole-punch.
+ public ConcurrentQueue PendingClients { get; } = new();
+
+ /// True if no heartbeat received in the last 60 seconds.
+ public bool IsDead => DateTime.UtcNow - LastHeartbeat > TimeSpan.FromSeconds(60);
+
+ ///
+ /// WebSocket connection from the host for push notifications.
+ ///
+ public WebSocket? HostWebSocket { get; set; }
+}
diff --git a/MMS/Program.cs b/MMS/Program.cs
new file mode 100644
index 0000000..7befe00
--- /dev/null
+++ b/MMS/Program.cs
@@ -0,0 +1,345 @@
+#pragma warning disable CS1587 // XML comment is not placed on a valid language element
+
+using System.Net;
+using System.Net.WebSockets;
+using System.Text;
+using JetBrains.Annotations;
+using MMS.Services;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Logging.ClearProviders();
+builder.Logging.AddSimpleConsole(options => {
+ options.SingleLine = true;
+ options.IncludeScopes = false;
+ options.TimestampFormat = "HH:mm:ss ";
+ }
+);
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+if (!app.Environment.IsDevelopment()) {
+ app.UseExceptionHandler("/error");
+}
+
+app.UseWebSockets();
+MapEndpoints(app);
+app.Urls.Add("http://0.0.0.0:5000");
+app.Run();
+
+#region Endpoint Registration
+
+static void MapEndpoints(WebApplication app) {
+ var lobbyService = app.Services.GetRequiredService();
+
+ // Health & Monitoring
+ app.MapGet("/", () => Results.Ok(new { service = "MMS", version = "1.0", status = "healthy" }))
+ .WithName("HealthCheck");
+ app.MapGet("/lobbies", GetLobbies).WithName("ListLobbies");
+
+ // Lobby Management
+ app.MapPost("/lobby", CreateLobby).WithName("CreateLobby");
+ app.MapGet("/lobby/{connectionData}", GetLobby).WithName("GetLobby");
+ app.MapGet("/lobby/mine/{token}", GetMyLobby).WithName("GetMyLobby");
+ app.MapDelete("/lobby/{token}", CloseLobby).WithName("CloseLobby");
+
+ // Host Operations
+ app.MapPost("/lobby/heartbeat/{token}", Heartbeat).WithName("Heartbeat");
+ app.MapGet("/lobby/pending/{token}", GetPendingClients).WithName("GetPendingClients");
+
+ // WebSocket for host push notifications
+ app.Map("/ws/{token}", async (HttpContext context, string token) => {
+ if (!context.WebSockets.IsWebSocketRequest) {
+ context.Response.StatusCode = 400;
+ return;
+ }
+
+ var lobby = lobbyService.GetLobbyByToken(token);
+ if (lobby == null) {
+ context.Response.StatusCode = 404;
+ return;
+ }
+
+ using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+ lobby.HostWebSocket = webSocket;
+ Console.WriteLine($"[WS] Host connected for lobby {lobby.ConnectionData}");
+
+ // Keep connection alive until closed
+ var buffer = new byte[1024];
+ try {
+ while (webSocket.State == WebSocketState.Open) {
+ var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
+ if (result.MessageType == WebSocketMessageType.Close) break;
+ }
+ } catch (WebSocketException) {
+ // Host disconnected without proper close handshake (normal during game exit)
+ } catch (Exception ex) when (ex.InnerException is System.Net.Sockets.SocketException) {
+ // Connection forcibly reset (normal during game exit)
+ } finally {
+ lobby.HostWebSocket = null;
+ Console.WriteLine($"[WS] Host disconnected from lobby {lobby.ConnectionData}");
+ }
+ });
+
+ // Client Operations
+ app.MapPost("/lobby/{connectionData}/join", JoinLobby).WithName("JoinLobby");
+}
+
+#endregion
+
+#region Endpoint Handlers
+
+///
+/// Returns all lobbies, optionally filtered by type.
+///
+static Ok> GetLobbies(LobbyService lobbyService, string? type = null) {
+ var lobbies = lobbyService.GetLobbies(type)
+ .Select(l => new LobbyResponse(
+ l.ConnectionData,
+ l.LobbyName,
+ l.LobbyType,
+ l.LobbyCode
+ )
+ );
+ return TypedResults.Ok(lobbies);
+}
+
+///
+/// Creates a new lobby (Steam or Matchmaking).
+///
+static Created CreateLobby(
+ CreateLobbyRequest request,
+ LobbyService lobbyService,
+ HttpContext context
+) {
+ var lobbyType = request.LobbyType ?? "matchmaking";
+ string connectionData;
+
+ if (lobbyType == "steam") {
+ if (string.IsNullOrEmpty(request.ConnectionData)) {
+ return TypedResults.Created(
+ "/lobby/invalid",
+ new CreateLobbyResponse("error", "Steam lobby requires ConnectionData", "")
+ );
+ }
+
+ connectionData = request.ConnectionData;
+ } else {
+ var rawHostIp = request.HostIp ?? context.Connection.RemoteIpAddress?.ToString();
+ if (string.IsNullOrEmpty(rawHostIp) || !IPAddress.TryParse(rawHostIp, out var parsedHostIp)) {
+ return TypedResults.Created(
+ "/lobby/invalid",
+ new CreateLobbyResponse("error", "Invalid IP address", "")
+ );
+ }
+ var hostIp = parsedHostIp.ToString();
+ if (request.HostPort is null or <= 0 or > 65535) {
+ return TypedResults.Created(
+ "/lobby/invalid",
+ new CreateLobbyResponse("error", "Invalid port number", "")
+ );
+ }
+
+ connectionData = $"{hostIp}:{request.HostPort}";
+ }
+
+ var lobby = lobbyService.CreateLobby(
+ connectionData,
+ request.LobbyName ?? "Unnamed Lobby",
+ lobbyType,
+ request.HostLanIp,
+ request.IsPublic ?? true
+ );
+
+ var visibility = lobby.IsPublic ? "Public" : "Private";
+ Console.WriteLine($"[LOBBY] Created: '{lobby.LobbyName}' [{lobby.LobbyType}] ({visibility}) -> {lobby.ConnectionData} (Code: {lobby.LobbyCode})");
+ return TypedResults.Created($"/lobby/{lobby.ConnectionData}", new CreateLobbyResponse(lobby.ConnectionData, lobby.HostToken, lobby.LobbyCode));
+}
+
+///
+/// Gets lobby info by ConnectionData.
+///
+static Results, NotFound> GetLobby(string connectionData, LobbyService lobbyService) {
+ // Try as lobby code first, then as connectionData
+ var lobby = lobbyService.GetLobbyByCode(connectionData) ?? lobbyService.GetLobby(connectionData);
+ return lobby == null
+ ? TypedResults.NotFound(new ErrorResponse("Lobby not found"))
+ : TypedResults.Ok(new LobbyResponse(lobby.ConnectionData, lobby.LobbyName, lobby.LobbyType, lobby.LobbyCode));
+}
+
+///
+/// Gets lobby info by host token.
+///
+static Results, NotFound> GetMyLobby(string token, LobbyService lobbyService) {
+ var lobby = lobbyService.GetLobbyByToken(token);
+ return lobby == null
+ ? TypedResults.NotFound(new ErrorResponse("Lobby not found"))
+ : TypedResults.Ok(new LobbyResponse(lobby.ConnectionData, lobby.LobbyName, lobby.LobbyType, lobby.LobbyCode));
+}
+
+
+
+///
+/// Closes a lobby by host token.
+///
+static Results> CloseLobby(string token, LobbyService lobbyService) {
+ if (!lobbyService.RemoveLobbyByToken(token)) {
+ return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+ }
+
+ Console.WriteLine("[LOBBY] Closed by host");
+ return TypedResults.NoContent();
+}
+
+///
+/// Refreshes lobby heartbeat to prevent expiration.
+///
+static Results, NotFound> Heartbeat(string token, LobbyService lobbyService) {
+ return lobbyService.Heartbeat(token)
+ ? TypedResults.Ok(new StatusResponse("alive"))
+ : TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+}
+
+///
+/// Returns pending clients waiting for NAT hole-punch (clears the queue).
+///
+static Results>, NotFound> GetPendingClients(
+ string token,
+ LobbyService lobbyService
+) {
+ var lobby = lobbyService.GetLobbyByToken(token);
+ if (lobby == null) {
+ return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+ }
+
+ var pending = new List();
+ var cutoff = DateTime.UtcNow.AddSeconds(-30);
+
+ while (lobby.PendingClients.TryDequeue(out var client)) {
+ if (client.RequestedAt >= cutoff) {
+ pending.Add(new PendingClientResponse(client.ClientIp, client.ClientPort));
+ }
+ }
+
+ return TypedResults.Ok(pending);
+}
+
+///
+/// Notifies host of pending client and returns host connection info.
+/// Uses WebSocket push if available, otherwise queues for polling.
+///
+static async Task, NotFound>> JoinLobby(
+ string connectionData,
+ JoinLobbyRequest request,
+ LobbyService lobbyService,
+ HttpContext context
+) {
+ // Try as lobby code first, then as connectionData
+ var lobby = lobbyService.GetLobbyByCode(connectionData) ?? lobbyService.GetLobby(connectionData);
+ if (lobby == null) {
+ return TypedResults.NotFound(new ErrorResponse("Lobby not found"));
+ }
+
+ var rawClientIp = request.ClientIp ?? context.Connection.RemoteIpAddress?.ToString();
+ if (string.IsNullOrEmpty(rawClientIp) || !IPAddress.TryParse(rawClientIp, out var parsedIp)) {
+ return TypedResults.NotFound(new ErrorResponse("Invalid IP address"));
+ }
+ var clientIp = parsedIp.ToString();
+
+ if (request.ClientPort is <= 0 or > 65535) {
+ return TypedResults.NotFound(new ErrorResponse("Invalid port"));
+ }
+
+ Console.WriteLine($"[JOIN] {clientIp}:{request.ClientPort} -> {lobby.ConnectionData}");
+
+ // Try WebSocket push first (instant notification)
+ if (lobby.HostWebSocket is { State: WebSocketState.Open }) {
+ var message = $"{{\"clientIp\":\"{clientIp}\",\"clientPort\":{request.ClientPort}}}";
+ var bytes = Encoding.UTF8.GetBytes(message);
+ await lobby.HostWebSocket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
+ Console.WriteLine($"[WS] Pushed client to host via WebSocket");
+ } else {
+ // Fallback to queue for polling (legacy clients)
+ lobby.PendingClients.Enqueue(new MMS.Models.PendingClient(clientIp, request.ClientPort, DateTime.UtcNow));
+ }
+
+ // Check if client is on the same network as the host
+ var joinConnectionData = lobby.ConnectionData;
+
+ // We can only check IP equality if we have the host's IP (for matchmaking lobbies mainly)
+ // NOTE: This assumes lobby.ConnectionData is in "IP:Port" format for matchmaking
+ if (!string.IsNullOrEmpty(lobby.HostLanIp)) {
+ // Parse Host Public IP from ConnectionData (format: "IP:Port")
+ var hostPublicIp = lobby.ConnectionData.Split(':')[0];
+
+ if (clientIp == hostPublicIp) {
+ Console.WriteLine($"[JOIN] Local Network Detected! Returning LAN IP: {lobby.HostLanIp}");
+ joinConnectionData = lobby.HostLanIp;
+ }
+ }
+
+ return TypedResults.Ok(new JoinResponse(joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort));
+}
+
+#endregion
+
+#region DTOs
+
+/// Host IP (Matchmaking only, optional).
+/// Host port (Matchmaking only).
+/// Steam lobby ID (Steam only).
+/// Display name for the lobby.
+/// "steam" or "matchmaking" (default: matchmaking).
+/// Host LAN IP for local network discovery.
+/// Whether lobby appears in browser (default: true).
+internal abstract record CreateLobbyRequest(
+ string? HostIp,
+ int? HostPort,
+ string? ConnectionData,
+ string? LobbyName,
+ string? LobbyType,
+ string? HostLanIp,
+ bool? IsPublic
+);
+
+/// Connection identifier (IP:Port or Steam lobby ID).
+/// Secret token for host operations.
+/// Human-readable invite code.
+internal record CreateLobbyResponse([UsedImplicitly] string ConnectionData, string HostToken, string LobbyCode);
+
+/// Connection identifier (IP:Port or Steam lobby ID).
+/// Display name.
+/// "steam" or "matchmaking".
+/// Human-readable invite code.
+internal record LobbyResponse(
+ [UsedImplicitly] string ConnectionData,
+ string Name,
+ string LobbyType,
+ string LobbyCode
+);
+
+/// Client IP (optional - uses connection IP if null).
+/// Client's local port for hole-punching.
+internal record JoinLobbyRequest([UsedImplicitly] string? ClientIp, int ClientPort);
+
+/// Host connection data (IP:Port or Steam lobby ID).
+/// "steam" or "matchmaking".
+/// Client's public IP as seen by MMS.
+/// Client's public port.
+internal record JoinResponse([UsedImplicitly] string ConnectionData, string LobbyType, string ClientIp, int ClientPort);
+
+/// Pending client's IP.
+/// Pending client's port.
+internal record PendingClientResponse([UsedImplicitly] string ClientIp, int ClientPort);
+
+/// Error message.
+internal record ErrorResponse([UsedImplicitly] string Error);
+
+/// Status message.
+internal record StatusResponse([UsedImplicitly] string Status);
+
+#endregion
diff --git a/MMS/Services/LobbyCleanupService.cs b/MMS/Services/LobbyCleanupService.cs
new file mode 100644
index 0000000..0fcc8d3
--- /dev/null
+++ b/MMS/Services/LobbyCleanupService.cs
@@ -0,0 +1,17 @@
+namespace MMS.Services;
+
+/// Background service that removes expired lobbies every 30 seconds.
+public class LobbyCleanupService(LobbyService lobbyService) : BackgroundService {
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ Console.WriteLine("[CLEANUP] Service started");
+
+ while (!stoppingToken.IsCancellationRequested) {
+ await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
+
+ var removed = lobbyService.CleanupDeadLobbies();
+ if (removed > 0) {
+ Console.WriteLine($"[CLEANUP] Removed {removed} expired lobbies");
+ }
+ }
+ }
+}
diff --git a/MMS/Services/LobbyService.cs b/MMS/Services/LobbyService.cs
new file mode 100644
index 0000000..fcd0971
--- /dev/null
+++ b/MMS/Services/LobbyService.cs
@@ -0,0 +1,167 @@
+using System.Collections.Concurrent;
+using MMS.Models;
+
+namespace MMS.Services;
+
+///
+/// Thread-safe in-memory lobby storage with heartbeat-based expiration.
+/// Lobbies are keyed by ConnectionData (Steam ID or IP:Port).
+///
+public class LobbyService {
+ /// Thread-safe dictionary of lobbies keyed by ConnectionData.
+ private readonly ConcurrentDictionary _lobbies = new();
+
+ /// Maps host tokens to ConnectionData for quick lookup.
+ private readonly ConcurrentDictionary _tokenToConnectionData = new();
+
+ /// Maps lobby codes to ConnectionData for quick lookup.
+ private readonly ConcurrentDictionary _codeToConnectionData = new();
+
+ /// Random number generator for token and code generation.
+ private static readonly Random Random = new();
+
+ /// Characters used for host authentication tokens (lowercase alphanumeric).
+ private const string TokenChars = "abcdefghijklmnopqrstuvwxyz0123456789";
+
+ /// Characters used for lobby codes (uppercase alphanumeric).
+ private const string LobbyCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+
+ /// Length of generated lobby codes.
+ private const int LobbyCodeLength = 6;
+
+ ///
+ /// Creates a new lobby keyed by ConnectionData.
+ ///
+ public Lobby CreateLobby(
+ string connectionData,
+ string lobbyName,
+ string lobbyType = "matchmaking",
+ string? hostLanIp = null,
+ bool isPublic = true
+ ) {
+ var hostToken = GenerateToken(32);
+
+ // Only generate lobby codes for matchmaking lobbies
+ // Steam lobbies use Steam's native join flow (no MMS invite codes)
+ var lobbyCode = lobbyType == "steam" ? "" : GenerateLobbyCode();
+ var lobby = new Lobby(connectionData, hostToken, lobbyCode, lobbyName, lobbyType, hostLanIp, isPublic);
+
+ _lobbies[connectionData] = lobby;
+ _tokenToConnectionData[hostToken] = connectionData;
+
+ // Only register code if we generated one
+ if (!string.IsNullOrEmpty(lobbyCode)) {
+ _codeToConnectionData[lobbyCode] = connectionData;
+ }
+
+ return lobby;
+ }
+
+ ///
+ /// Gets lobby by ConnectionData. Returns null if not found or expired.
+ ///
+ public Lobby? GetLobby(string connectionData) {
+ if (!_lobbies.TryGetValue(connectionData, out var lobby)) return null;
+ if (!lobby.IsDead) return lobby;
+
+ RemoveLobby(connectionData);
+ return null;
+ }
+
+ ///
+ /// Gets lobby by host token. Returns null if not found or expired.
+ ///
+ public Lobby? GetLobbyByToken(string token) {
+ return _tokenToConnectionData.TryGetValue(token, out var connData) ? GetLobby(connData) : null;
+ }
+
+ ///
+ /// Gets lobby by lobby code. Returns null if not found or expired.
+ ///
+ public Lobby? GetLobbyByCode(string code) {
+ // Normalize to uppercase for case-insensitive matching
+ var normalizedCode = code.ToUpperInvariant();
+ return _codeToConnectionData.TryGetValue(normalizedCode, out var connData) ? GetLobby(connData) : null;
+ }
+
+ ///
+ /// Refreshes lobby heartbeat. Returns false if lobby not found.
+ ///
+ public bool Heartbeat(string token) {
+ var lobby = GetLobbyByToken(token);
+ if (lobby == null) return false;
+
+ lobby.LastHeartbeat = DateTime.UtcNow;
+ return true;
+ }
+
+ ///
+ /// Removes lobby by host token. Returns false if not found.
+ ///
+ public bool RemoveLobbyByToken(string token) {
+ var lobby = GetLobbyByToken(token);
+ return lobby != null && RemoveLobby(lobby.ConnectionData);
+ }
+
+ ///
+ /// Returns all active (non-expired) lobbies.
+ ///
+ public IEnumerable GetAllLobbies() => _lobbies.Values.Where(l => !l.IsDead);
+
+ ///
+ /// Returns active PUBLIC lobbies, optionally filtered by type ("steam" or "matchmaking").
+ /// Private lobbies are excluded from browser listings.
+ ///
+ public IEnumerable GetLobbies(string? lobbyType = null) {
+ var lobbies = _lobbies.Values.Where(l => !l.IsDead && l.IsPublic);
+ return string.IsNullOrEmpty(lobbyType)
+ ? lobbies
+ : lobbies.Where(l => l.LobbyType.Equals(lobbyType, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Removes all expired lobbies. Returns count removed.
+ ///
+ public int CleanupDeadLobbies() {
+ var dead = _lobbies.Values.Where(l => l.IsDead).ToList();
+ foreach (var lobby in dead) {
+ RemoveLobby(lobby.ConnectionData);
+ }
+ return dead.Count;
+ }
+
+ ///
+ /// Removes a lobby by its ConnectionData and cleans up token/code mappings.
+ ///
+ /// The ConnectionData of the lobby to remove.
+ /// True if the lobby was found and removed; otherwise, false.
+ private bool RemoveLobby(string connectionData) {
+ if (!_lobbies.TryRemove(connectionData, out var lobby)) return false;
+ _tokenToConnectionData.TryRemove(lobby.HostToken, out _);
+ _codeToConnectionData.TryRemove(lobby.LobbyCode, out _);
+ return true;
+ }
+
+ ///
+ /// Generates a random token of the specified length.
+ ///
+ /// Length of the token to generate.
+ /// A random alphanumeric token string.
+ private static string GenerateToken(int length) {
+ return new string(Enumerable.Range(0, length).Select(_ => TokenChars[Random.Next(TokenChars.Length)]).ToArray());
+ }
+
+ ///
+ /// Generates a unique lobby code, retrying on collision.
+ ///
+ /// A unique 6-character uppercase alphanumeric code.
+ private string GenerateLobbyCode() {
+ // Generate unique code, retry if collision (extremely rare with 30^6 = 729M combinations)
+ string code;
+ do {
+ code = new string(Enumerable.Range(0, LobbyCodeLength)
+ .Select(_ => LobbyCodeChars[Random.Next(LobbyCodeChars.Length)]).ToArray());
+ } while (_codeToConnectionData.ContainsKey(code));
+ return code;
+ }
+}
diff --git a/SSMP.sln b/SSMP.sln
index 49c016d..6ab95e8 100644
--- a/SSMP.sln
+++ b/SSMP.sln
@@ -4,19 +4,59 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSMP", "SSMP\SSMP.csproj",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSMPServer", "SSMPServer\SSMPServer.csproj", "{6AAF3681-32CB-4470-BA06-7203A907FCB5}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MMS", "MMS\MMS.csproj", "{5C66A9FF-F323-4269-B33F-B21245AAF9F5}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|x64.Build.0 = Debug|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|x86.Build.0 = Debug|Any CPU
{5EA55B06-181D-4031-A61A-85BA60F106AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5EA55B06-181D-4031-A61A-85BA60F106AB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Release|x64.ActiveCfg = Release|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Release|x64.Build.0 = Release|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Release|x86.ActiveCfg = Release|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Release|x86.Build.0 = Release|Any CPU
+ {5EA55B06-181D-4031-A61A-85BA60F106AB}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|x64.Build.0 = Debug|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|x86.Build.0 = Debug|Any CPU
{6AAF3681-32CB-4470-BA06-7203A907FCB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6AAF3681-32CB-4470-BA06-7203A907FCB5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Release|x64.ActiveCfg = Release|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Release|x64.Build.0 = Release|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Release|x86.ActiveCfg = Release|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Release|x86.Build.0 = Release|Any CPU
+ {6AAF3681-32CB-4470-BA06-7203A907FCB5}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|x64.Build.0 = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|x86.Build.0 = Debug|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Release|x64.ActiveCfg = Release|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Release|x64.Build.0 = Release|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Release|x86.ActiveCfg = Release|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Release|x86.Build.0 = Release|Any CPU
+ {5C66A9FF-F323-4269-B33F-B21245AAF9F5}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs
index 4bbca21..e7d1ea7 100644
--- a/SSMP/Game/Client/ClientManager.cs
+++ b/SSMP/Game/Client/ClientManager.cs
@@ -20,6 +20,7 @@
using SSMP.Networking.Packet.Data;
using SSMP.Networking.Packet.Update;
using SSMP.Networking.Transport.Common;
+using SSMP.Networking.Transport.HolePunch;
using SSMP.Networking.Transport.SteamP2P;
using SSMP.Networking.Transport.UDP;
using SSMP.Ui;
@@ -371,6 +372,7 @@ private void DeregisterHooks() {
private void RegisterCommands() {
_commandManager.RegisterCommand(new AddonCommand(_addonManager, _netClient));
_commandManager.RegisterCommand(new DebugCommand());
+ _commandManager.RegisterCommand(new InviteCommand());
}
///
@@ -510,6 +512,7 @@ private void Connect(string address, int port, string username, TransportType tr
var transport = transportType switch {
TransportType.Udp => (IEncryptedTransport)new UdpEncryptedTransport(),
TransportType.Steam => new SteamEncryptedTransport(),
+ TransportType.HolePunch => new HolePunchEncryptedTransport(),
_ => throw new ArgumentOutOfRangeException(nameof(transportType), transportType, "Unsupported transport type")
};
diff --git a/SSMP/Game/Command/Client/InviteCommand.cs b/SSMP/Game/Command/Client/InviteCommand.cs
new file mode 100644
index 0000000..e20170c
--- /dev/null
+++ b/SSMP/Game/Command/Client/InviteCommand.cs
@@ -0,0 +1,36 @@
+using SSMP.Api.Command;
+using SSMP.Api.Command.Client;
+using SSMP.Ui;
+
+namespace SSMP.Game.Command.Client;
+
+///
+/// Command to open Steam's invite dialog for sending lobby invites.
+/// Primarily used for private lobbies where friends can't "Join Game".
+///
+internal class InviteCommand : IClientCommand, ICommandWithDescription {
+ ///
+ public string Trigger => "/invite";
+
+ ///
+ public string[] Aliases => ["/inv"];
+
+ ///
+ public string Description => "Open Steam's invite dialog to invite friends to your lobby.";
+
+ ///
+ public void Execute(string[] arguments) {
+ if (!SteamManager.IsInitialized) {
+ UiManager.InternalChatBox.AddMessage("Steam is not available.");
+ return;
+ }
+
+ if (!SteamManager.IsHostingLobby) {
+ UiManager.InternalChatBox.AddMessage("You must be hosting a Steam lobby to invite players.");
+ return;
+ }
+
+ SteamManager.OpenInviteDialog();
+ UiManager.InternalChatBox.AddMessage("Opening Steam invite dialog...");
+ }
+}
diff --git a/SSMP/Game/GameManager.cs b/SSMP/Game/GameManager.cs
index 686446b..55ffcee 100644
--- a/SSMP/Game/GameManager.cs
+++ b/SSMP/Game/GameManager.cs
@@ -77,12 +77,16 @@ public void Initialize() {
// Initialize Steam if available
if (SteamManager.Initialize()) {
- // Register Steam callback updates on Unity's update loop
- MonoBehaviourUtil.Instance.OnUpdateEvent += SteamManager.RunCallbacks;
+ // Hook lobby cleanup to UI's stop request (explicit user action), NOT ServerShutdownEvent
+ // ServerShutdownEvent fires on server restarts too, which would prematurely clean up lobbies
+ _uiManager.RequestServerStopHostEvent += () => {
+ SteamManager.LeaveLobby();
+ // Also close MMS lobby registration if any (for public Steam lobbies)
+ _uiManager.ConnectInterface.MmsClient.CloseLobby();
+ };
}
_uiManager.Initialize();
-
_serverManager.Initialize();
_clientManager.Initialize(_serverManager);
}
diff --git a/SSMP/Game/Server/ModServerManager.cs b/SSMP/Game/Server/ModServerManager.cs
index 1642247..fc49b52 100644
--- a/SSMP/Game/Server/ModServerManager.cs
+++ b/SSMP/Game/Server/ModServerManager.cs
@@ -4,6 +4,7 @@
using SSMP.Networking.Packet;
using SSMP.Networking.Server;
using SSMP.Networking.Transport.Common;
+using SSMP.Networking.Transport.HolePunch;
using SSMP.Networking.Transport.SteamP2P;
using SSMP.Networking.Transport.UDP;
using SSMP.Ui;
@@ -25,6 +26,8 @@ internal class ModServerManager : ServerManager {
///
private readonly ModSettings _modSettings;
+
+
///
/// The settings command.
///
@@ -42,7 +45,7 @@ public ModServerManager(
ServerSettings serverSettings,
UiManager uiManager,
ModSettings modSettings
- ) : base(netServer, packetManager, serverSettings) {
+ ) : base(netServer, packetManager, serverSettings) {
_uiManager = uiManager;
_modSettings = modSettings;
_settingsCommand = new SettingsCommand(this, InternalServerSettings);
@@ -91,12 +94,20 @@ private void OnRequestServerStartHost(int port, bool fullSynchronisation, Transp
IEncryptedTransportServer transportServer = transportType switch {
TransportType.Udp => new UdpEncryptedTransportServer(),
TransportType.Steam => new SteamEncryptedTransportServer(),
+ TransportType.HolePunch => CreateHolePunchServer(),
_ => throw new ArgumentOutOfRangeException(nameof(transportType), transportType, null)
};
Start(port, fullSynchronisation, transportServer);
}
+ ///
+ /// Creates a HolePunch server with the MmsClient for lobby cleanup on shutdown.
+ ///
+ private HolePunchEncryptedTransportServer CreateHolePunchServer() {
+ return new HolePunchEncryptedTransportServer(_uiManager.ConnectInterface.MmsClient);
+ }
+
///
protected override void RegisterCommands() {
base.RegisterCommands();
diff --git a/SSMP/Game/Server/ServerManager.cs b/SSMP/Game/Server/ServerManager.cs
index 4f4eb5a..35dc6e7 100644
--- a/SSMP/Game/Server/ServerManager.cs
+++ b/SSMP/Game/Server/ServerManager.cs
@@ -79,6 +79,11 @@ internal abstract class ServerManager : IServerManager {
///
public readonly ServerSettings InternalServerSettings;
+ ///
+ /// Lock to synchronize Start/Stop operations, ensuring cleanup completes before restart.
+ ///
+ private readonly object _serverStateLock = new();
+
///
/// The server command manager instance.
///
@@ -367,39 +372,53 @@ private void DeregisterPacketHandlers() {
/// Whether full synchronisation should be enabled.
/// The transport server to use.
public virtual void Start(int port, bool fullSynchronisation, IEncryptedTransportServer transportServer) {
- // Stop existing server
- if (_netServer.IsStarted) {
- Logger.Info("Server was running, shutting it down before starting");
- _netServer.Stop();
- }
+ lock (_serverStateLock) {
+ // Stop existing server (including deregistering commands)
+ if (_netServer.IsStarted) {
+ Logger.Info("Server was running, shutting it down before starting");
+ StopInternal();
+ }
- FullSynchronisation = fullSynchronisation;
-
- RegisterCommands();
- RegisterPacketHandlers();
+ FullSynchronisation = fullSynchronisation;
+
+ RegisterCommands();
+ RegisterPacketHandlers();
- // Start server again with given port
- _netServer.Start(port, transportServer);
+ // Start server again with given port
+ _netServer.Start(port, transportServer);
+ }
}
///
/// Stops the currently running server.
///
public void Stop() {
- if (_netServer.IsStarted) {
- // Before shutting down, send TCP packets to all clients indicating
- // that the server is shutting down
- _netServer.SetDataForAllClients(updateManager => {
- updateManager.SetDisconnect(DisconnectReason.Shutdown);
- });
-
- _netServer.Stop();
-
- DeregisterCommands();
- DeregisterPacketHandlers();
+ lock (_serverStateLock) {
+ StopInternal();
}
}
+ ///
+ /// Internal stop logic without locking (called from Start and Stop).
+ ///
+ private void StopInternal() {
+ if (!_netServer.IsStarted) return;
+
+ // Before shutting down, send TCP packets to all clients indicating
+ // that the server is shutting down
+ _netServer.SetDataForAllClients(updateManager => {
+ updateManager.SetDisconnect(DisconnectReason.Shutdown);
+ });
+
+ _netServer.Stop();
+
+ DeregisterCommands();
+ DeregisterPacketHandlers();
+
+ _playerData.Clear();
+ _entityData.Clear();
+ }
+
///
/// Authorizes a given authentication key.
///
diff --git a/SSMP/Game/Settings/ModSettings.cs b/SSMP/Game/Settings/ModSettings.cs
index 17e100d..f5fcba6 100644
--- a/SSMP/Game/Settings/ModSettings.cs
+++ b/SSMP/Game/Settings/ModSettings.cs
@@ -45,7 +45,7 @@ internal class ModSettings {
///
/// Whether to display a UI element for the ping.
///
- public bool DisplayPing { get; set; }
+ public bool DisplayPing { get; set; } = true;
///
/// Set of addon names for addons that are disabled by the user.
@@ -63,6 +63,12 @@ internal class ModSettings {
/// The last used server settings in a hosted server.
///
public ServerSettings? ServerSettings { get; set; }
+
+ ///
+ /// The URL of the MatchMaking Service (MMS).
+ /// Points to public MMS server for testing.
+ ///
+ public string MmsUrl { get; set; } = "http://194.219.181.154:5000";
///
/// Load the mod settings from file or create a new instance.
diff --git a/SSMP/Game/SteamManager.cs b/SSMP/Game/SteamManager.cs
index ca71918..fca2565 100644
--- a/SSMP/Game/SteamManager.cs
+++ b/SSMP/Game/SteamManager.cs
@@ -1,5 +1,6 @@
using System;
using System.Reflection;
+using System.Threading;
using Steamworks;
using SSMP.Logging;
@@ -8,17 +9,13 @@ namespace SSMP.Game;
///
/// Manages Steam API initialization and availability checks.
/// Handles graceful fallback when Steam is not available (multi-platform support).
+/// OPTIMIZED VERSION - Enhanced performance through caching, reduced allocations, and lock optimization.
///
public static class SteamManager {
///
- /// The current version of the mod.
+ /// The default maximum number of players allowed in a lobby (250 = Steam's max = unlimited).
///
- private static readonly string ModVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString();
-
- ///
- /// The default maximum number of players allowed in a lobby.
- ///
- private const int DefaultMaxPlayers = 4;
+ private const int DefaultMaxPlayers = 250;
///
/// The default lobby visibility type.
@@ -38,12 +35,12 @@ public static class SteamManager {
///
/// Whether we are currently hosting a Steam lobby.
///
- private static bool IsHostingLobby { get; set; }
+ public static bool IsHostingLobby { get; private set; }
///
/// Whether we are currently in a Steam lobby (hosting or client).
///
- public static bool IsInLobby => CurrentLobbyId != CSteamID.Nil;
+ public static bool IsInLobby => CurrentLobbyId != NilLobbyId;
///
/// Event fired when a Steam lobby is successfully created.
@@ -69,9 +66,48 @@ public static class SteamManager {
private static string? _pendingLobbyUsername;
///
- /// Lock object for thread-safe access to lobby state.
+ /// Stored lobby type for lobby creation callback.
+ /// Used to determine if Rich Presence should be set (not set for private lobbies).
+ ///
+ private static ELobbyType _pendingLobbyType;
+
+ ///
+ /// Callback timer interval in milliseconds (~60Hz).
+ ///
+ private const int CallbackIntervalMs = 17;
+
+ ///
+ /// Cached CSteamID.Nil value to avoid repeated struct creation.
///
- private static readonly object LobbyStateLock = new();
+ private static readonly CSteamID NilLobbyId = CSteamID.Nil;
+
+ ///
+ /// Cached string keys for lobby metadata to avoid allocations.
+ ///
+ private const string LobbyKeyName = "name";
+ private const string LobbyKeyVersion = "version";
+
+ ///
+ /// Cached mod version string from assembly metadata.
+ ///
+ private static readonly string ModVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0";
+
+ ///
+ /// Reusable callback instances to avoid GC allocations.
+ ///
+ private static CallResult? _lobbyCreatedCallback;
+ private static CallResult? _lobbyMatchListCallback;
+ private static CallResult? _lobbyEnterCallback;
+
+ ///
+ /// Thread-safe timer using Threading.Timer instead of System.Timers.Timer for better performance.
+ ///
+ private static Timer? _callbackTimer;
+
+ ///
+ /// Cancellation flag for callback timer (int for atomic operations).
+ ///
+ private static int _isRunningCallbacks;
///
/// Initializes the Steam API if available.
@@ -101,6 +137,14 @@ public static bool Initialize() {
Callback.Create(OnGameLobbyJoinRequested);
Callback.Create(OnGameRichPresenceJoinRequested);
+ // Pre-allocate callback instances to reuse
+ _lobbyCreatedCallback = CallResult.Create(OnLobbyCreated);
+ _lobbyMatchListCallback = CallResult.Create(OnLobbyMatchList);
+ _lobbyEnterCallback = CallResult.Create(OnLobbyEnter);
+
+ // Start a timer-based callback loop that runs independently of frame rate
+ StartCallbackTimer();
+
return true;
} catch (Exception e) {
Logger.Error($"Steam: Exception during initialization: {e}");
@@ -112,9 +156,8 @@ public static bool Initialize() {
/// Creates a Steam lobby for multiplayer.
///
/// Host's username to set as lobby name
- /// Maximum number of players (default 4)
+ /// Maximum number of players (default 250 = unlimited)
/// Type of lobby to create (default friends-only)
- /// True if lobby creation was initiated, false if Steam is unavailable
public static void CreateLobby(
string username,
int maxPlayers = DefaultMaxPlayers,
@@ -130,71 +173,90 @@ public static void CreateLobby(
LeaveLobby();
}
- lock (LobbyStateLock) {
- _pendingLobbyUsername = username;
- }
- Logger.Info($"Creating Steam lobby for {maxPlayers} players...");
+ // Use Interlocked for atomic write (faster than lock for simple assignments)
+ Volatile.Write(ref _pendingLobbyUsername, username);
+ _pendingLobbyType = lobbyType;
+
+ Logger.Info($"Creating Steam lobby for {maxPlayers} players (type: {lobbyType})...");
- // Create lobby and register callback
+ // Create lobby and register callback (reuse pre-allocated callback)
var apiCall = SteamMatchmaking.CreateLobby(lobbyType, maxPlayers);
- var lobbyCreatedCallback = CallResult.Create(OnLobbyCreated);
- lobbyCreatedCallback.Set(apiCall);
+ _lobbyCreatedCallback?.Set(apiCall);
}
///
/// Requests a list of lobbies from Steam.
///
- /// True if the request was sent, false otherwise.
public static void RequestLobbyList() {
if (!IsInitialized) return;
Logger.Info("Requesting Steam lobby list...");
- // Add filters if needed (e.g. only lobbies with specific data)
+ // Add filters to only show lobbies with matching game version
SteamMatchmaking.AddRequestLobbyListStringFilter(
- "version",
+ LobbyKeyVersion,
ModVersion,
ELobbyComparison.k_ELobbyComparisonEqual
);
var apiCall = SteamMatchmaking.RequestLobbyList();
- var lobbyMatchListCallback = CallResult.Create(OnLobbyMatchList);
- lobbyMatchListCallback.Set(apiCall);
+ _lobbyMatchListCallback?.Set(apiCall);
}
///
/// Joins a Steam lobby.
///
/// The ID of the lobby to join.
- /// True if the join request was sent, false otherwise.
public static void JoinLobby(CSteamID lobbyId) {
if (!IsInitialized) return;
Logger.Info($"Joining Steam lobby: {lobbyId}");
var apiCall = SteamMatchmaking.JoinLobby(lobbyId);
- var lobbyEnterCallback = CallResult.Create(OnLobbyEnter);
- lobbyEnterCallback.Set(apiCall);
+ _lobbyEnterCallback?.Set(apiCall);
}
///
/// Leaves the current lobby if hosting one.
+ /// Optimized: Reduced allocation and streamlined logic.
///
public static void LeaveLobby() {
- CSteamID lobbyToLeave;
-
- lock (LobbyStateLock) {
- if (CurrentLobbyId == CSteamID.Nil || !IsInitialized) return;
-
- lobbyToLeave = CurrentLobbyId;
- IsHostingLobby = false;
- CurrentLobbyId = CSteamID.Nil;
- }
+ if (!IsInitialized) return;
+
+ // Fast path check - direct comparison is faster than property access
+ if (CurrentLobbyId == NilLobbyId) return;
+
+ // Take local copy and clear state
+ var lobbyToLeave = CurrentLobbyId;
+ CurrentLobbyId = NilLobbyId;
+ IsHostingLobby = false;
+
+ // Clear Rich Presence so friends no longer see "Join Game" option
+ SteamFriends.ClearRichPresence();
Logger.Info($"Leaving Steam lobby: {lobbyToLeave}");
SteamMatchmaking.LeaveLobby(lobbyToLeave);
}
+ ///
+ /// Opens the Steam overlay invite dialog to invite friends to the current lobby.
+ /// Works for all lobby types (Public, Friends Only, Private).
+ ///
+ public static void OpenInviteDialog() {
+ if (!IsInitialized) {
+ Logger.Warn("Cannot open invite dialog: Steam is not initialized");
+ return;
+ }
+
+ if (CurrentLobbyId == NilLobbyId) {
+ Logger.Warn("Cannot open invite dialog: Not in a lobby");
+ return;
+ }
+
+ Logger.Info($"Opening Steam invite dialog for lobby: {CurrentLobbyId}");
+ SteamFriends.ActivateGameOverlayInviteDialog(CurrentLobbyId);
+ }
+
///
/// Shuts down the Steam API.
/// Should be called on application exit if Steam was initialized.
@@ -206,6 +268,9 @@ public static void Shutdown() {
// Leave any active lobby
LeaveLobby();
+ // Stop the callback timer
+ StopCallbackTimer();
+
SteamAPI.Shutdown();
IsInitialized = false;
Logger.Info("Steam: Shut down successfully");
@@ -215,16 +280,51 @@ public static void Shutdown() {
}
///
- /// Runs Steam callbacks. Should be called regularly (e.g. in Update loop).
- /// No-op if Steam is not initialized.
+ /// Starts the timer-based callback loop.
+ /// Optimized: Uses Threading.Timer for lower overhead and better performance.
///
- public static void RunCallbacks() {
+ private static void StartCallbackTimer() {
+ if (_callbackTimer != null) return;
+
+ _callbackTimer = new Timer(
+ OnCallbackTimerElapsed,
+ null,
+ CallbackIntervalMs,
+ CallbackIntervalMs
+ );
+
+ Logger.Info($"Steam: Started callback timer at {1000 / CallbackIntervalMs:F0}Hz");
+ }
+
+ ///
+ /// Stops the timer-based callback loop.
+ ///
+ private static void StopCallbackTimer() {
+ var timer = Interlocked.Exchange(ref _callbackTimer, null);
+ if (timer == null) return;
+
+ timer.Dispose();
+ Logger.Info("Steam: Stopped callback timer");
+ }
+
+ ///
+ /// Timer callback that runs Steam API callbacks.
+ /// Optimized: Uses atomic flag to prevent concurrent execution.
+ ///
+ private static void OnCallbackTimerElapsed(object? state) {
if (!IsInitialized) return;
+ // Prevent concurrent callback execution (lock-free)
+ if (Interlocked.CompareExchange(ref _isRunningCallbacks, 1, 0) != 0) {
+ return; // Already running, skip this tick
+ }
+
try {
SteamAPI.RunCallbacks();
- } catch (Exception e) {
- Logger.Error($"Steam: Exception in RunCallbacks:\n{e}");
+ } catch (Exception ex) {
+ Logger.Error($"Steam: Exception in timer RunCallbacks:\n{ex}");
+ } finally {
+ Volatile.Write(ref _isRunningCallbacks, 0);
}
}
@@ -237,37 +337,51 @@ public static void RunCallbacks() {
///
/// Callback invoked when a Steam lobby is created.
+ /// Optimized: Reduced allocations and lock contention.
///
private static void OnLobbyCreated(LobbyCreated_t callback, bool ioFailure) {
if (ioFailure || callback.m_eResult != EResult.k_EResultOK) {
Logger.Error($"Failed to create Steam lobby: {callback.m_eResult}");
- _pendingLobbyUsername = null;
+ Volatile.Write(ref _pendingLobbyUsername, null);
return;
}
- lock (LobbyStateLock) {
- CurrentLobbyId = new CSteamID(callback.m_ulSteamIDLobby);
- IsHostingLobby = true;
- }
+ var lobbyId = new CSteamID(callback.m_ulSteamIDLobby);
+ CurrentLobbyId = lobbyId;
+ IsHostingLobby = true;
+
+ Logger.Info($"Steam lobby created successfully: {lobbyId}");
- Logger.Info($"Steam lobby created successfully: {CurrentLobbyId}");
+ // Get username atomically
+ var username = Volatile.Read(ref _pendingLobbyUsername);
- // Set lobby metadata
- if (_pendingLobbyUsername != null) {
- SteamMatchmaking.SetLobbyData(CurrentLobbyId, "name", $"{_pendingLobbyUsername}'s Lobby");
+ // Set lobby metadata using Steam persona name and game version
+ 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");
}
- SteamMatchmaking.SetLobbyData(CurrentLobbyId, "version", ModVersion);
// Fire event for listeners
- LobbyCreatedEvent?.Invoke(CurrentLobbyId, _pendingLobbyUsername ?? "Unknown");
+ LobbyCreatedEvent?.Invoke(lobbyId, username ?? "Unknown");
- lock (LobbyStateLock) {
- _pendingLobbyUsername = null;
- }
+ Volatile.Write(ref _pendingLobbyUsername, null);
}
///
/// Callback invoked when a list of lobbies is received.
+ /// Optimized: Pre-allocated array size, single loop.
///
private static void OnLobbyMatchList(LobbyMatchList_t callback, bool ioFailure) {
if (ioFailure) {
@@ -275,10 +389,11 @@ private static void OnLobbyMatchList(LobbyMatchList_t callback, bool ioFailure)
return;
}
- Logger.Info($"Received {callback.m_nLobbiesMatching} lobbies");
+ var count = (int)callback.m_nLobbiesMatching;
+ Logger.Info($"Received {count} lobbies");
- var lobbyIds = new CSteamID[callback.m_nLobbiesMatching];
- for (var i = 0; i < callback.m_nLobbiesMatching; i++) {
+ var lobbyIds = new CSteamID[count];
+ for (var i = 0; i < count; i++) {
lobbyIds[i] = SteamMatchmaking.GetLobbyByIndex(i);
}
@@ -287,6 +402,7 @@ private static void OnLobbyMatchList(LobbyMatchList_t callback, bool ioFailure)
///
/// Callback invoked when a lobby is entered.
+ /// Optimized: Reduced lock scope.
///
private static void OnLobbyEnter(LobbyEnter_t callback, bool ioFailure) {
if (ioFailure) {
@@ -294,18 +410,17 @@ private static void OnLobbyEnter(LobbyEnter_t callback, bool ioFailure) {
return;
}
- if (callback.m_EChatRoomEnterResponse != (uint) EChatRoomEnterResponse.k_EChatRoomEnterResponseSuccess) {
- Logger.Error($"Failed to join lobby: {(EChatRoomEnterResponse) callback.m_EChatRoomEnterResponse}");
+ if (callback.m_EChatRoomEnterResponse != (uint)EChatRoomEnterResponse.k_EChatRoomEnterResponseSuccess) {
+ Logger.Error($"Failed to join lobby: {(EChatRoomEnterResponse)callback.m_EChatRoomEnterResponse}");
return;
}
- lock (LobbyStateLock) {
- CurrentLobbyId = new CSteamID(callback.m_ulSteamIDLobby);
- IsHostingLobby = false; // We are a client
- }
+ var lobbyId = new CSteamID(callback.m_ulSteamIDLobby);
+ CurrentLobbyId = lobbyId;
+ IsHostingLobby = false; // We are a client
- Logger.Info($"Joined lobby successfully: {CurrentLobbyId}");
- LobbyJoinedEvent?.Invoke(CurrentLobbyId);
+ Logger.Info($"Joined lobby successfully: {lobbyId}");
+ LobbyJoinedEvent?.Invoke(lobbyId);
}
///
@@ -318,6 +433,7 @@ private static void OnGameLobbyJoinRequested(GameLobbyJoinRequested_t callback)
///
/// Callback for when the user joins a friend's game via Steam Friends list.
+ /// Optimized: Direct ulong parsing without intermediate variable.
///
private static void OnGameRichPresenceJoinRequested(GameRichPresenceJoinRequested_t callback) {
Logger.Info($"Joining friend's game via Rich Presence: {callback.m_rgchConnect}");
diff --git a/SSMP/Networking/Client/ClientTlsClient.cs b/SSMP/Networking/Client/ClientTlsClient.cs
index ac35846..16de796 100644
--- a/SSMP/Networking/Client/ClientTlsClient.cs
+++ b/SSMP/Networking/Client/ClientTlsClient.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Text;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Security;
@@ -74,12 +75,11 @@ public void NotifyServerCertificate(TlsServerCertificate serverCertificate) {
if (serverCertificate.Certificate == null || serverCertificate.Certificate.IsEmpty) {
throw new TlsFatalAlert(AlertDescription.bad_certificate);
}
-
+
var chain = serverCertificate.Certificate.GetCertificateList();
Logger.Info("Server certificate fingerprint(s):");
- for (var i = 0; i < chain.Length; i++) {
- var entry = X509CertificateStructure.GetInstance(chain[i].GetEncoded());
+ foreach (var entry in chain.Select(t => X509CertificateStructure.GetInstance(t.GetEncoded()))) {
Logger.Info($" fingerprint:SHA256 {Fingerprint(entry)} ({entry.Subject})");
}
}
@@ -112,6 +112,7 @@ private static string Fingerprint(X509CertificateStructure c) {
fp.Append(':');
fp.Append(hex.Substring(i, 2));
}
+
return fp.ToString();
}
}
diff --git a/SSMP/Networking/Client/ClientUpdateManager.cs b/SSMP/Networking/Client/ClientUpdateManager.cs
index 2351fc8..8d2c878 100644
--- a/SSMP/Networking/Client/ClientUpdateManager.cs
+++ b/SSMP/Networking/Client/ClientUpdateManager.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using SSMP.Animation;
using SSMP.Game;
using SSMP.Game.Client.Entity;
@@ -201,11 +202,9 @@ private T FindOrCreateEntityUpdate(ushort entityId, ServerUpdatePacketId pack
// Search for existing entity update
var dataInstances = entityUpdateCollection.DataInstances;
- foreach (var t in dataInstances) {
- var existingUpdate = (T) t;
- if (existingUpdate.Id == entityId) {
- return existingUpdate;
- }
+ foreach (var existingUpdate in
+ dataInstances.Cast().Where(existingUpdate => existingUpdate!.Id == entityId)) {
+ return existingUpdate!;
}
// Create new entity update
diff --git a/SSMP/Networking/Client/DtlsClient.cs b/SSMP/Networking/Client/DtlsClient.cs
index 9bec127..5013177 100644
--- a/SSMP/Networking/Client/DtlsClient.cs
+++ b/SSMP/Networking/Client/DtlsClient.cs
@@ -69,15 +69,17 @@ internal class DtlsClient {
///
/// The address of the server.
/// The port of the server.
+ /// Optional pre-bound socket for hole punch scenarios.
/// Thrown when the underlying socket fails to connect to the server.
/// Thrown when the DTLS protocol fails to connect to the server.
- public void Connect(string address, int port) {
+ public void Connect(string address, int port, Socket? boundSocket = null) {
// Clean up any existing connection first
if (_receiveTaskTokenSource != null) {
InternalDisconnect();
}
- _socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ // Use provided socket or create new one
+ _socket = boundSocket ?? new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
// Prevent UDP WSAECONNRESET (10054) from surfacing as exceptions on Windows when the remote endpoint closes
try {
@@ -87,11 +89,14 @@ public void Connect(string address, int port) {
Logger.Debug($"IOControl SioUDPConnReset not supported: {e.Message}");
}
- try {
- _socket.Connect(address, port);
- } catch (SocketException e) {
- Logger.Error($"Failed to connect socket to {address}:{port}");
- CleanupAndThrow(e);
+ // Only connect if we created the socket (hole punch sockets are already "connected")
+ if (boundSocket == null) {
+ try {
+ _socket.Connect(address, port);
+ } catch (SocketException e) {
+ Logger.Error($"Failed to connect socket to {address}:{port}");
+ CleanupAndThrow(e);
+ }
}
var clientProtocol = new DtlsClientProtocol();
@@ -122,9 +127,10 @@ public void Connect(string address, int port) {
_handshakeThread.Start();
// Wait for handshake to complete or timeout
- if (!_handshakeThread.Join(DtlsHandshakeTimeoutMillis)) {
+ // Increase timeout to 20s for hole punching
+ if (!_handshakeThread.Join(20000)) {
// Handshake timed out - close socket to force handshake thread to abort
- Logger.Error($"DTLS handshake timed out after {DtlsHandshakeTimeoutMillis}ms");
+ Logger.Error($"DTLS handshake timed out after 20000ms");
_socket?.Close();
// Give handshake thread a brief moment to exit after socket closure
@@ -135,7 +141,7 @@ public void Connect(string address, int port) {
// Handshake completed - check if it succeeded or threw an exception
if (handshakeException != null) {
- Logger.Error($"DTLS handshake failed with exception");
+ Logger.Error($"DTLS handshake failed with exception: {handshakeException}");
CleanupAndThrow(handshakeException is IOException ? handshakeException : new IOException("DTLS handshake failed", handshakeException));
}
diff --git a/SSMP/Networking/Client/NetClient.cs b/SSMP/Networking/Client/NetClient.cs
index 4572508..5dde003 100644
--- a/SSMP/Networking/Client/NetClient.cs
+++ b/SSMP/Networking/Client/NetClient.cs
@@ -147,6 +147,7 @@ IEncryptedTransport transport
_transport.Connect(address, port);
UpdateManager.Transport = _transport;
+ UpdateManager.Reset();
UpdateManager.StartUpdates();
_chunkSender.Start();
@@ -259,6 +260,7 @@ private void OnReceiveData(byte[] buffer, int length) {
// UpdateManager will skip UDP-specific logic for Steam transports
UpdateManager.OnReceivePacket(clientUpdatePacket);
+
// First check for slice or slice ack data and handle it separately by passing it onto either the chunk
// sender or chunk receiver
var packetData = clientUpdatePacket.GetPacketData();
diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs
new file mode 100644
index 0000000..0e51c3c
--- /dev/null
+++ b/SSMP/Networking/Matchmaking/MmsClient.cs
@@ -0,0 +1,624 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+
+using System.Net.Sockets;
+using System.Net;
+
+namespace SSMP.Networking.Matchmaking;
+
+///
+/// High-performance client for the MatchMaking Service (MMS) API.
+/// Handles lobby creation, lookup, heartbeat, and NAT hole-punching coordination.
+///
+internal class MmsClient {
+ ///
+ /// Base URL of the MMS server (e.g., "http://localhost:5000")
+ ///
+ private readonly string _baseUrl;
+
+ ///
+ /// Authentication token for host operations (heartbeat, close, pending clients).
+ /// Set when a lobby is created, cleared when closed.
+ ///
+ private string? _hostToken;
+
+ ///
+ /// The currently active lobby ID, if this client is hosting a lobby.
+ ///
+ private string? CurrentLobbyId { get; set; }
+
+ ///
+ /// Timer that sends periodic heartbeats to keep the lobby alive on the MMS.
+ /// Fires every 30 seconds while a lobby is active.
+ ///
+ private Timer? _heartbeatTimer;
+
+ ///
+ /// Interval between heartbeat requests (30 seconds).
+ /// Keeps the lobby registered and prevents timeout on the MMS.
+ ///
+ private const int HeartbeatIntervalMs = 30000;
+
+ ///
+ /// HTTP request timeout in milliseconds (5 seconds).
+ /// Prevents hanging on unresponsive server.
+ ///
+ private const int HttpTimeoutMs = 5000;
+
+ ///
+ /// WebSocket connection for receiving push notifications from MMS.
+ ///
+ private ClientWebSocket? _hostWebSocket;
+
+ ///
+ /// Cancellation token source for WebSocket connection.
+ ///
+ private CancellationTokenSource? _webSocketCts;
+
+ ///
+ /// Reusable empty JSON object bytes for heartbeat requests.
+ /// Eliminates allocations since heartbeats send no data.
+ ///
+ private static readonly byte[] EmptyJsonBytes = "{}"u8.ToArray();
+
+ ///
+ /// Shared character array pool for zero-allocation JSON string building.
+ /// Reuses buffers across all JSON formatting operations.
+ ///
+ private static readonly ArrayPool CharPool = ArrayPool.Shared;
+
+ ///
+ /// Shared HttpClient instance for connection pooling and reuse across all MmsClient instances.
+ /// This provides 3-5x performance improvement over creating new connections per request.
+ /// Configured for optimal performance with disabled cookies, proxies, and redirects.
+ ///
+ private static readonly HttpClient HttpClient = CreateHttpClient();
+
+ ///
+ /// Creates and configures the shared HttpClient with optimal performance settings.
+ ///
+ /// Configured HttpClient instance for MMS API calls
+ private static HttpClient CreateHttpClient() {
+ // Configure handler for maximum performance
+ var handler = new HttpClientHandler {
+ // Skip proxy detection for faster connections
+ UseProxy = false,
+ // MMS doesn't use cookies
+ UseCookies = false,
+ // MMS doesn't redirect
+ AllowAutoRedirect = false
+ };
+
+ // Configure ServicePointManager for connection pooling (works in Unity Mono)
+ ServicePointManager.DefaultConnectionLimit = 10;
+ // Disable Nagle for lower latency
+ ServicePointManager.UseNagleAlgorithm = false;
+ // Skip 100-Continue handshake
+ ServicePointManager.Expect100Continue = false;
+
+ return new HttpClient(handler) {
+ Timeout = TimeSpan.FromMilliseconds(HttpTimeoutMs)
+ };
+ }
+
+ ///
+ /// Static constructor to hook process exit and dispose the shared HttpClient.
+ /// Ensures that OS-level resources are released when the host process shuts down.
+ ///
+ static MmsClient() {
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => {
+ HttpClient.Dispose();
+ };
+ }
+
+ ///
+ /// Initializes a new instance of the MmsClient.
+ ///
+ /// Base URL of the MMS server (default: "http://localhost:5000")
+ public MmsClient(string baseUrl = "http://localhost:5000") {
+ _baseUrl = baseUrl.TrimEnd('/');
+ }
+
+
+ ///
+ /// Creates a new lobby asynchronously with configuration options.
+ /// Non-blocking - runs STUN discovery and HTTP request on background thread.
+ ///
+ /// Local port the host is listening on
+ /// Display name for the lobby
+ /// Whether to list in public browser
+ /// Game version for compatibility
+ /// Type of lobby: "steam" or "matchmaking"
+ /// Task containing the lobby ID if successful, null on failure
+ public Task CreateLobbyAsync(int hostPort, string? lobbyName = null, bool isPublic = true, string gameVersion = "unknown", string lobbyType = "matchmaking") {
+ return Task.Run(async () => {
+ try {
+ // Rent a buffer from the pool to build JSON without allocations
+ var buffer = CharPool.Rent(512);
+ try {
+ // MMS will use the connection's source IP for the host address
+ // Include local LAN IP for same-network detection
+ var localIp = GetLocalIpAddress();
+ var length = FormatCreateLobbyJsonPortOnly(
+ buffer, hostPort, lobbyName, isPublic, gameVersion, lobbyType, localIp
+ );
+ Logger.Info($"MmsClient: Creating lobby on port {hostPort}, Local IP: {localIp}");
+
+ // Build string from buffer and send POST request
+ var json = new string(buffer, 0, length);
+ var response = await PostJsonAsync($"{_baseUrl}/lobby", json);
+ if (response == null) return null;
+
+ // Parse response to extract connection data, host token, and lobby code
+ var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData");
+ var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken");
+ var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode");
+
+ if (lobbyId == null || hostToken == null || lobbyCode == null) {
+ Logger.Error($"MmsClient: Invalid response from CreateLobby: {response}");
+ return null;
+ }
+
+ // Store tokens and start heartbeat to keep lobby alive
+ _hostToken = hostToken;
+ CurrentLobbyId = lobbyId;
+
+ StartHeartbeat();
+ Logger.Info($"MmsClient: Created lobby {lobbyCode}");
+ return lobbyCode;
+ } finally {
+ // Always return buffer to pool to enable reuse
+ CharPool.Return(buffer);
+ }
+ } catch (Exception ex) {
+ Logger.Error($"MmsClient: Failed to create lobby: {ex.Message}");
+ return null;
+ }
+ });
+ }
+
+ ///
+ /// Registers a Steam lobby with MMS for discovery.
+ /// Called after creating a Steam lobby via SteamMatchmaking.CreateLobby().
+ ///
+ /// The Steam lobby ID (CSteamID as string)
+ /// Display name for the lobby
+ /// Whether to list in public browser
+ /// Game version for compatibility
+ /// Task containing the MMS lobby ID if successful, null on failure
+ public Task RegisterSteamLobbyAsync(
+ string steamLobbyId,
+ string? lobbyName = null,
+ bool isPublic = true,
+ string gameVersion = "unknown"
+ ) {
+ return Task.Run(async () => {
+ try {
+ // Build JSON with ConnectionData = Steam lobby ID
+ var json = $"{{\"ConnectionData\":\"{steamLobbyId}\",\"LobbyName\":\"{lobbyName ?? "Steam Lobby"}\",\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"steam\"}}";
+
+ var response = await PostJsonAsync($"{_baseUrl}/lobby", json);
+ if (response == null) return null;
+
+ // Parse response to extract connection data, host token, and lobby code
+ var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData");
+ var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken");
+ var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode");
+
+ if (lobbyId == null || hostToken == null || lobbyCode == null) {
+ Logger.Error($"MmsClient: Invalid response from RegisterSteamLobby: {response}");
+ return null;
+ }
+
+ // Store tokens for heartbeat
+ _hostToken = hostToken;
+ CurrentLobbyId = lobbyId;
+
+ StartHeartbeat();
+ Logger.Info($"MmsClient: Registered Steam lobby {steamLobbyId} as MMS lobby {lobbyCode}");
+ return lobbyCode;
+
+ } catch (TaskCanceledException) {
+ Logger.Warn("MmsClient: Steam lobby registration was canceled");
+ return null;
+ } catch (Exception ex) {
+ Logger.Warn($"MmsClient: Failed to register Steam lobby: {ex.Message}");
+ return null;
+ }
+ });
+ }
+
+ ///
+ /// Gets the list of public lobbies asynchronously.
+ /// Non-blocking - runs HTTP request on background thread.
+ ///
+ /// Optional: filter by "steam" or "matchmaking"
+ /// Task containing list of public lobby info, or null on failure
+ public Task?> GetPublicLobbiesAsync(string? lobbyType = null) {
+ return Task.Run(async () => {
+ try {
+ var url = $"{_baseUrl}/lobbies";
+ if (!string.IsNullOrEmpty(lobbyType)) {
+ url += $"?type={lobbyType}";
+ }
+ var response = await GetJsonAsync(url);
+ if (response == null) return null;
+
+ var result = new List();
+ var span = response.AsSpan();
+ var idx = 0;
+
+ // Parse JSON array of lobbies
+ while (idx < span.Length) {
+ var connStart = span[idx..].IndexOf("\"connectionData\":");
+ if (connStart == -1) break;
+
+ connStart += idx;
+ var connectionData = ExtractJsonValueSpan(span[connStart..], "connectionData");
+ var name = ExtractJsonValueSpan(span[connStart..], "name");
+ var type = ExtractJsonValueSpan(span[connStart..], "lobbyType");
+ var code = ExtractJsonValueSpan(span[connStart..], "lobbyCode");
+
+ if (connectionData != null && name != null) {
+ result.Add(new PublicLobbyInfo(connectionData, name, type ?? "matchmaking", code ?? ""));
+ }
+
+ idx = connStart + 1;
+ }
+
+ return result;
+ } catch (Exception ex) {
+ Logger.Error($"MmsClient: Failed to get public lobbies: {ex.Message}");
+ return null;
+ }
+ });
+ }
+
+
+ ///
+ /// Closes the currently hosted lobby and unregisters it from the MMS.
+ /// Stops heartbeat and WebSocket connection.
+ ///
+ public void CloseLobby() {
+ if (_hostToken == null) return;
+
+ // Stop all connections before closing
+ StopHeartbeat();
+ StopWebSocket();
+
+ try {
+ // Send DELETE request to remove lobby from MMS (run on background thread)
+ Task.Run(async () => await DeleteRequestAsync($"{_baseUrl}/lobby/{_hostToken}")).Wait(HttpTimeoutMs);
+ Logger.Info($"MmsClient: Closed lobby {CurrentLobbyId}");
+ } catch (Exception ex) {
+ Logger.Warn($"MmsClient: Failed to close lobby: {ex.Message}");
+ }
+
+ // Clear state
+ _hostToken = null;
+ CurrentLobbyId = null;
+ }
+
+ ///
+ /// Joins a lobby, performs NAT hole-punching, and returns host connection details.
+ ///
+ /// The ID of the lobby to join
+ /// The local port the client is listening on
+ /// Host connection details (connectionData, lobbyType) or null on failure
+ public Task<(string connectionData, string lobbyType)?> JoinLobbyAsync(string lobbyId, int clientPort) {
+ return Task.Run<(string connectionData, string lobbyType)?>(async () => {
+ try {
+ // Request join to get host connection info and queue for hole punching
+ var jsonRequest = $"{{\"ClientIp\":null,\"ClientPort\":{clientPort}}}";
+ var response = await PostJsonAsync($"{_baseUrl}/lobby/{lobbyId}/join", jsonRequest);
+
+ if (response == null) return null;
+
+ // Rent buffer for zero-allocation parsing
+ var buffer = CharPool.Rent(response.Length);
+ try {
+ // Use standard CopyTo compatible with older .NET/Unity
+ response.CopyTo(0, buffer, 0, response.Length);
+ var span = buffer.AsSpan(0, response.Length);
+
+ var connectionData = ExtractJsonValueSpan(span, "connectionData");
+ var lobbyType = ExtractJsonValueSpan(span, "lobbyType");
+
+ if (connectionData == null || lobbyType == null) {
+ Logger.Error($"MmsClient: Invalid response from JoinLobby: {response}");
+ return null;
+ }
+
+ Logger.Info($"MmsClient: Joined lobby {lobbyId}, type: {lobbyType}, connection: {connectionData}");
+ return (connectionData, lobbyType);
+ } finally {
+ CharPool.Return(buffer);
+ }
+ } catch (Exception ex) {
+ Logger.Error($"MmsClient: Failed to join lobby: {ex.Message}");
+ return null;
+ }
+ });
+ }
+
+ ///
+ /// Event raised when a pending client needs NAT hole-punching.
+ /// Subscribers should send packets to the specified endpoint to punch through NAT.
+ ///
+ public static event Action? PunchClientRequested;
+
+ ///
+ /// Starts WebSocket connection to MMS for receiving push notifications.
+ /// Should be called after creating a lobby to enable instant client notifications.
+ ///
+ public void StartPendingClientPolling() {
+ if (_hostToken == null) {
+ Logger.Error("MmsClient: Cannot start WebSocket without host token");
+ return;
+ }
+
+ // Run WebSocket connection on background thread
+ Task.Run(ConnectWebSocketAsync);
+ }
+
+ ///
+ /// Connects to MMS WebSocket and listens for pending client notifications.
+ ///
+ private async Task ConnectWebSocketAsync() {
+ StopWebSocket(); // Ensure no duplicate connections
+
+ _webSocketCts = new CancellationTokenSource();
+ _hostWebSocket = new ClientWebSocket();
+
+ try {
+ // Convert http:// to ws://
+ var wsUrl = _baseUrl.Replace("http://", "ws://").Replace("https://", "wss://");
+ var uri = new Uri($"{wsUrl}/ws/{_hostToken}");
+
+ await _hostWebSocket.ConnectAsync(uri, _webSocketCts.Token);
+ Logger.Info($"MmsClient: WebSocket connected to MMS");
+
+ // Listen for messages
+ var buffer = new byte[1024];
+ while (_hostWebSocket.State == WebSocketState.Open && !_webSocketCts.Token.IsCancellationRequested) {
+ var result = await _hostWebSocket.ReceiveAsync(buffer, _webSocketCts.Token);
+ if (result.MessageType == WebSocketMessageType.Close) break;
+
+ if (result is { MessageType: WebSocketMessageType.Text, Count: > 0 }) {
+ var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ HandleWebSocketMessage(message);
+ }
+ }
+ } catch (Exception ex) when (ex is not OperationCanceledException) {
+ Logger.Error($"MmsClient: WebSocket error: {ex.Message}");
+ } finally {
+ _hostWebSocket?.Dispose();
+ _hostWebSocket = null;
+ Logger.Info("MmsClient: WebSocket disconnected");
+ }
+ }
+
+ ///
+ /// Handles incoming WebSocket message containing pending client info.
+ ///
+ private void HandleWebSocketMessage(string message) {
+ // Parse JSON: {"clientIp":"x.x.x.x","clientPort":12345}
+ var ip = ExtractJsonValueSpan(message.AsSpan(), "clientIp");
+ var portStr = ExtractJsonValueSpan(message.AsSpan(), "clientPort");
+
+ if (ip != null && int.TryParse(portStr, out var port)) {
+ Logger.Info($"MmsClient: WebSocket received pending client {ip}:{port}");
+ PunchClientRequested?.Invoke(ip, port);
+ }
+ }
+
+ ///
+ /// Stops WebSocket connection.
+ ///
+ private void StopWebSocket() {
+ _webSocketCts?.Cancel();
+ _webSocketCts?.Dispose();
+ _webSocketCts = null;
+ _hostWebSocket?.Dispose();
+ _hostWebSocket = null;
+ }
+
+ ///
+ /// Starts the heartbeat timer to keep the lobby alive on the MMS.
+ /// Lobbies without heartbeats expire after a timeout period.
+ ///
+ private void StartHeartbeat() {
+ StopHeartbeat(); // Ensure no duplicate timers
+ _heartbeatTimer = new Timer(SendHeartbeat, null, HeartbeatIntervalMs, HeartbeatIntervalMs);
+ }
+
+ ///
+ /// Stops the heartbeat timer.
+ /// Called when lobby is closed.
+ ///
+ private void StopHeartbeat() {
+ _heartbeatTimer?.Dispose();
+ _heartbeatTimer = null;
+ }
+
+ ///
+ /// Timer callback that sends a heartbeat to the MMS.
+ /// Uses empty JSON body and reusable byte array to minimize allocations.
+ ///
+ /// Unused timer state parameter
+ private void SendHeartbeat(object? state) {
+ if (_hostToken == null) return;
+
+ try {
+ // Send empty JSON body - just need to hit the endpoint (run on background thread)
+ Task.Run(async () => await PostJsonBytesAsync($"{_baseUrl}/lobby/heartbeat/{_hostToken}", EmptyJsonBytes))
+ .Wait(HttpTimeoutMs);
+ } catch (Exception ex) {
+ Logger.Warn($"MmsClient: Heartbeat failed: {ex.Message}");
+ }
+ }
+
+ #region HTTP Helpers (Async with HttpClient)
+
+ ///
+ /// Performs an HTTP GET request and returns the response body as a string.
+ /// Uses ResponseHeadersRead for efficient streaming.
+ ///
+ /// The URL to GET
+ /// Response body as string, or null if request failed
+ private static async Task GetJsonAsync(string url) {
+ try {
+ // ResponseHeadersRead allows streaming without buffering entire response
+ var response = await HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
+ if (!response.IsSuccessStatusCode) return null;
+
+ return await response.Content.ReadAsStringAsync();
+ } catch (HttpRequestException) {
+ // Network error or invalid response
+ return null;
+ } catch (TaskCanceledException) {
+ // Timeout exceeded
+ return null;
+ }
+ }
+
+ ///
+ /// Performs an HTTP POST request with JSON content.
+ ///
+ /// The URL to POST to
+ /// JSON string to send as request body
+ /// Response body as string
+ private static async Task PostJsonAsync(string url, string json) {
+ // StringContent handles UTF-8 encoding and sets Content-Type header
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var response = await HttpClient.PostAsync(url, content);
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ ///
+ /// Performs an HTTP POST request with pre-encoded JSON bytes.
+ /// More efficient than string-based version for reusable content like heartbeats.
+ ///
+ /// The URL to POST to
+ /// JSON bytes to send as request body
+ /// Response body as string
+ private static async Task PostJsonBytesAsync(string url, byte[] jsonBytes) {
+ using var content = new ByteArrayContent(jsonBytes);
+ content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+ using var response = await HttpClient.PostAsync(url, content);
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ ///
+ /// Performs an HTTP DELETE request.
+ /// Used to close lobbies on the MMS.
+ ///
+ /// The URL to DELETE
+ private static async Task DeleteRequestAsync(string url) {
+ await HttpClient.DeleteAsync(url);
+ }
+
+ #endregion
+
+ #region Zero-Allocation JSON Helpers
+
+ ///
+ /// Formats JSON for CreateLobby request with port only.
+ /// MMS will use the HTTP connection's source IP as the host address.
+ ///
+ private static int FormatCreateLobbyJsonPortOnly(
+ Span buffer,
+ int port,
+ string? lobbyName,
+ bool isPublic,
+ string gameVersion,
+ string lobbyType,
+ string? hostLanIp
+ ) {
+ var lanIpPart = hostLanIp != null ? $",\"HostLanIp\":\"{hostLanIp}:{port}\"" : "";
+ var json =
+ $"{{\"HostPort\":{port},\"LobbyName\":\"{lobbyName ?? "Unnamed"}\",\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"{lobbyType}\"{lanIpPart}}}";
+ json.AsSpan().CopyTo(buffer);
+ return json.Length;
+ }
+
+ ///
+ /// Extracts a JSON value by key from a JSON string using zero allocations.
+ /// Supports both string values (quoted) and numeric values (unquoted).
+ ///
+ /// JSON string to search
+ /// Key to find (without quotes)
+ /// The value as a string, or null if not found
+ ///
+ /// This is a simple parser suitable for MMS responses. It assumes well-formed JSON.
+ /// Searches for "key": pattern and extracts the following value.
+ ///
+ private static string? ExtractJsonValueSpan(ReadOnlySpan json, string key) {
+ // Build search pattern: "key":
+ Span searchKey = stackalloc char[key.Length + 3];
+ searchKey[0] = '"';
+ key.AsSpan().CopyTo(searchKey[1..]);
+ searchKey[key.Length + 1] = '"';
+ searchKey[key.Length + 2] = ':';
+
+ // Find the key in JSON
+ var idx = json.IndexOf(searchKey, StringComparison.Ordinal);
+ if (idx == -1) return null;
+
+ var valueStart = idx + searchKey.Length;
+
+ // Skip any whitespace after the colon
+ while (valueStart < json.Length && char.IsWhiteSpace(json[valueStart]))
+ valueStart++;
+
+ if (valueStart >= json.Length) return null;
+
+ // Determine if value is quoted (string) or unquoted (number)
+ if (json[valueStart] == '"') {
+ // String value - find closing quote
+ var valueEnd = json[(valueStart + 1)..].IndexOf('"');
+ return valueEnd == -1 ? null : json.Slice(valueStart + 1, valueEnd).ToString();
+ } else {
+ // Numeric value - read until non-digit character
+ var valueEnd = valueStart;
+ while (valueEnd < json.Length &&
+ (char.IsDigit(json[valueEnd]) || json[valueEnd] == '.' || json[valueEnd] == '-'))
+ valueEnd++;
+ return json.Slice(valueStart, valueEnd - valueStart).ToString();
+ }
+ }
+
+ #endregion
+
+ ///
+ /// Gets the local IP address of the machine.
+ /// Uses a UDP socket to determine the routing to the internet to pick the correct interface.
+ ///
+ private static string? GetLocalIpAddress() {
+ try {
+ using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
+ socket.Connect("8.8.8.8", 65530);
+ return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString();
+ } catch {
+ return null;
+ }
+ }
+}
+
+///
+/// Public lobby information for the lobby browser.
+///
+public record PublicLobbyInfo(
+ // IP:Port for Matchmaking, Steam lobby ID for Steam
+ string ConnectionData,
+ string Name,
+ string LobbyType,
+ string LobbyCode
+);
diff --git a/SSMP/Networking/RttTracker.cs b/SSMP/Networking/RttTracker.cs
index dfb3fc4..a25fd01 100644
--- a/SSMP/Networking/RttTracker.cs
+++ b/SSMP/Networking/RttTracker.cs
@@ -101,4 +101,14 @@ private void UpdateAverageRtt(long measuredRtt) {
? measuredRtt
: AverageRtt + (measuredRtt - AverageRtt) * RttSmoothingFactor;
}
+
+ ///
+ /// Resets the RTT tracker to its initial state.
+ /// Clears all tracked packets and resets RTT statistics.
+ ///
+ public void Reset() {
+ _trackedPackets.Clear();
+ _firstAckReceived = false;
+ AverageRtt = 0;
+ }
}
diff --git a/SSMP/Networking/Server/DtlsServer.cs b/SSMP/Networking/Server/DtlsServer.cs
index c49949f..50055b1 100644
--- a/SSMP/Networking/Server/DtlsServer.cs
+++ b/SSMP/Networking/Server/DtlsServer.cs
@@ -90,6 +90,13 @@ public void Start(int port) {
_socketReceiveThread.Start();
}
+ ///
+ /// Send a raw UDP packet to the given endpoint (for hole punching).
+ ///
+ public void SendRaw(byte[] data, IPEndPoint endPoint) {
+ _socket?.SendTo(data, endPoint);
+ }
+
///
/// Stop the DTLS server by disconnecting all clients and cancelling all running threads.
///
@@ -99,20 +106,27 @@ public void Stop() {
_socket?.Close();
_socket = null;
- // Wait for the socket receive thread to exit
+ // Wait for the socket receive thread to exit (short timeout to prevent freezing)
if (_socketReceiveThread != null && _socketReceiveThread.IsAlive) {
- _socketReceiveThread.Join(TimeSpan.FromSeconds(5));
+ if (!_socketReceiveThread.Join(500)) {
+ Logger.Warn("Socket receive thread did not exit within timeout, abandoning");
+ }
}
_socketReceiveThread = null;
_tlsServer?.Cancel();
- // Disconnect all clients
+ // Disconnect all clients without waiting for threads serially
+ // We just cancel tokens and close transports. The threads are background and will die.
foreach (var kvp in _connections) {
var connInfo = kvp.Value;
lock (connInfo) {
if (connInfo.State == ConnectionState.Connected && connInfo.Client != null) {
- InternalDisconnectClient(connInfo.Client);
+ // Signal cancellation but don't join
+ connInfo.Client.ReceiveLoopTokenSource.Cancel();
+ connInfo.Client.DtlsTransport.Close();
+ connInfo.Client.DatagramTransport.Dispose();
+ connInfo.Client.ReceiveLoopTokenSource.Dispose();
} else {
connInfo.DatagramTransport.Close();
}
@@ -282,6 +296,7 @@ CancellationToken cancellationToken
}
// 2. Handle new connection attempt
+ Logger.Debug($"DtlsServer: Received packet from new endpoint {ipEndPoint} ({numReceived} bytes). Starting handshake.");
var newTransport = new ServerDatagramTransport(_socket!) {
IPEndPoint = ipEndPoint
};
diff --git a/SSMP/Networking/Server/NetServer.cs b/SSMP/Networking/Server/NetServer.cs
index 25787a3..88a24e8 100644
--- a/SSMP/Networking/Server/NetServer.cs
+++ b/SSMP/Networking/Server/NetServer.cs
@@ -101,11 +101,6 @@ PacketManager packetManager
_receivedQueue = new ConcurrentQueue();
_processingWaitHandle = new AutoResetEvent(false);
-
- _packetManager.RegisterServerConnectionPacketHandler(
- ServerConnectionPacketId.ClientInfo,
- OnClientInfoReceived
- );
}
///
@@ -123,6 +118,12 @@ public void Start(int port, IEncryptedTransportServer transportServer) {
}
Logger.Info($"Starting NetServer on port {port}");
+
+ _packetManager.RegisterServerConnectionPacketHandler(
+ ServerConnectionPacketId.ClientInfo,
+ OnClientInfoReceived
+ );
+
IsStarted = true;
_transportServer = transportServer;
@@ -381,7 +382,7 @@ public void Stop() {
_taskTokenSource?.Cancel();
// Wait for processing thread to exit gracefully (with timeout)
- if (_processingThread != null && _processingThread.IsAlive) {
+ if (_processingThread is { IsAlive: true }) {
if (!_processingThread.Join(1000)) {
Logger.Warn("Processing thread did not exit within timeout");
}
@@ -402,6 +403,9 @@ public void Stop() {
// Clear leftover data
_leftoverData = null;
+ // Deregister the client info handler to prevent leaks when restarting the server
+ _packetManager.DeregisterServerConnectionPacketHandler(ServerConnectionPacketId.ClientInfo);
+
// Clean up existing clients
foreach (var client in _clientsById.Values) {
client.Disconnect();
@@ -409,6 +413,9 @@ public void Stop() {
_clientsById.Clear();
+ // Reset client IDs so the next session starts from 0
+ NetServerClient.ResetIds();
+
// Clean up throttled clients
_throttledClients.Clear();
diff --git a/SSMP/Networking/Server/NetServerClient.cs b/SSMP/Networking/Server/NetServerClient.cs
index cd7b2c1..c40ec38 100644
--- a/SSMP/Networking/Server/NetServerClient.cs
+++ b/SSMP/Networking/Server/NetServerClient.cs
@@ -29,21 +29,22 @@ internal class NetServerClient {
/// Whether the client is registered.
///
public bool IsRegistered { get; set; }
-
+
///
/// The update manager for the client.
///
public ServerUpdateManager UpdateManager { get; }
-
+
///
/// The chunk sender instance for sending large amounts of data.
///
public ServerChunkSender ChunkSender { get; }
+
///
/// The chunk receiver instance for receiving large amounts of data.
///
public ServerChunkReceiver ChunkReceiver { get; }
-
+
///
/// The connection manager for the client.
///
@@ -64,8 +65,9 @@ public NetServerClient(IEncryptedTransportClient transportClient, PacketManager
Id = GetId();
- UpdateManager = new ServerUpdateManager();
- UpdateManager.TransportClient = transportClient;
+ UpdateManager = new ServerUpdateManager {
+ TransportClient = transportClient
+ };
ChunkSender = new ServerChunkSender(UpdateManager);
ChunkReceiver = new ServerChunkReceiver(UpdateManager);
ConnectionManager = new ServerConnectionManager(packetManager, ChunkSender, ChunkReceiver, Id);
@@ -84,6 +86,15 @@ public void Disconnect() {
ConnectionManager.StopAcceptingConnection();
}
+ ///
+ /// Resets the static ID counter and used IDs.
+ /// Should be called when the server is stopped to ensure the next server session starts with ID 0.
+ ///
+ public static void ResetIds() {
+ UsedIds.Clear();
+ _lastId = 0;
+ }
+
///
/// Get a new ID that is not in use by another client.
///
diff --git a/SSMP/Networking/Server/ServerDatagramTransport.cs b/SSMP/Networking/Server/ServerDatagramTransport.cs
index fc87bda..d6f3c94 100644
--- a/SSMP/Networking/Server/ServerDatagramTransport.cs
+++ b/SSMP/Networking/Server/ServerDatagramTransport.cs
@@ -18,7 +18,7 @@ internal class ServerDatagramTransport : UdpDatagramTransport {
///
/// The IP endpoint for the client that this datagram transport belongs to.
///
- public IPEndPoint? IPEndPoint { get; set; }
+ public IPEndPoint? IPEndPoint { get; init; }
public ServerDatagramTransport(Socket socket) {
_socket = socket;
diff --git a/SSMP/Networking/Server/ServerUpdateManager.cs b/SSMP/Networking/Server/ServerUpdateManager.cs
index 2671258..2170ece 100644
--- a/SSMP/Networking/Server/ServerUpdateManager.cs
+++ b/SSMP/Networking/Server/ServerUpdateManager.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using SSMP.Game;
using SSMP.Game.Client.Entity;
using SSMP.Game.Settings;
@@ -34,7 +35,7 @@ public override void ResendReliableData(ClientUpdatePacket lostPacket) {
/// The ID of the packet data.
/// The type of the generic client packet data.
/// An instance of the packet data in the packet.
- private T FindOrCreatePacketData(ushort id, ClientUpdatePacketId packetId) where T : GenericClientData, new() {
+ private T? FindOrCreatePacketData(ushort id, ClientUpdatePacketId packetId) where T : GenericClientData, new() {
return FindOrCreatePacketData(
packetId,
packetData => packetData.Id == id,
@@ -50,7 +51,7 @@ public override void ResendReliableData(ClientUpdatePacket lostPacket) {
/// The function to construct the packet data if it does not exist.
/// The type of the generic client packet data.
/// An instance of the packet data in the packet.
- private T FindOrCreatePacketData(
+ private T? FindOrCreatePacketData(
ClientUpdatePacketId packetId,
Func findFunc,
Func constructFunc
@@ -63,11 +64,8 @@ Func constructFunc
// Search for existing packet data
var dataInstances = packetDataCollection.DataInstances;
- foreach (var t in dataInstances) {
- var existingData = (T) t;
- if (findFunc(existingData)) {
- return existingData;
- }
+ foreach (var existingData in dataInstances.Cast().Where(existingData => findFunc(existingData!))) {
+ return existingData;
}
} else {
// Create new collection if it doesn't exist
@@ -142,7 +140,7 @@ public void SetSliceAckData(byte chunkId, ushort numSlices, bool[] acked) {
public void AddPlayerConnectData(ushort id, string username) {
lock (Lock) {
var playerConnect = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerConnect);
- playerConnect.Username = username;
+ playerConnect!.Username = username;
}
}
@@ -156,7 +154,7 @@ public void AddPlayerDisconnectData(ushort id, string username, bool timedOut =
lock (Lock) {
var playerDisconnect =
FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerDisconnect);
- playerDisconnect.Username = username;
+ playerDisconnect!.Username = username;
playerDisconnect.TimedOut = timedOut;
}
}
@@ -183,7 +181,7 @@ ushort animationClipId
lock (Lock) {
var playerEnterScene =
FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerEnterScene);
- playerEnterScene.Username = username;
+ playerEnterScene!.Username = username;
playerEnterScene.Position = position;
playerEnterScene.Scale = scale;
playerEnterScene.Team = team;
@@ -229,7 +227,7 @@ public void AddPlayerLeaveSceneData(ushort id, string sceneName) {
lock (Lock) {
var playerLeaveScene =
FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerLeaveScene);
- playerLeaveScene.SceneName = sceneName;
+ playerLeaveScene!.SceneName = sceneName;
}
}
@@ -241,7 +239,7 @@ public void AddPlayerLeaveSceneData(ushort id, string sceneName) {
public void UpdatePlayerPosition(ushort id, Vector2 position) {
lock (Lock) {
var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate);
- playerUpdate.UpdateTypes.Add(PlayerUpdateType.Position);
+ playerUpdate!.UpdateTypes.Add(PlayerUpdateType.Position);
playerUpdate.Position = position;
}
}
@@ -254,7 +252,7 @@ public void UpdatePlayerPosition(ushort id, Vector2 position) {
public void UpdatePlayerScale(ushort id, bool scale) {
lock (Lock) {
var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate);
- playerUpdate.UpdateTypes.Add(PlayerUpdateType.Scale);
+ playerUpdate!.UpdateTypes.Add(PlayerUpdateType.Scale);
playerUpdate.Scale = scale;
}
}
@@ -267,7 +265,7 @@ public void UpdatePlayerScale(ushort id, bool scale) {
public void UpdatePlayerMapPosition(ushort id, Vector2 mapPosition) {
lock (Lock) {
var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate);
- playerUpdate.UpdateTypes.Add(PlayerUpdateType.MapPosition);
+ playerUpdate!.UpdateTypes.Add(PlayerUpdateType.MapPosition);
playerUpdate.MapPosition = mapPosition;
}
}
@@ -280,7 +278,7 @@ public void UpdatePlayerMapPosition(ushort id, Vector2 mapPosition) {
public void UpdatePlayerMapIcon(ushort id, bool hasIcon) {
lock (Lock) {
var playerMapUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerMapUpdate);
- playerMapUpdate.HasIcon = hasIcon;
+ playerMapUpdate!.HasIcon = hasIcon;
}
}
@@ -294,7 +292,7 @@ public void UpdatePlayerMapIcon(ushort id, bool hasIcon) {
public void UpdatePlayerAnimation(ushort id, ushort clipId, byte frame, byte[]? effectInfo) {
lock (Lock) {
var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate);
- playerUpdate.UpdateTypes.Add(PlayerUpdateType.Animation);
+ playerUpdate!.UpdateTypes.Add(PlayerUpdateType.Animation);
playerUpdate.AnimationInfos.Add(
new AnimationInfo {
ClipId = clipId,
@@ -332,17 +330,15 @@ public void SetEntitySpawn(ushort id, EntityType spawningType, EntityType spawne
/// The type of the entity update. Either or
/// .
/// An instance of the entity update in the packet.
- private T FindOrCreateEntityUpdate(ushort entityId, ClientUpdatePacketId packetId)
+ private T? FindOrCreateEntityUpdate(ushort entityId, ClientUpdatePacketId packetId)
where T : BaseEntityUpdate, new() {
var entityUpdateCollection = GetOrCreateCollection(packetId);
// Search for existing entity update
var dataInstances = entityUpdateCollection.DataInstances;
- foreach (var t in dataInstances) {
- var existingUpdate = (T) t;
- if (existingUpdate.Id == entityId) {
- return existingUpdate;
- }
+ foreach (var existingUpdate in
+ dataInstances.Cast().Where(existingUpdate => existingUpdate!.Id == entityId)) {
+ return existingUpdate;
}
// Create new entity update
@@ -359,7 +355,7 @@ private T FindOrCreateEntityUpdate(ushort entityId, ClientUpdatePacketId pack
public void UpdateEntityPosition(ushort entityId, Vector2 position) {
lock (Lock) {
var entityUpdate = FindOrCreateEntityUpdate(entityId, ClientUpdatePacketId.EntityUpdate);
- entityUpdate.UpdateTypes.Add(EntityUpdateType.Position);
+ entityUpdate!.UpdateTypes.Add(EntityUpdateType.Position);
entityUpdate.Position = position;
}
}
@@ -372,7 +368,7 @@ public void UpdateEntityPosition(ushort entityId, Vector2 position) {
public void UpdateEntityScale(ushort entityId, EntityUpdate.ScaleData scale) {
lock (Lock) {
var entityUpdate = FindOrCreateEntityUpdate(entityId, ClientUpdatePacketId.EntityUpdate);
- entityUpdate.UpdateTypes.Add(EntityUpdateType.Scale);
+ entityUpdate!.UpdateTypes.Add(EntityUpdateType.Scale);
entityUpdate.Scale = scale;
}
}
@@ -386,7 +382,7 @@ public void UpdateEntityScale(ushort entityId, EntityUpdate.ScaleData scale) {
public void UpdateEntityAnimation(ushort entityId, byte animationId, byte animationWrapMode) {
lock (Lock) {
var entityUpdate = FindOrCreateEntityUpdate(entityId, ClientUpdatePacketId.EntityUpdate);
- entityUpdate.UpdateTypes.Add(EntityUpdateType.Animation);
+ entityUpdate!.UpdateTypes.Add(EntityUpdateType.Animation);
entityUpdate.AnimationId = animationId;
entityUpdate.AnimationWrapMode = animationWrapMode;
}
@@ -401,7 +397,7 @@ public void UpdateEntityIsActive(ushort entityId, bool isActive) {
lock (Lock) {
var entityUpdate =
FindOrCreateEntityUpdate(entityId, ClientUpdatePacketId.ReliableEntityUpdate);
- entityUpdate.UpdateTypes.Add(EntityUpdateType.Active);
+ entityUpdate!.UpdateTypes.Add(EntityUpdateType.Active);
entityUpdate.IsActive = isActive;
}
}
@@ -415,7 +411,7 @@ public void AddEntityData(ushort entityId, List data) {
lock (Lock) {
var entityUpdate =
FindOrCreateEntityUpdate(entityId, ClientUpdatePacketId.ReliableEntityUpdate);
- entityUpdate.UpdateTypes.Add(EntityUpdateType.Data);
+ entityUpdate!.UpdateTypes.Add(EntityUpdateType.Data);
entityUpdate.GenericData.AddRange(data);
}
}
@@ -430,7 +426,7 @@ public void AddEntityHostFsmData(ushort entityId, byte fsmIndex, EntityHostFsmDa
lock (Lock) {
var entityUpdate =
FindOrCreateEntityUpdate(entityId, ClientUpdatePacketId.ReliableEntityUpdate);
- entityUpdate.UpdateTypes.Add(EntityUpdateType.HostFsm);
+ entityUpdate!.UpdateTypes.Add(EntityUpdateType.HostFsm);
if (entityUpdate.HostFsmData.TryGetValue(fsmIndex, out var existingData)) {
existingData.MergeData(data);
@@ -482,14 +478,15 @@ public void AddPlayerSettingUpdateData(Team? team = null, byte? skinId = null) {
);
if (team.HasValue) {
- playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Team);
+ playerSettingUpdate!.UpdateTypes.Add(PlayerSettingUpdateType.Team);
playerSettingUpdate.Team = team.Value;
}
- if (skinId.HasValue) {
- playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Skin);
+ if (!skinId.HasValue) {
+ playerSettingUpdate!.UpdateTypes.Add(PlayerSettingUpdateType.Skin);
playerSettingUpdate.SkinId = skinId.Value;
}
+
}
}
@@ -520,17 +517,17 @@ public void AddOtherPlayerSettingUpdateData(
);
if (team.HasValue) {
- playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Team);
+ playerSettingUpdate!.UpdateTypes.Add(PlayerSettingUpdateType.Team);
playerSettingUpdate.Team = team.Value;
}
if (skinId.HasValue) {
- playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Skin);
+ playerSettingUpdate!.UpdateTypes.Add(PlayerSettingUpdateType.Skin);
playerSettingUpdate.SkinId = skinId.Value;
}
if (crestType.HasValue) {
- playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Crest);
+ playerSettingUpdate!.UpdateTypes.Add(PlayerSettingUpdateType.Crest);
playerSettingUpdate.CrestType = crestType.Value;
}
}
diff --git a/SSMP/Networking/Transport/Common/TransportType.cs b/SSMP/Networking/Transport/Common/TransportType.cs
index 5342211..dc135de 100644
--- a/SSMP/Networking/Transport/Common/TransportType.cs
+++ b/SSMP/Networking/Transport/Common/TransportType.cs
@@ -12,5 +12,10 @@ public enum TransportType {
///
/// Steam P2P transport (Lobby).
///
- Steam
+ Steam,
+
+ ///
+ /// UDP Hole Punch transport (NAT traversal).
+ ///
+ HolePunch
}
diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs
new file mode 100644
index 0000000..5567779
--- /dev/null
+++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using SSMP.Logging;
+using SSMP.Networking.Client;
+using SSMP.Networking.Transport.Common;
+using SSMP.Ui;
+
+namespace SSMP.Networking.Transport.HolePunch;
+
+///
+/// UDP Hole Punch implementation of .
+/// Performs NAT traversal before establishing DTLS connection for secure peer-to-peer networking.
+///
+///
+///
+/// This transport layer combines NAT hole-punching with DTLS encryption to enable
+/// secure peer-to-peer connections between clients behind NAT/firewalls.
+///
+///
+/// NAT Hole Punching Process:
+/// 1. Client creates a socket and registers with MMS (which sees public endpoint)
+/// 2. Client sends "punch" packets to peer's public endpoint
+/// 3. These packets open a hole in the local NAT mapping
+/// 4. Peer's packets can now reach through the opened NAT hole
+/// 5. DTLS handshake proceeds over the established UDP path
+///
+///
+/// The transport handles both:
+/// - Remote connections: Full hole-punching and DTLS
+/// - Local connections: Direct DTLS without hole-punching
+///
+///
+internal class HolePunchEncryptedTransport : IEncryptedTransport {
+ ///
+ /// Maximum UDP packet size to avoid IP fragmentation.
+ /// Set to 1200 bytes to safely fit within MTU after IP/UDP/DTLS headers.
+ ///
+ private const int UdpMaxPacketSize = 1200;
+
+ ///
+ /// Number of punch packets to send during NAT traversal.
+ /// Set to 100 packets (5 seconds total) to cover MMS polling latency.
+ ///
+ ///
+ /// High count ensures:
+ /// - NAT mapping stays open long enough for peer to respond
+ /// - Covers the time for MMS to notify host of pending client
+ /// - Compensates for packet loss during hole-punching
+ ///
+ private const int PunchPacketCount = 100;
+
+ ///
+ /// Delay between consecutive punch packets in milliseconds.
+ /// 50ms provides good balance between NAT mapping refresh and network overhead.
+ ///
+ private const int PunchPacketDelayMs = 50;
+
+ ///
+ /// The IP address used for self-connecting (host connecting to own server).
+ /// Localhost connections bypass hole-punching as no NAT traversal is needed.
+ ///
+ private const string LocalhostAddress = "127.0.0.1";
+
+ ///
+ /// Pre-allocated punch packet bytes containing "PUNCH" in UTF-8.
+ /// Reused across all punch operations to avoid allocations.
+ ///
+ ///
+ /// The actual content doesn't matter - we just need to send packets
+ /// to establish the NAT mapping. "PUNCH" is used for debugging clarity.
+ ///
+ private static readonly byte[] PunchPacket = "PUNCH"u8.ToArray();
+
+ ///
+ /// The underlying DTLS client that provides encrypted communication.
+ /// Handles encryption, decryption, and secure handshaking.
+ ///
+ private readonly DtlsClient _dtlsClient;
+
+ ///
+ /// Event raised when encrypted data is received from the peer.
+ /// Data has already been decrypted by the DTLS layer.
+ ///
+ public event Action? DataReceivedEvent;
+
+ ///
+ /// Indicates whether this transport requires congestion management.
+ /// UDP provides no congestion control, so higher layers must implement it.
+ ///
+ public bool RequiresCongestionManagement => true;
+
+ ///
+ /// Indicates whether this transport requires reliability mechanisms.
+ /// UDP is unreliable, so higher layers must implement retransmission.
+ ///
+ public bool RequiresReliability => true;
+
+ ///
+ /// Indicates whether this transport requires sequencing mechanisms.
+ /// UDP doesn't guarantee ordering, so higher layers must sequence packets.
+ ///
+ public bool RequiresSequencing => true;
+
+ ///
+ /// Gets the maximum packet size that can be safely transmitted.
+ /// Limited by MTU considerations to avoid fragmentation.
+ ///
+ public int MaxPacketSize => UdpMaxPacketSize;
+
+ ///
+ /// Initializes a new instance of the class.
+ /// Sets up the DTLS client and subscribes to its data events.
+ ///
+ public HolePunchEncryptedTransport() {
+ _dtlsClient = new DtlsClient();
+
+ // Forward decrypted data from DTLS to our event subscribers
+ _dtlsClient.DataReceivedEvent += OnDataReceived;
+ }
+
+ ///
+ /// Connects to the specified remote endpoint with NAT traversal.
+ /// Performs hole-punching for remote connections, direct connection for localhost.
+ ///
+ /// The IP address to connect to
+ /// The port to connect to
+ ///
+ /// Connection process:
+ /// - Localhost: Direct DTLS connection (no hole-punching needed)
+ /// - Remote: Hole-punch first, then DTLS connection over punched socket
+ ///
+ public void Connect(string address, int port) {
+ // Detect self-connect or LAN scenario
+ if (address == LocalhostAddress || IsPrivateIp(address)) {
+ Logger.Debug($"HolePunch: Local/LAN connection detected ({address}), using direct DTLS");
+
+ // We don't need the pre-bound socket for LAN, so clean it up
+ if (ConnectInterface.HolePunchSocket != null) {
+ ConnectInterface.HolePunchSocket.Close();
+ ConnectInterface.HolePunchSocket = null;
+ }
+
+ // No hole-punching needed for localhost/LAN
+ _dtlsClient.Connect(address, port);
+ return;
+ }
+
+ // Remote connection requires NAT traversal
+ Logger.Info($"HolePunch: Starting NAT traversal to {address}:{port}");
+ var socket = PerformHolePunch(address, port);
+
+ // Establish DTLS connection using the hole-punched socket
+ _dtlsClient.Connect(address, port, socket);
+ }
+
+ ///
+ /// Checks if an IP address is a private (LAN) address.
+ ///
+ private static bool IsPrivateIp(string ipAddress) {
+ if (!IPAddress.TryParse(ipAddress, out var ip)) return false;
+
+ var bytes = ip.GetAddressBytes();
+
+ return bytes[0] switch {
+ 10 => true, // 10.0.0.0/8
+ 172 => bytes[1] >= 16 && bytes[1] <= 31, // 172.16.0.0/12
+ 192 => bytes[1] == 168, // 192.168.0.0/16
+ _ => false
+ };
+ }
+
+ ///
+ /// Sends encrypted data to the connected peer.
+ /// Data is encrypted by the DTLS layer before transmission.
+ ///
+ /// Buffer containing data to send
+ /// Offset in buffer where data begins
+ /// Number of bytes to send
+ /// Thrown if not connected
+ public void Send(byte[] buffer, int offset, int length) {
+ // Ensure DTLS connection is established
+ if (_dtlsClient.DtlsTransport == null) {
+ throw new InvalidOperationException("Not connected");
+ }
+
+ // Delegate to DTLS transport for encryption and transmission
+ _dtlsClient.DtlsTransport.Send(buffer, offset, length);
+ }
+
+ ///
+ /// Disconnects from the peer and cleans up resources.
+ /// Closes the DTLS session and underlying socket.
+ ///
+ public void Disconnect() {
+ _dtlsClient.Disconnect();
+ }
+
+ ///
+ /// Performs UDP hole punching to the specified endpoint.
+ /// Opens NAT mapping by sending packets, then returns connected socket for DTLS.
+ ///
+ /// Target IP address
+ /// Target port
+ /// Connected UDP socket ready for DTLS communication
+ /// Thrown if hole punching fails
+ ///
+ /// Hole-punching sequence:
+ /// 1. Reuse pre-bound socket from STUN discovery (or create new one)
+ /// 2. Configure socket to ignore ICMP Port Unreachable errors
+ /// 3. Send 100 "PUNCH" packets over 5 seconds to open NAT mapping
+ /// 4. Connect socket to peer endpoint
+ /// 5. Return socket for DTLS handshake
+ ///
+ private static Socket PerformHolePunch(string address, int port) {
+ // Attempt to reuse the socket from ConnectInterface
+ // This is important because the NAT mapping was created with this socket
+ var socket = ConnectInterface.HolePunchSocket;
+ ConnectInterface.HolePunchSocket = null;
+
+ if (socket == null) {
+ socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ socket.Bind(new IPEndPoint(IPAddress.Any, 0));
+ Logger.Warn("HolePunch: No pre-bound socket, creating new one (NAT traversal may fail)");
+ }
+
+ // Suppress ICMP Port Unreachable errors (SIO_UDP_CONNRESET)
+ // When we send to a port that's not open yet, we get ICMP errors
+ // These would normally cause SocketException, but we want to ignore them
+ try {
+ const int sioUdpConnReset = -1744830452;
+ socket.IOControl(sioUdpConnReset, [0], null);
+ } catch {
+ // Some platforms don't support this option, continue anyway
+ Logger.Warn("HolePunch: Failed to set SioUdpConnReset (ignored platform?)");
+ }
+
+ try {
+ // Parse target endpoint
+ var endpoint = new IPEndPoint(IPAddress.Parse(address), port);
+
+ Logger.Debug($"HolePunch: Sending {PunchPacketCount} punch packets to {endpoint}");
+
+ // Send punch packets to create/maintain NAT mapping
+ // Each packet refreshes the NAT timer and increases chance of success
+ for (var i = 0; i < PunchPacketCount; i++) {
+ socket.SendTo(PunchPacket, endpoint);
+
+ // Wait between packets to spread them over time
+ Thread.Sleep(PunchPacketDelayMs);
+ }
+
+ // "Connect" the socket to filter incoming packets to only this peer
+ // This is important for DTLS which expects point-to-point communication
+ socket.Connect(endpoint);
+
+ Logger.Info($"HolePunch: NAT traversal complete, socket connected to {endpoint}");
+ return socket;
+ } catch (Exception ex) {
+ // Clean up socket on failure
+ socket.Dispose();
+ throw new InvalidOperationException($"Hole punch failed: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Handles data received from the DTLS client.
+ /// Forwards decrypted data to subscribers of .
+ ///
+ /// Buffer containing received data
+ /// Number of valid bytes in buffer
+ private void OnDataReceived(byte[] data, int length) {
+ // Forward to subscribers
+ DataReceivedEvent?.Invoke(data, length);
+ }
+}
diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportClient.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportClient.cs
new file mode 100644
index 0000000..03ba4e0
--- /dev/null
+++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportClient.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Net;
+using SSMP.Networking.Server;
+using SSMP.Networking.Transport.Common;
+
+namespace SSMP.Networking.Transport.HolePunch;
+
+///
+/// UDP Hole Punch implementation of .
+/// Wraps DtlsServerClient for hole punched connections.
+///
+internal class HolePunchEncryptedTransportClient : IEncryptedTransportClient {
+ ///
+ /// The underlying DTLS server client.
+ ///
+ private readonly DtlsServerClient _dtlsServerClient;
+
+ ///
+ public string ToDisplayString() => "UDP Hole Punch";
+
+ ///
+ public string GetUniqueIdentifier() => _dtlsServerClient.EndPoint.ToString();
+
+ ///
+ /// The IP endpoint of the server client after NAT traversal.
+ ///
+ public IPEndPoint EndPoint => _dtlsServerClient.EndPoint;
+
+ ///
+ IPEndPoint IEncryptedTransportClient.EndPoint => EndPoint;
+
+ ///
+ public bool RequiresCongestionManagement => true;
+
+ ///
+ public bool RequiresReliability => true;
+
+ ///
+ public bool RequiresSequencing => true;
+
+ ///
+ public event Action? DataReceivedEvent;
+
+ public HolePunchEncryptedTransportClient(DtlsServerClient dtlsServerClient) {
+ _dtlsServerClient = dtlsServerClient;
+ }
+
+ ///
+ public void Send(byte[] buffer, int offset, int length) {
+ _dtlsServerClient.DtlsTransport.Send(buffer, offset, length);
+ }
+
+ ///
+ /// Raises the with the given data.
+ ///
+ internal void RaiseDataReceived(byte[] data, int length) {
+ DataReceivedEvent?.Invoke(data, length);
+ }
+}
diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs
new file mode 100644
index 0000000..d68f7c8
--- /dev/null
+++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Concurrent;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using SSMP.Logging;
+using SSMP.Networking.Matchmaking;
+using SSMP.Networking.Server;
+using SSMP.Networking.Transport.Common;
+
+namespace SSMP.Networking.Transport.HolePunch;
+
+///
+/// UDP Hole Punch implementation of .
+/// Handles hole punch coordination for incoming client connections.
+///
+internal class HolePunchEncryptedTransportServer : IEncryptedTransportServer {
+ ///
+ /// Number of punch packets to send per client.
+ /// Increased to 100 (5s) to ensure reliability.
+ ///
+ private const int PunchPacketCount = 100;
+
+ ///
+ /// Delay between punch packets in milliseconds.
+ ///
+ private const int PunchPacketDelayMs = 50;
+
+ ///
+ /// Pre-allocated punch packet bytes ("PUNCH").
+ ///
+ private static readonly byte[] PunchPacket = "PUNCH"u8.ToArray();
+
+ ///
+ /// MMS client instance for lobby management.
+ /// Stored to enable proper cleanup when server shuts down.
+ ///
+ private MmsClient? _mmsClient;
+
+ ///
+ /// The underlying DTLS server.
+ ///
+ private readonly DtlsServer _dtlsServer;
+
+ ///
+ /// Dictionary containing the clients of this server.
+ ///
+ private readonly ConcurrentDictionary _clients;
+
+ ///
+ public event Action? ClientConnectedEvent;
+
+ public HolePunchEncryptedTransportServer(MmsClient? mmsClient = null) {
+ _mmsClient = mmsClient;
+ _dtlsServer = new DtlsServer();
+ _clients = new ConcurrentDictionary();
+ _dtlsServer.DataReceivedEvent += OnClientDataReceived;
+ }
+
+ ///
+ public void Start(int port) {
+ Logger.Info($"HolePunch Server: Starting on port {port}");
+
+ // Subscribe to punch coordination
+ MmsClient.PunchClientRequested += OnPunchClientRequested;
+
+ _dtlsServer.Start(port);
+ }
+
+ ///
+ public void Stop() {
+ Logger.Info("HolePunch Server: Stopping");
+
+ // Close MMS lobby if we have an MMS client
+ _mmsClient?.CloseLobby();
+ _mmsClient = null;
+
+ // Unsubscribe from punch coordination
+ MmsClient.PunchClientRequested -= OnPunchClientRequested;
+
+ _dtlsServer.Stop();
+ _clients.Clear();
+ }
+
+ ///
+ /// Called when MMS notifies us of a client needing punch-back.
+ ///
+ private void OnPunchClientRequested(string clientIp, int clientPort) {
+ if (!IPAddress.TryParse(clientIp, out var ip)) {
+ Logger.Warn($"HolePunch Server: Invalid client IP: {clientIp}");
+ return;
+ }
+
+ PunchToClient(new IPEndPoint(ip, clientPort));
+ }
+
+ ///
+ public void DisconnectClient(IEncryptedTransportClient client) {
+ if (client is not HolePunchEncryptedTransportClient hpClient)
+ return;
+
+ _dtlsServer.DisconnectClient(hpClient.EndPoint);
+ _clients.TryRemove(hpClient.EndPoint, out _);
+ }
+
+ ///
+ /// Initiates hole punch to a client that wants to connect.
+ /// Uses the DTLS server's socket so the punch comes from the correct port.
+ ///
+ /// The client's public endpoint.
+ private void PunchToClient(IPEndPoint clientEndpoint) {
+ // Run on background thread to avoid blocking the calling thread for 5 seconds
+ Task.Run(() => {
+ Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}");
+
+ for (var i = 0; i < PunchPacketCount; i++) {
+ _dtlsServer.SendRaw(PunchPacket, clientEndpoint);
+ Thread.Sleep(PunchPacketDelayMs);
+ }
+
+ Logger.Info($"HolePunch Server: Punch packets sent to {clientEndpoint}");
+ });
+ }
+
+ ///
+ /// 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;
+ });
+
+ client.RaiseDataReceived(data, length);
+ }
+}
diff --git a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs
index 4764c18..55a4cb0 100644
--- a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs
+++ b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs
@@ -1,4 +1,6 @@
using System;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
using System.Threading;
using SSMP.Game;
using SSMP.Logging;
@@ -10,8 +12,9 @@ namespace SSMP.Networking.Transport.SteamP2P;
///
/// Steam P2P implementation of .
/// Used by clients to connect to a server via Steam P2P networking.
+/// Optimized for maximum performance with zero-allocation hot paths.
///
-internal class SteamEncryptedTransport : IReliableTransport {
+internal sealed class SteamEncryptedTransport : IReliableTransport {
///
/// Maximum Steam P2P packet size.
///
@@ -23,6 +26,21 @@ internal class SteamEncryptedTransport : IReliableTransport {
///
private const double PollIntervalMS = 17.2;
+ ///
+ /// Maximum wait time threshold before switching from spin to sleep (ms).
+ ///
+ private const int SpinWaitThreshold = 15;
+
+ ///
+ /// Channel ID for server->client communication.
+ ///
+ private const int ServerChannel = 1;
+
+ ///
+ /// Maximum drift correction threshold in milliseconds.
+ ///
+ private const long MaxDriftThreshold = 100;
+
///
public event Action? DataReceivedEvent;
@@ -54,7 +72,7 @@ internal class SteamEncryptedTransport : IReliableTransport {
private volatile bool _isConnected;
///
- /// Buffer for receiving P2P packets.
+ /// Buffer for receiving P2P packets (aligned for better cache performance).
///
private readonly byte[] _receiveBuffer = new byte[SteamMaxPacketSize];
@@ -68,6 +86,32 @@ internal class SteamEncryptedTransport : IReliableTransport {
///
private Thread? _receiveThread;
+ ///
+ /// Cached CSteamID.Nil to avoid allocation on hot path.
+ ///
+ private static readonly CSteamID NilSteamId = CSteamID.Nil;
+
+ ///
+ /// Cached loopback channel instance to avoid GetOrCreate() overhead.
+ ///
+ private SteamLoopbackChannel? _cachedLoopbackChannel;
+
+ ///
+ /// Flag indicating if we're in loopback mode (connecting to self).
+ ///
+ private bool _isLoopback;
+
+ ///
+ /// Cached Steam initialized check to reduce property access overhead.
+ ///
+ private bool _steamInitialized;
+
+ ///
+ /// Cached send types to avoid boxing and allocation.
+ ///
+ private static readonly EP2PSend UnreliableSendType = EP2PSend.k_EP2PSendUnreliableNoDelay;
+ private static readonly EP2PSend ReliableSendType = EP2PSend.k_EP2PSendReliable;
+
///
/// Connect to remote peer via Steam P2P.
///
@@ -76,7 +120,9 @@ internal class SteamEncryptedTransport : IReliableTransport {
/// Thrown if Steam is not initialized.
/// Thrown if address is not a valid Steam ID.
public void Connect(string address, int port) {
- if (!SteamManager.IsInitialized) {
+ _steamInitialized = SteamManager.IsInitialized;
+
+ if (!_steamInitialized) {
throw new InvalidOperationException("Cannot connect via Steam P2P: Steam is not initialized");
}
@@ -86,85 +132,113 @@ public void Connect(string address, int port) {
_remoteSteamId = new CSteamID(steamId64);
_localSteamId = SteamUser.GetSteamID();
+ _isLoopback = _remoteSteamId == _localSteamId;
_isConnected = true;
Logger.Info($"Steam P2P: Connecting to {_remoteSteamId}");
SteamNetworking.AllowP2PPacketRelay(true);
- if (_remoteSteamId == _localSteamId) {
+ if (_isLoopback) {
Logger.Info("Steam P2P: Connecting to self, using loopback channel");
- SteamLoopbackChannel.GetOrCreate().RegisterClient(this);
+ _cachedLoopbackChannel = SteamLoopbackChannel.GetOrCreate();
+ _cachedLoopbackChannel.RegisterClient(this);
}
_receiveTokenSource = new CancellationTokenSource();
- _receiveThread = new Thread(ReceiveLoop) { IsBackground = true };
+ _receiveThread = new Thread(ReceiveLoop) {
+ IsBackground = true,
+ Priority = ThreadPriority.AboveNormal,
+ Name = "Steam P2P Receive"
+ };
_receiveThread.Start();
}
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Send(byte[] buffer, int offset, int length) {
- SendInternal(buffer, offset, length, EP2PSend.k_EP2PSendUnreliableNoDelay);
+ SendInternal(buffer, offset, length, UnreliableSendType);
}
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SendReliable(byte[] buffer, int offset, int length) {
- SendInternal(buffer, offset, length, EP2PSend.k_EP2PSendReliable);
+ SendInternal(buffer, offset, length, ReliableSendType);
}
///
/// Internal helper to send data with a specific P2P send type.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void SendInternal(byte[] buffer, int offset, int length, EP2PSend sendType) {
- if (!_isConnected) {
- throw new InvalidOperationException("Cannot send: not connected");
- }
-
- if (!SteamManager.IsInitialized) {
- throw new InvalidOperationException("Cannot send: Steam is not initialized");
+ // Fast-path validation (likely branches first)
+ if (!_isConnected | !_steamInitialized) {
+ ThrowNotConnected();
}
- if (_remoteSteamId == _localSteamId) {
- SteamLoopbackChannel.GetOrCreate().SendToServer(buffer, offset, length);
+ if (_isLoopback) {
+ // Use cached loopback channel (no null check needed - set during Connect)
+ _cachedLoopbackChannel!.SendToServer(buffer, offset, length);
return;
}
- if (!SteamNetworking.SendP2PPacket(_remoteSteamId, buffer, (uint) length, sendType)) {
+ // Client sends to server on Channel 0
+ if (!SteamNetworking.SendP2PPacket(_remoteSteamId, buffer, (uint)length, sendType)) {
Logger.Warn($"Steam P2P: Failed to send packet to {_remoteSteamId}");
}
}
- private void Receive(byte[]? buffer, int offset, int length) {
- if (!_isConnected || !SteamManager.IsInitialized) return;
-
- if (!SteamNetworking.IsP2PPacketAvailable(out var packetSize)) return;
-
- if (!SteamNetworking.ReadP2PPacket(
- _receiveBuffer,
- SteamMaxPacketSize,
- out packetSize,
- out var remoteSteamId
- )) {
- return;
- }
+ ///
+ /// Cold path for throwing connection exceptions (keeps hot path clean).
+ ///
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static void ThrowNotConnected() {
+ throw new InvalidOperationException("Cannot send: not connected or Steam not initialized");
+ }
- if (remoteSteamId != _remoteSteamId) {
- Logger.Warn($"Steam P2P: Received packet from unexpected peer {remoteSteamId}, expected {_remoteSteamId}");
- //return 0;
- return;
- }
+ ///
+ /// Process all available incoming P2P packets.
+ /// Drains the entire queue to prevent packet buildup when polling.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void ReceivePackets() {
+ // Early exit checks (use bitwise OR to avoid branch prediction penalty)
+ if (!_isConnected | !_steamInitialized) return;
+
+ // Cache event delegate to avoid repeated volatile field access
+ var dataReceived = DataReceivedEvent;
+ if (dataReceived == null) return;
+
+ var receiveBuffer = _receiveBuffer;
+ var remoteSteamId = _remoteSteamId;
+
+ // Drain ALL available packets (matches server-side behavior)
+ while (SteamNetworking.IsP2PPacketAvailable(out var packetSize, ServerChannel)) {
+ // Client listens for server packets on Channel 1
+ if (!SteamNetworking.ReadP2PPacket(
+ receiveBuffer,
+ SteamMaxPacketSize,
+ out packetSize,
+ out var senderSteamId,
+ ServerChannel
+ )) {
+ continue;
+ }
- var size = (int) packetSize;
+ // Validate sender (security check)
+ if (senderSteamId != remoteSteamId) {
+ Logger.Warn(
+ $"Steam P2P: Received packet from unexpected peer {senderSteamId}, expected {remoteSteamId}"
+ );
+ continue;
+ }
- // Always fire the event
- var data = new byte[size];
- Array.Copy(_receiveBuffer, 0, data, 0, size);
- DataReceivedEvent?.Invoke(data, size);
+ var size = (int)packetSize;
- // Copy to buffer if provided
- if (buffer != null) {
- var bytesToCopy = System.Math.Min(size, length);
- Array.Copy(_receiveBuffer, 0, buffer, offset, bytesToCopy);
+ // Allocate a copy for safety - handlers may hold references
+ var data = new byte[size];
+ Buffer.BlockCopy(receiveBuffer, 0, data, 0, size);
+ dataReceived(data, size);
}
}
@@ -172,18 +246,23 @@ out var remoteSteamId
public void Disconnect() {
if (!_isConnected) return;
- SteamLoopbackChannel.GetOrCreate().UnregisterClient();
- SteamLoopbackChannel.ReleaseIfEmpty();
+ // Signal shutdown first
+ _isConnected = false;
+ _receiveTokenSource?.Cancel();
- Logger.Info($"Steam P2P: Disconnecting from {_remoteSteamId}");
+ if (_cachedLoopbackChannel != null) {
+ _cachedLoopbackChannel.UnregisterClient();
+ SteamLoopbackChannel.ReleaseIfEmpty();
+ _cachedLoopbackChannel = null;
+ }
- _receiveTokenSource?.Cancel();
+ Logger.Info($"Steam P2P: Disconnecting from {_remoteSteamId}");
- if (SteamManager.IsInitialized) {
+ if (_steamInitialized) {
SteamNetworking.CloseP2PSessionWithUser(_remoteSteamId);
}
- _remoteSteamId = CSteamID.Nil;
+ _remoteSteamId = NilSteamId;
if (_receiveThread != null) {
if (!_receiveThread.Join(5000)) {
@@ -193,38 +272,83 @@ public void Disconnect() {
_receiveThread = null;
}
- _isConnected = false;
_receiveTokenSource?.Dispose();
_receiveTokenSource = null;
}
///
- /// Continuously polls for incoming P2P packets.
+ /// Continuously polls for incoming P2P packets with precise timing.
+ /// Uses Stopwatch + hybrid spin/sleep for consistent ~58Hz polling rate.
/// Steam API limitation: no blocking receive or callback available, must poll.
///
private void ReceiveLoop() {
var token = _receiveTokenSource;
if (token == null) return;
- while (_isConnected && !token.IsCancellationRequested) {
+ var cancellationToken = token.Token;
+ var stopwatch = Stopwatch.StartNew();
+ var nextPollTime = stopwatch.ElapsedMilliseconds;
+ const long pollInterval = (long)PollIntervalMS;
+
+ // Preallocate SpinWait struct to avoid per-iteration allocation
+ var spinWait = new SpinWait();
+
+ while (_isConnected) {
try {
+ // Fast cancellation check without allocation
+ if (cancellationToken.IsCancellationRequested) break;
+
// Exit cleanly if Steam shuts down (e.g., during forceful game closure)
if (!SteamManager.IsInitialized) {
+ _steamInitialized = false;
Logger.Info("Steam P2P: Steam shut down, exiting receive loop");
break;
}
- Receive(null, 0, 0);
+ // Precise wait until next poll time
+ var currentTime = stopwatch.ElapsedMilliseconds;
+ var waitTime = nextPollTime - currentTime;
+
+ if (waitTime > 0) {
+ if (waitTime > SpinWaitThreshold) {
+ // Sleep for most of the wait, then spin for precision
+ var sleepTime = (int)(waitTime - 2);
+ if (sleepTime > 0) {
+ Thread.Sleep(sleepTime);
+ }
+ }
+
+ // Spin for remaining time (high precision, low overhead)
+ spinWait.Reset();
+ while (stopwatch.ElapsedMilliseconds < nextPollTime) {
+ spinWait.SpinOnce();
+ }
+ }
+
+ // Poll for available packets (hot path)
+ ReceivePackets();
- // Steam API does not provide a blocking receive or callback for P2P packets,
- // so we must poll. Sleep interval is tuned to achieve ~58Hz polling rate.
- Thread.Sleep(TimeSpan.FromMilliseconds(PollIntervalMS));
+ // Schedule next poll
+ nextPollTime += pollInterval;
+
+ // Drift correction: prevent accumulating lag
+ var now = stopwatch.ElapsedMilliseconds;
+ if (now > nextPollTime + MaxDriftThreshold) {
+ nextPollTime = now + pollInterval;
+ }
} catch (InvalidOperationException ex) when (ex.Message.Contains("Steamworks is not initialized")) {
// Steam shut down during operation - exit gracefully
+ _steamInitialized = false;
Logger.Info("Steam P2P: Steamworks shut down during receive, exiting loop");
break;
+ } catch (ThreadAbortException) {
+ // Thread is being aborted during shutdown - exit gracefully
+ Logger.Info("Steam P2P: Receive thread aborted, exiting loop");
+ break;
} catch (Exception e) {
Logger.Error($"Steam P2P: Error in receive loop: {e}");
+ // Continue polling on error, but maintain timing
+ nextPollTime = stopwatch.ElapsedMilliseconds + pollInterval;
}
}
@@ -234,6 +358,7 @@ private void ReceiveLoop() {
///
/// Receives a packet from the loopback channel.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ReceiveLoopbackPacket(byte[] data, int length) {
if (!_isConnected) return;
DataReceivedEvent?.Invoke(data, length);
diff --git a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportClient.cs b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportClient.cs
index 7a675cc..5f7be53 100644
--- a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportClient.cs
+++ b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportClient.cs
@@ -81,7 +81,8 @@ private void SendInternal(byte[] buffer, int offset, int length, EP2PSend sendTy
return;
}
- if (!SteamNetworking.SendP2PPacket(_steamIdStruct, buffer, (uint) length, sendType)) {
+ // Server sends to client on Channel 1
+ if (!SteamNetworking.SendP2PPacket(_steamIdStruct, buffer, (uint) length, sendType, 1)) {
Logger.Warn($"Steam P2P: Failed to send packet to client {SteamId}");
}
}
diff --git a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportServer.cs b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportServer.cs
index 08e282e..28ee337 100644
--- a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportServer.cs
+++ b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransportServer.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
using System.Threading;
using SSMP.Game;
using SSMP.Logging;
@@ -11,8 +13,9 @@ namespace SSMP.Networking.Transport.SteamP2P;
///
/// Steam P2P implementation of .
/// Manages multiple client connections via Steam P2P networking.
+/// Optimized for maximum performance with minimal allocations.
///
-internal class SteamEncryptedTransportServer : IEncryptedTransportServer {
+internal sealed class SteamEncryptedTransportServer : IEncryptedTransportServer {
///
/// Maximum Steam P2P packet size.
///
@@ -24,16 +27,28 @@ internal class SteamEncryptedTransportServer : IEncryptedTransportServer {
///
private const double PollIntervalMS = 17.2;
+ ///
+ /// Maximum wait time threshold before switching from spin to sleep (ms).
+ ///
+ private const int SpinWaitThreshold = 15;
+
+ ///
+ /// Maximum drift correction threshold in milliseconds.
+ ///
+ private const long MaxDriftThreshold = 100;
+
///
public event Action? ClientConnectedEvent;
///
/// Connected clients indexed by Steam ID.
+ /// Uses optimal concurrency level for typical game server scenarios.
///
- private readonly ConcurrentDictionary _clients = new();
+ private readonly ConcurrentDictionary _clients =
+ new(Environment.ProcessorCount, 64);
///
- /// Buffer for receiving P2P packets.
+ /// Buffer for receiving P2P packets (aligned for better cache performance).
///
private readonly byte[] _receiveBuffer = new byte[MaxPacketSize];
@@ -42,6 +57,11 @@ internal class SteamEncryptedTransportServer : IEncryptedTransportServer {
///
private volatile bool _isRunning;
+ ///
+ /// Cached flag for Steam initialization state.
+ ///
+ private volatile bool _steamInitialized;
+
///
/// Callback for P2P session requests.
///
@@ -57,13 +77,20 @@ internal class SteamEncryptedTransportServer : IEncryptedTransportServer {
///
private Thread? _receiveThread;
+ ///
+ /// Cached loopback channel reference.
+ ///
+ private SteamLoopbackChannel? _cachedLoopbackChannel;
+
///
/// Start listening for Steam P2P connections.
///
/// Port parameter (unused for Steam P2P)
/// Thrown if Steam is not initialized.
public void Start(int port) {
- if (!SteamManager.IsInitialized) {
+ _steamInitialized = SteamManager.IsInitialized;
+
+ if (!_steamInitialized) {
throw new InvalidOperationException("Cannot start Steam P2P server: Steam is not initialized");
}
@@ -80,10 +107,15 @@ public void Start(int port) {
Logger.Info("Steam P2P: Server started, listening for connections");
- SteamLoopbackChannel.GetOrCreate().RegisterServer(this);
+ _cachedLoopbackChannel = SteamLoopbackChannel.GetOrCreate();
+ _cachedLoopbackChannel.RegisterServer(this);
_receiveTokenSource = new CancellationTokenSource();
- _receiveThread = new Thread(ReceiveLoop) { IsBackground = true };
+ _receiveThread = new Thread(ReceiveLoop) {
+ IsBackground = true,
+ Priority = ThreadPriority.AboveNormal,
+ Name = "Steam P2P Server Receive"
+ };
_receiveThread.Start();
}
@@ -93,8 +125,8 @@ public void Stop() {
Logger.Info("Steam P2P: Stopping server");
+ // Signal shutdown first
_isRunning = false;
-
_receiveTokenSource?.Cancel();
if (_receiveThread != null) {
@@ -108,14 +140,19 @@ public void Stop() {
_receiveTokenSource?.Dispose();
_receiveTokenSource = null;
+ // Disconnect all clients
foreach (var client in _clients.Values) {
- DisconnectClient(client);
+ DisconnectClientInternal(client);
}
+ _clients.Clear();
- SteamLoopbackChannel.GetOrCreate().UnregisterServer();
- SteamLoopbackChannel.ReleaseIfEmpty();
+ // Cleanup loopback
+ if (_cachedLoopbackChannel != null) {
+ _cachedLoopbackChannel.UnregisterServer();
+ SteamLoopbackChannel.ReleaseIfEmpty();
+ _cachedLoopbackChannel = null;
+ }
- _clients.Clear();
_sessionRequestCallback?.Dispose();
_sessionRequestCallback = null;
@@ -123,13 +160,22 @@ public void Stop() {
}
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void DisconnectClient(IEncryptedTransportClient client) {
- if (client is not SteamEncryptedTransportClient steamClient) return;
+ if (client is SteamEncryptedTransportClient steamClient) {
+ DisconnectClientInternal(steamClient);
+ }
+ }
+ ///
+ /// Internal disconnect logic to avoid interface cast overhead in hot paths.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void DisconnectClientInternal(SteamEncryptedTransportClient steamClient) {
var steamId = new CSteamID(steamClient.SteamId);
if (!_clients.TryRemove(steamId, out _)) return;
- if (SteamManager.IsInitialized) {
+ if (_steamInitialized && SteamManager.IsInitialized) {
SteamNetworking.CloseP2PSessionWithUser(steamId);
}
@@ -151,44 +197,87 @@ private void OnP2PSessionRequest(P2PSessionRequest_t request) {
return;
}
+ // Fast path: check if already connected before creating new client
if (_clients.ContainsKey(remoteSteamId)) return;
var client = new SteamEncryptedTransportClient(remoteSteamId.m_SteamID);
- _clients[remoteSteamId] = client;
-
- Logger.Info($"Steam P2P: New client connected: {remoteSteamId}");
-
- ClientConnectedEvent?.Invoke(client);
+
+ // Use TryAdd to handle race conditions
+ if (_clients.TryAdd(remoteSteamId, client)) {
+ Logger.Info($"Steam P2P: New client connected: {remoteSteamId}");
+ ClientConnectedEvent?.Invoke(client);
+ }
}
///
- /// Continuously polls for incoming P2P packets.
+ /// Continuously polls for incoming P2P packets with precise timing.
+ /// Uses Stopwatch + hybrid spin/sleep for consistent ~58Hz polling rate.
/// Steam API limitation: no blocking receive or callback available for server-side, must poll.
///
private void ReceiveLoop() {
- // Make token a local variable in case _receiveTokenSource is re-initialized
var token = _receiveTokenSource;
if (token == null) return;
- while (_isRunning && !token.IsCancellationRequested) {
+ var cancellationToken = token.Token;
+ var stopwatch = Stopwatch.StartNew();
+ var nextPollTime = stopwatch.ElapsedMilliseconds;
+ const long pollInterval = (long)PollIntervalMS;
+
+ // Preallocate SpinWait struct to avoid per-iteration allocation
+ var spinWait = new SpinWait();
+
+ while (_isRunning) {
try {
+ // Fast cancellation check without allocation
+ if (cancellationToken.IsCancellationRequested) break;
+
// Exit cleanly if Steam shuts down (e.g., during forceful game closure)
if (!SteamManager.IsInitialized) {
+ _steamInitialized = false;
Logger.Info("Steam P2P Server: Steam shut down, exiting receive loop");
break;
}
+ // Precise wait until next poll time
+ var currentTime = stopwatch.ElapsedMilliseconds;
+ var waitTime = nextPollTime - currentTime;
+
+ if (waitTime > 0) {
+ if (waitTime > SpinWaitThreshold) {
+ // Sleep for most of the wait, then spin for precision
+ var sleepTime = (int)(waitTime - 2);
+ if (sleepTime > 0) {
+ Thread.Sleep(sleepTime);
+ }
+ }
+
+ // Spin for remaining time (high precision, low overhead)
+ spinWait.Reset();
+ while (stopwatch.ElapsedMilliseconds < nextPollTime) {
+ spinWait.SpinOnce();
+ }
+ }
+
+ // Poll for available packets (hot path)
ProcessIncomingPackets();
- // Steam API does not provide a blocking receive or callback for P2P packets,
- // so we must poll. Sleep interval is tuned to achieve ~58Hz polling rate.
- Thread.Sleep(TimeSpan.FromMilliseconds(PollIntervalMS));
+ // Schedule next poll
+ nextPollTime += pollInterval;
+
+ // Drift correction: prevent accumulating lag
+ var now = stopwatch.ElapsedMilliseconds;
+ if (now > nextPollTime + MaxDriftThreshold) {
+ nextPollTime = now + pollInterval;
+ }
} catch (InvalidOperationException ex) when (ex.Message.Contains("Steamworks is not initialized")) {
// Steam shut down during operation - exit gracefully
+ _steamInitialized = false;
Logger.Info("Steam P2P Server: Steamworks shut down during receive, exiting loop");
break;
} catch (Exception e) {
Logger.Error($"Steam P2P: Error in server receive loop: {e}");
+ // Continue polling on error, but maintain timing
+ nextPollTime = stopwatch.ElapsedMilliseconds + pollInterval;
}
}
@@ -197,24 +286,35 @@ private void ReceiveLoop() {
///
/// Processes available P2P packets.
+ /// Optimized for minimal allocations and maximum throughput.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ProcessIncomingPackets() {
- if (!_isRunning || !SteamManager.IsInitialized) return;
+ // Early exit checks (use bitwise OR to avoid branch prediction penalty)
+ if (!_isRunning | !_steamInitialized) return;
+ var receiveBuffer = _receiveBuffer;
+ var clients = _clients;
+
+ // Server listens for client packets on Channel 0
while (SteamNetworking.IsP2PPacketAvailable(out var packetSize)) {
if (!SteamNetworking.ReadP2PPacket(
- _receiveBuffer,
- MaxPacketSize,
- out packetSize,
- out var remoteSteamId
- )) {
+ receiveBuffer,
+ MaxPacketSize,
+ out packetSize,
+ out var remoteSteamId
+ )) {
continue;
}
- if (_clients.TryGetValue(remoteSteamId, out var client)) {
- var data = new byte[packetSize];
- Array.Copy(_receiveBuffer, 0, data, 0, (int) packetSize);
- client.RaiseDataReceived(data, (int) packetSize);
+ // Fast path: direct dictionary lookup
+ if (clients.TryGetValue(remoteSteamId, out var client)) {
+ var size = (int)packetSize;
+
+ // Allocate only for client delivery - unavoidable as client needs owned copy
+ var data = new byte[size];
+ Buffer.BlockCopy(receiveBuffer, 0, data, 0, size);
+ client.RaiseDataReceived(data, size);
} else {
Logger.Warn($"Steam P2P: Received packet from unknown client {remoteSteamId}");
}
@@ -224,22 +324,32 @@ out var remoteSteamId
///
/// Receives a packet from the loopback channel.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ReceiveLoopbackPacket(byte[] data, int length) {
- if (!_isRunning || !SteamManager.IsInitialized) return;
+ // Early exit checks (use bitwise OR to avoid branch prediction penalty)
+ if (!_isRunning | !_steamInitialized) return;
try {
var steamId = SteamUser.GetSteamID();
+ // Try to get existing client first (common case)
if (!_clients.TryGetValue(steamId, out var client)) {
+ // Create new loopback client
client = new SteamEncryptedTransportClient(steamId.m_SteamID);
- _clients[steamId] = client;
- ClientConnectedEvent?.Invoke(client);
- //Logger.Debug($"Steam P2P: New loopback client connected: {steamId}");
+
+ // Use TryAdd to handle race conditions
+ if (_clients.TryAdd(steamId, client)) {
+ ClientConnectedEvent?.Invoke(client);
+ } else {
+ // Another thread added it, retrieve the instance
+ _clients.TryGetValue(steamId, out client);
+ }
}
- client.RaiseDataReceived(data, length);
+ client?.RaiseDataReceived(data, length);
} catch (InvalidOperationException) {
// Steam shut down between check and API call - ignore silently
+ _steamInitialized = false;
}
}
}
diff --git a/SSMP/Networking/Transport/UDP/UdpDatagramTransport.cs b/SSMP/Networking/Transport/UDP/UdpDatagramTransport.cs
index 2f5137c..7fe9ea1 100644
--- a/SSMP/Networking/Transport/UDP/UdpDatagramTransport.cs
+++ b/SSMP/Networking/Transport/UDP/UdpDatagramTransport.cs
@@ -13,20 +13,14 @@ internal abstract class UdpDatagramTransport : DatagramTransport {
///
/// Token source for cancelling the blocking call on the received data collection.
///
- private readonly CancellationTokenSource _cancellationTokenSource;
+ private readonly CancellationTokenSource _cancellationTokenSource = new();
///
/// A thread-safe blocking collection storing received data that is used to handle the "Receive" calls from the
/// DTLS transport.
///
- public BlockingCollection ReceivedDataCollection { get; }
+ public BlockingCollection ReceivedDataCollection { get; } = new();
- protected UdpDatagramTransport() {
- _cancellationTokenSource = new CancellationTokenSource();
-
- ReceivedDataCollection = new BlockingCollection();
- }
-
///
/// This method is called whenever the corresponding DtlsTransport's Receive is called. The implementation
/// obtains data from the blocking collection and store it in the given buffer. If no data is present in the
@@ -139,10 +133,10 @@ public class ReceivedData {
///
/// Byte array containing the data.
///
- public required byte[] Buffer { get; set; }
+ public required byte[] Buffer { get; init; }
///
/// The number of bytes in the buffer.
///
- public required int Length { get; set; }
+ public required int Length { get; init; }
}
}
diff --git a/SSMP/Networking/UpdateManager.cs b/SSMP/Networking/UpdateManager.cs
index 69fc2fd..eec0f48 100644
--- a/SSMP/Networking/UpdateManager.cs
+++ b/SSMP/Networking/UpdateManager.cs
@@ -80,11 +80,6 @@ internal abstract class UpdateManager
///
private bool _requiresSequencing = true;
- ///
- /// Whether the transport requires application-level reliability.
- ///
- protected bool RequiresReliability { get; private set; } = true;
-
///
/// The last sent sequence number.
///
@@ -113,7 +108,7 @@ internal abstract class UpdateManager
private volatile object? _transportSender;
///
- /// The current update packet being assembled.
+ /// The current update packet being assembled. Protected for subclass access.
///
protected TOutgoing CurrentUpdatePacket { get; private set; }
@@ -122,6 +117,11 @@ internal abstract class UpdateManager
///
protected object Lock { get; } = new();
+ ///
+ /// Whether the transport requires application-level reliability. Protected for subclass access.
+ ///
+ protected bool RequiresReliability { get; private set; } = true;
+
///
/// Gets or sets the transport for client-side communication.
/// Captures transport capabilities when set.
@@ -214,6 +214,33 @@ public void StartUpdates() {
_isUpdating = true;
}
+ ///
+ /// Stop sending the periodic UDP update packets after sending the current one.
+ ///
+ ///
+ /// Resets the update manager state, clearing queues and sequences.
+ ///
+ public void Reset() {
+ lock (Lock) {
+ _receivedQueue?.Clear();
+
+ _localSequence = 0;
+ _remoteSequence = 0;
+ CurrentUpdatePacket = new TOutgoing();
+ _lastSendRate = CurrentSendRate;
+
+ // Reset managers by nullifying them - InitializeManagersIfNeeded will recreate them
+ // with proper transport properties
+ _rttTracker = null;
+ _reliabilityManager = null;
+ _congestionManager = null;
+ _receivedQueue = null;
+
+ // Reinitialize managers with transport properties set correctly
+ InitializeManagersIfNeeded();
+ }
+ }
+
///
/// Stop sending the periodic UDP update packets after sending the current one.
///
@@ -266,8 +293,6 @@ public void OnReceivePacket(TIncoming packet)
_congestionManager?.OnReceivePacket();
var sequence = packet.Sequence;
- // _receivedQueue is guaranteed non-null here:
- // InitializeManagersIfNeeded() initializes it when _requiresSequencing is true
_receivedQueue!.Enqueue(sequence);
packet.DropDuplicateResendData(_receivedQueue.GetCopy());
@@ -310,12 +335,8 @@ private void CreateAndSendPacket() {
// Transports requiring sequencing: Track for RTT, reliability
if (_requiresSequencing) {
- // _rttTracker is guaranteed non-null here:
- // InitializeManagersIfNeeded() initializes it when _requiresSequencing is true
_rttTracker!.OnSendPacket(_localSequence);
if (RequiresReliability) {
- // _reliabilityManager is guaranteed non-null here: InitializeManagersIfNeeded() initializes it
- // when RequiresReliability is true and _rttTracker is non-null (which it is per above)
_reliabilityManager!.OnSendPacket(_localSequence, packetToSend);
}
@@ -330,9 +351,7 @@ private void CreateAndSendPacket() {
/// Each bit indicates whether a packet with that sequence number was received.
/// Only used for UDP/HolePunch transports.
///
- private void PopulateAckField() {
- // _receivedQueue is guaranteed non-null here: this method is only called inside _requiresSequencing blocks,
- // and InitializeManagersIfNeeded() initializes _receivedQueue when _requiresSequencing is true
+ private void PopulateAckField() {
var receivedQueue = _receivedQueue!.GetCopy();
var ackField = CurrentUpdatePacket.AckField;
@@ -361,7 +380,7 @@ private void SendWithFragmentation(Packet.Packet packet, bool isReliable) {
while (remaining > 0) {
var chunkSize = System.Math.Min(remaining, PacketMtu);
var fragment = new byte[chunkSize];
-
+
Array.Copy(data, offset, fragment, 0, chunkSize);
// Fragmented packets are only reliable if the original packet was, and we only
diff --git a/SSMP/SSMP.csproj b/SSMP/SSMP.csproj
index 34e86a2..2f7dc6a 100644
--- a/SSMP/SSMP.csproj
+++ b/SSMP/SSMP.csproj
@@ -23,6 +23,10 @@
LGPL-2.1-or-later
..\bin\
+
+
+ true
+ portable
diff --git a/SSMP/Ui/Component/LobbyBrowserPanel.cs b/SSMP/Ui/Component/LobbyBrowserPanel.cs
new file mode 100644
index 0000000..ebad687
--- /dev/null
+++ b/SSMP/Ui/Component/LobbyBrowserPanel.cs
@@ -0,0 +1,359 @@
+using System;
+using System.Collections.Generic;
+using SSMP.Networking.Matchmaking;
+using UnityEngine;
+using UnityEngine.UI;
+using Object = UnityEngine.Object;
+
+namespace SSMP.Ui.Component;
+
+///
+/// A full-screen overlay panel that displays public lobbies from MMS.
+/// Includes Back and Refresh buttons at the bottom.
+///
+internal class LobbyBrowserPanel : IComponent {
+ /// The root GameObject for this panel.
+ private GameObject GameObject { get; }
+
+ /// Content container for the scrollable lobby list.
+ private readonly RectTransform _content;
+
+ /// Text displayed when no lobbies are available.
+ private readonly Text _emptyText;
+
+ /// List of instantiated lobby entry GameObjects.
+ private readonly List _lobbyEntries = [];
+
+ /// Callback invoked when a lobby is selected.
+ private Action? _onLobbySelected;
+
+ /// Callback invoked when Back is pressed.
+ private Action? _onBack;
+
+ /// Callback invoked when Refresh is pressed.
+ private Action? _onRefresh;
+
+ /// Tracks the panel's own active state.
+ private bool _activeSelf;
+
+ /// Parent component group for visibility management.
+ private readonly ComponentGroup _componentGroup;
+
+ /// Height of each lobby entry row.
+ private const float EntryHeight = 50f;
+
+ /// Vertical spacing between lobby entries.
+ private const float EntrySpacing = 8f;
+
+ /// Padding around panel edges.
+ private const float Padding = 15f;
+
+ /// Height of the header text.
+ private const float HeaderHeight = 35f;
+
+ /// Height of the bottom button area.
+ private const float ButtonAreaHeight = 60f;
+
+ public LobbyBrowserPanel(ComponentGroup parent, Vector2 position, Vector2 size) {
+ // Create main container - no background, sits inside existing panel
+ GameObject = new GameObject("LobbyBrowserPanel");
+ var rect = GameObject.AddComponent();
+ rect.anchorMin = rect.anchorMax = new Vector2(position.x / 1920f, position.y / 1080f);
+ rect.sizeDelta = size;
+ rect.pivot = new Vector2(0.5f, 1f); // Top-center pivot to align with content area
+
+ // Header: "PUBLIC LOBBIES"
+ var header = new GameObject("Header");
+ var headerRect = header.AddComponent();
+ headerRect.anchorMin = new Vector2(0f, 1f);
+ headerRect.anchorMax = new Vector2(1f, 1f);
+ headerRect.pivot = new Vector2(0.5f, 1f);
+ headerRect.anchoredPosition = Vector2.zero;
+ headerRect.sizeDelta = new Vector2(0f, HeaderHeight);
+ var headerText = header.AddComponent();
+ headerText.text = "PUBLIC LOBBIES";
+ headerText.font = Resources.FontManager.UIFontRegular;
+ headerText.fontSize = 18;
+ headerText.alignment = TextAnchor.MiddleCenter;
+ headerText.color = new Color(1f, 0.85f, 0.6f, 1f); // Gold/orange accent
+ header.transform.SetParent(GameObject.transform, false);
+
+ // Scroll view for lobby list (between header and buttons)
+ var scrollView = new GameObject("ScrollView");
+ var scrollRect = scrollView.AddComponent();
+ var scrollRectTransform = scrollView.GetComponent();
+ scrollRectTransform.anchorMin = new Vector2(0f, 0f);
+ scrollRectTransform.anchorMax = new Vector2(1f, 1f);
+ scrollRectTransform.offsetMin = new Vector2(Padding, ButtonAreaHeight);
+ scrollRectTransform.offsetMax = new Vector2(-Padding, -HeaderHeight - Padding);
+ scrollView.transform.SetParent(GameObject.transform, false);
+
+ // Viewport
+ var viewport = new GameObject("Viewport");
+ var viewportRect = viewport.AddComponent();
+ viewportRect.anchorMin = Vector2.zero;
+ viewportRect.anchorMax = Vector2.one;
+ viewportRect.offsetMin = Vector2.zero;
+ viewportRect.offsetMax = Vector2.zero;
+ viewport.AddComponent();
+ viewport.transform.SetParent(scrollView.transform, false);
+
+ // Content container
+ var content = new GameObject("Content");
+ _content = content.AddComponent();
+ _content.anchorMin = new Vector2(0f, 1f);
+ _content.anchorMax = new Vector2(1f, 1f);
+ _content.pivot = new Vector2(0.5f, 1f);
+ _content.anchoredPosition = Vector2.zero;
+ _content.sizeDelta = new Vector2(0f, 0f);
+ content.transform.SetParent(viewport.transform, false);
+
+ scrollRect.viewport = viewportRect;
+ scrollRect.content = _content;
+ scrollRect.horizontal = false;
+ scrollRect.vertical = true;
+ scrollRect.scrollSensitivity = 25f;
+ scrollRect.movementType = ScrollRect.MovementType.Clamped;
+
+ // Empty message
+ var emptyObj = new GameObject("EmptyText");
+ var emptyRect = emptyObj.AddComponent();
+ emptyRect.anchorMin = new Vector2(0.5f, 0.5f);
+ emptyRect.anchorMax = new Vector2(0.5f, 0.5f);
+ emptyRect.pivot = new Vector2(0.5f, 0.5f);
+ emptyRect.sizeDelta = new Vector2(size.x - 60f, 80f);
+ _emptyText = emptyObj.AddComponent();
+ _emptyText.text = "No public lobbies found.\nClick Refresh to check again.";
+ _emptyText.font = Resources.FontManager.UIFontRegular;
+ _emptyText.fontSize = 16;
+ _emptyText.alignment = TextAnchor.MiddleCenter;
+ _emptyText.color = new Color(0.5f, 0.5f, 0.5f, 1f);
+ emptyObj.transform.SetParent(content.transform, false);
+
+ // Bottom button area
+ var buttonArea = new GameObject("ButtonArea");
+ var buttonAreaRect = buttonArea.AddComponent();
+ buttonAreaRect.anchorMin = new Vector2(0f, 0f);
+ buttonAreaRect.anchorMax = new Vector2(1f, 0f);
+ buttonAreaRect.pivot = new Vector2(0.5f, 0f);
+ buttonAreaRect.anchoredPosition = Vector2.zero;
+ buttonAreaRect.sizeDelta = new Vector2(0f, ButtonAreaHeight);
+ buttonArea.transform.SetParent(GameObject.transform, false);
+
+ // Back button (left)
+ CreateButton(buttonArea.transform, "BackButton", "← BACK",
+ new Vector2(0.02f, 0.12f), new Vector2(0.48f, 0.88f),
+ new Color(0.15f, 0.15f, 0.18f, 1f), () => _onBack?.Invoke());
+
+ // Refresh button (right)
+ CreateButton(buttonArea.transform, "RefreshButton", "↻ REFRESH",
+ new Vector2(0.52f, 0.12f), new Vector2(0.98f, 0.88f),
+ new Color(0.15f, 0.4f, 0.25f, 1f), () => _onRefresh?.Invoke());
+
+ _componentGroup = parent;
+ _activeSelf = false;
+ parent.AddComponent(this);
+ GameObject.transform.SetParent(UiManager.UiGameObject!.transform, false);
+ Object.DontDestroyOnLoad(GameObject);
+ GameObject.SetActive(false);
+ }
+
+ private void CreateButton(Transform parent, string name, string text,
+ Vector2 anchorMin, Vector2 anchorMax, Color bgColor, Action onClick) {
+ var btnObj = new GameObject(name);
+ var btnRect = btnObj.AddComponent();
+ btnRect.anchorMin = anchorMin;
+ btnRect.anchorMax = anchorMax;
+ btnRect.offsetMin = Vector2.zero;
+ btnRect.offsetMax = Vector2.zero;
+
+ var btnImage = btnObj.AddComponent();
+ btnImage.color = bgColor;
+
+ var btnText = new GameObject("Text");
+ var btnTextRect = btnText.AddComponent();
+ btnTextRect.anchorMin = Vector2.zero;
+ btnTextRect.anchorMax = Vector2.one;
+ btnTextRect.offsetMin = Vector2.zero;
+ btnTextRect.offsetMax = Vector2.zero;
+ var textComp = btnText.AddComponent();
+ textComp.text = text;
+ textComp.font = Resources.FontManager.UIFontRegular;
+ textComp.fontSize = 16;
+ textComp.alignment = TextAnchor.MiddleCenter;
+ textComp.color = Color.white;
+ btnText.transform.SetParent(btnObj.transform, false);
+
+ var button = btnObj.AddComponent