Practice/deck editing mostly there

This commit is contained in:
gamer147
2026-05-24 00:17:28 -04:00
parent 21b97269ff
commit bdff142d16
14 changed files with 588 additions and 59 deletions

View File

@@ -0,0 +1,41 @@
using Steamworks;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Production <see cref="ISteamServer"/> backed by Facepunch.Steamworks 2.3.3.
/// Initialization is process-global; registering this as a singleton is required.
/// </summary>
public sealed class FacepunchSteamServer : ISteamServer
{
private readonly object _initLock = new();
private bool _initialized;
public void Initialize(int appId)
{
if (_initialized) return;
lock (_initLock)
{
if (_initialized) return;
SteamServer.Init(appId, new SteamServerInit
{
GamePort = default,
QueryPort = default,
});
_initialized = true;
}
}
public bool BeginAuthSession(byte[] ticket, ulong steamId) =>
SteamServer.BeginAuthSession(ticket, new SteamId { Value = steamId });
public void EndSession(ulong steamId) =>
SteamServer.EndSession(new SteamId { Value = steamId });
public void Shutdown()
{
if (!_initialized) return;
SteamServer.Shutdown();
_initialized = false;
}
}

View File

@@ -0,0 +1,34 @@
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Thin wrapper around the static <c>Steamworks.SteamServer</c> API. Exists purely so
/// <see cref="SteamSessionService"/> can be unit-tested — Facepunch.Steamworks is a static
/// class with process-global state that can't be mocked or run twice in the same test host.
///
/// Only the operations <see cref="SteamSessionService"/> actually invokes are exposed. Add
/// methods here as needed rather than expanding the surface speculatively.
/// </summary>
public interface ISteamServer
{
/// <summary>Initialize the underlying Steam game server with the given app id. Idempotent.</summary>
void Initialize(int appId);
/// <summary>
/// Open an auth session for the given steam id with the given ticket bytes. Returns true
/// when Steam accepts the ticket. Returns false on any rejection — most commonly the
/// "duplicate request" case (the steam id already has an open session on this server),
/// which is the failure mode <see cref="SteamSessionService"/> resolves by calling
/// <see cref="EndSession"/> first.
/// </summary>
bool BeginAuthSession(byte[] ticket, ulong steamId);
/// <summary>
/// Close any active auth session for this steam id. Safe to call when no session exists
/// (Steam SDK no-ops). Must be called before a second <see cref="BeginAuthSession"/> for
/// the same steam id or Steam will reject the new call as a duplicate request.
/// </summary>
void EndSession(ulong steamId);
/// <summary>Tear down the game server. Implicitly ends every open auth session.</summary>
void Shutdown();
}

View File

@@ -1,74 +1,138 @@
using System.Collections.Concurrent;
using Steamworks;
using Microsoft.Extensions.Logging;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Validates Steam session tickets against Steamworks for the auth handler. Owns the
/// lifecycle of per-user Steam auth sessions: when a client reconnects with a fresh ticket
/// (typical on every client restart — Steam tickets are per-process), we end the prior
/// session before opening a new one. Without that, Steam rejects the second
/// <c>BeginAuthSession</c> as a duplicate request and the auth handler returns 401.
///
/// See <c>docs/audits/game-start-steam-ticket-401-on-client-restart-2026-05-23.md</c> for
/// the original symptom + the choice of end-then-begin over reactively retrying on
/// DuplicateRequest (Facepunch 2.3.3 collapses the BeginAuthResult enum to bool, so we
/// can't see the duplicate-request signal directly).
/// </summary>
public class SteamSessionService : IDisposable
{
private readonly ConcurrentDictionary<string, ulong> _validatedSessionTickets = new();
private readonly object _initLock = new();
private bool _steamInitialized;
private const int ShadowVerseAppId = 453480;
/// <summary>
/// Validates if a given session ticket is valid, and matches up with the given steamid.
/// </summary>
/// <param name="ticket">the ticket, represented as a hexadecimal string</param>
/// <param name="steamId">the steamid that should be associated with the ticket</param>
/// <returns>whether the ticket is valid for the given steamid</returns>
private readonly ISteamServer _steam;
private readonly ILogger<SteamSessionService> _logger;
/// <summary>Ticket-bytes-to-steamid for cache hits on identical re-sends (e.g. retries within one client session).</summary>
private readonly ConcurrentDictionary<string, ulong> _validatedSessionTickets = new();
/// <summary>steamId → currently-open ticket. Single entry per user; replaced when a new ticket supersedes it.</summary>
private readonly ConcurrentDictionary<ulong, string> _activeSessionBySteamId = new();
/// <summary>Per-steamId mutex so the check-end-begin sequence is atomic for that user without serializing all auth.</summary>
private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _steamIdLocks = new();
public SteamSessionService(ISteamServer steam, ILogger<SteamSessionService> logger)
{
_steam = steam;
_logger = logger;
}
public bool IsTicketValidForUser(string ticket, ulong steamId)
{
if (string.IsNullOrEmpty(ticket))
{
// Caller already shouldn't pass null/empty here, but a misshaped request body
// (e.g. wrong casing) used to NRE on the ConcurrentDictionary lookup below.
// Fail cleanly so the auth pipeline returns 401 instead of crashing the request.
// Mishaped request body (wrong casing on the field) used to NRE on the dictionary
// lookup. Fail cleanly so the auth pipeline returns 401 instead of 500.
return false;
}
if (_validatedSessionTickets.TryGetValue(ticket, out ulong storedSteamId))
// Fast path: identical bytes from a prior validated call for this user. Real clients
// don't replay tickets across restarts (Steam regenerates per-process), but in-process
// retries can hit this and avoid both the Steam SDK call and the per-user lock.
if (_validatedSessionTickets.TryGetValue(ticket, out ulong cachedSteamId))
{
return storedSteamId == steamId;
return cachedSteamId == steamId;
}
EnsureSteamInitialized();
_steam.Initialize(ShadowVerseAppId);
List<byte> ticketBytes = new List<byte>();
for (int i = 0; i < ticket.Length; i += 2)
byte[] ticketBytes = HexDecode(ticket);
SemaphoreSlim gate = _steamIdLocks.GetOrAdd(steamId, _ => new SemaphoreSlim(1, 1));
gate.Wait();
try
{
ticketBytes.Add(Convert.ToByte(ticket.Substring(i, 2), 16));
}
// Re-check the cache: another caller for the same steamId may have validated this
// exact ticket while we were waiting on the semaphore.
if (_validatedSessionTickets.TryGetValue(ticket, out cachedSteamId))
{
return cachedSteamId == steamId;
}
var steamCheckResults = SteamServer.BeginAuthSession(ticketBytes.ToArray(), new SteamId { Value = steamId });
if (steamCheckResults)
// If a different ticket is currently open for this steam id, close it first.
// Steam's BeginAuthSession returns DuplicateRequest (which Facepunch surfaces as
// false) when the same user already has an open session on this server — that's
// the entire bug this whole machinery exists to fix.
if (_activeSessionBySteamId.TryGetValue(steamId, out string? priorTicket)
&& !string.Equals(priorTicket, ticket, StringComparison.Ordinal))
{
_logger.LogInformation(
"Retiring stale Steam auth session for steamId {SteamId} before opening a new one (prior ticket bytes differ).",
steamId);
_steam.EndSession(steamId);
_validatedSessionTickets.TryRemove(priorTicket, out _);
_activeSessionBySteamId.TryRemove(steamId, out _);
}
bool accepted = _steam.BeginAuthSession(ticketBytes, steamId);
if (!accepted)
{
_logger.LogWarning("Steam rejected BeginAuthSession for steamId {SteamId}.", steamId);
return false;
}
_validatedSessionTickets[ticket] = steamId;
_activeSessionBySteamId[steamId] = ticket;
return true;
}
finally
{
_validatedSessionTickets.TryAdd(ticket, steamId);
gate.Release();
}
return steamCheckResults;
}
private void EnsureSteamInitialized()
private static byte[] HexDecode(string hex)
{
if (_steamInitialized) return;
lock (_initLock)
byte[] bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
if (_steamInitialized) return;
SteamServer.Init(ShadowVerseAppId, new SteamServerInit
{
GamePort = default,
QueryPort = default
});
_steamInitialized = true;
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return bytes;
}
public void Dispose()
{
if (_steamInitialized)
// End every tracked session before shutting down so the Steam SDK doesn't see orphaned
// sessions on the next process. Shutdown does this implicitly, but being explicit keeps
// observability honest (and tests can assert it on the fake without needing Shutdown).
foreach (var (steamId, _) in _activeSessionBySteamId)
{
SteamServer.Shutdown();
try { _steam.EndSession(steamId); }
catch (Exception ex)
{
_logger.LogWarning(ex, "EndSession during dispose failed for steamId {SteamId}.", steamId);
}
}
_activeSessionBySteamId.Clear();
_validatedSessionTickets.Clear();
foreach (var sem in _steamIdLocks.Values) sem.Dispose();
_steamIdLocks.Clear();
try { _steam.Shutdown(); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Steam shutdown failed.");
}
}
}