From 89230bd6997fe56532be67113e1a61f21e603ff0 Mon Sep 17 00:00:00 2001 From: Vesly <74131882+Veslydev@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:29:56 +0300 Subject: [PATCH 01/15] fix(updater): fallback to GitHub API for releases and bump version to 2.1.3 --- SiteLink.API/Packages/PackageManager.cs | 123 ++++++++++++++++++++++-- SiteLink.API/SiteLinkAPI.cs | 4 +- SiteLink/Services/BuildInformation.cs | 4 +- SiteLink/SiteLink.csproj | 4 +- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/SiteLink.API/Packages/PackageManager.cs b/SiteLink.API/Packages/PackageManager.cs index 476a7e8..424b750 100644 --- a/SiteLink.API/Packages/PackageManager.cs +++ b/SiteLink.API/Packages/PackageManager.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System.Diagnostics; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -20,12 +21,25 @@ public static async Task GetIndexAsync( CancellationToken cancellationToken = default) { (string owner, string name) = ParseRepository(repository); - string url = $"https://{owner.ToLowerInvariant()}.github.io/{name}/releases.json"; - using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); - string json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json) - ?? throw new InvalidDataException($"Release index '{url}' was empty."); + + try + { + string url = $"https://{owner.ToLowerInvariant()}.github.io/{name}/releases.json"; + using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + string json = await response.Content.ReadAsStringAsync(); + PackageReleaseIndex index = JsonConvert.DeserializeObject(json); + if (index != null && index.Versions != null && index.Versions.Count > 0) + return index; + } + } + catch + { + // Fall back to GitHub REST API if releases.json is missing or invalid + } + + return await GetIndexFromGitHubApiAsync(owner, name, cancellationToken); } public static PackageManifest GetLatest( @@ -174,6 +188,103 @@ public static (string Owner, string Repository) ParseRepository(string repositor private static Version ParseVersion(string value) => Version.TryParse(value?.TrimStart('v', 'V'), out Version version) ? version : null; + private static async Task GetIndexFromGitHubApiAsync( + string owner, + string name, + CancellationToken cancellationToken) + { + string url = $"https://api.github.com/repos/{owner}/{name}/releases"; + using HttpResponseMessage response = await Http.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(); + JArray releases = JArray.Parse(json); + + PackageReleaseIndex index = new() + { + DisplayName = name, + OwnerName = owner, + RepositoryName = name + }; + + foreach (JObject release in releases.Children()) + { + if (release["draft"]?.Value() == true) + continue; + + string tagName = release["tag_name"]?.ToString(); + if (string.IsNullOrWhiteSpace(tagName)) + continue; + + string versionStr = tagName.TrimStart('v', 'V'); + PackageManifest manifest = new() + { + DisplayName = name, + OwnerName = owner, + RepositoryName = name, + Version = versionStr, + PackageType = "core" + }; + + if (release["assets"] is JArray assets) + { + foreach (JObject asset in assets.Children()) + { + string fileName = asset["name"]?.ToString(); + string downloadUrl = asset["browser_download_url"]?.ToString(); + long size = asset["size"]?.Value() ?? 0; + + if (string.IsNullOrWhiteSpace(fileName) || string.IsNullOrWhiteSpace(downloadUrl)) + continue; + + string platform; + if (fileName.Equals("SiteLink.exe", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || + fileName.Contains("win", StringComparison.OrdinalIgnoreCase)) + { + platform = "windows"; + } + else if (fileName.Equals("SiteLink", StringComparison.OrdinalIgnoreCase) || + fileName.Contains("linux", StringComparison.OrdinalIgnoreCase)) + { + platform = "linux"; + } + else if (fileName.Contains("mac", StringComparison.OrdinalIgnoreCase) || + fileName.Contains("osx", StringComparison.OrdinalIgnoreCase)) + { + platform = "macos"; + } + else + { + platform = "any"; + } + + manifest.Platforms[platform] = new PackageAsset + { + Platform = platform, + FileName = fileName, + FileUrl = downloadUrl, + Size = size + }; + } + } + + if (index.DisplayName == name && release["name"] != null) + { + string relName = release["name"].ToString(); + if (!string.IsNullOrWhiteSpace(relName)) + index.DisplayName = relName; + } + + index.Versions[versionStr] = manifest; + } + + if (index.Versions.Count == 0) + throw new InvalidOperationException($"No valid releases found in GitHub repository '{owner}/{name}'."); + + return index; + } + private static HttpClient CreateHttpClient() { HttpClient client = new(); diff --git a/SiteLink.API/SiteLinkAPI.cs b/SiteLink.API/SiteLinkAPI.cs index 27daa25..c09e1d5 100644 --- a/SiteLink.API/SiteLinkAPI.cs +++ b/SiteLink.API/SiteLinkAPI.cs @@ -1,4 +1,4 @@ -using SiteLink.API; +using SiteLink.API; [assembly: AssemblyProduct("SiteLinkAPI")] [assembly: AssemblyCopyright("Killers0992 @ 2026")] @@ -12,7 +12,7 @@ public class SiteLinkAPI static Version _apiVersion; public const string GameVersionText = "14.2.7"; - public const string ApiVersionText = "2.1.2"; + public const string ApiVersionText = "2.1.3"; public static int ThresholdBytes => 65535 * (NetConstants.MaxPacketSize - 6); diff --git a/SiteLink/Services/BuildInformation.cs b/SiteLink/Services/BuildInformation.cs index eb3853a..b6a5a0e 100644 --- a/SiteLink/Services/BuildInformation.cs +++ b/SiteLink/Services/BuildInformation.cs @@ -1,6 +1,6 @@ -namespace SiteLink.Services; +namespace SiteLink.Services; public class BuildInformation { - public const string VersionText = "1.0.1"; + public static string VersionText => SiteLink.API.SiteLinkAPI.ApiVersionText; } \ No newline at end of file diff --git a/SiteLink/SiteLink.csproj b/SiteLink/SiteLink.csproj index edd085b..89e4a4f 100644 --- a/SiteLink/SiteLink.csproj +++ b/SiteLink/SiteLink.csproj @@ -1,4 +1,4 @@ - + Exe @@ -8,7 +8,7 @@ SiteLink Multi-server proxy for SCP: Secret Laboratory. Killers0992 - 2.1.2 + 2.1.3 win-x64;linux-x64 From 3020035b14f83b25becdb26ba8656b82f0ec0f62 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 14:57:57 +0000 Subject: [PATCH 02/15] docs: add SiteLink.Bridge design spec --- .../2026-07-29-sitelink-bridge-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md diff --git a/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md b/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md new file mode 100644 index 0000000..4bc79e0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md @@ -0,0 +1,134 @@ +# SiteLink.Bridge — game server plugin and release pipeline + +Date: 2026-07-29 +Status: Approved + +## Problem + +SiteLink ships a `net48` build of `SiteLink.API.dll` (containing only `SiteLinkBridge.cs`) +that a game server can load from `LabAPI/dependencies/global`. It exposes the connection, +messaging and target-server APIs, but nothing calls `SiteLinkBridge.Initialize`. Every server +owner has to write their own LabAPI plugin to do it. + +Two consequences: + +1. There is no released, ready-to-drop-in plugin. `release.yml` publishes only the proxy + executables; the `net48` bridge assembly is never built in CI (and could not be — the + reference download step does not fetch `mscorlib.dll` or `CommandSystem.Core.dll`). +2. Player counts reported to the SCP:SL central servers come from + `Server.SessionsCount` — the number of sessions *this proxy* is holding. With two proxies + in front of one game server, each reports its own slice, so neither number is correct. + CSGD 5.6 requires accurate data. Northwood's guidance is to report the game server's + count, not the proxy's. + +## Scope + +- A new `SiteLink.Bridge` LabAPI plugin project in this repository. +- A player-count packet, consumed built-in by `SiteLink.API` (not delegated to a + third-party proxy plugin). +- `SiteLinkBridge.TargetServers` filtered by `servers_in_selector`. +- `release.yml` producing `SiteLink.Bridge.dll` and `dependencies.zip` as release assets. + +Out of scope: changing the proxy's session accounting, the selector UI, or the PreAuth +handshake. + +## Architecture + +``` +Game server (net48) Proxy (net10.0) +------------------ --------------- +SiteLink.Bridge.dll (LabAPI plugin) + └ SiteLinkBridge.Initialize(ip,port,key) + │ LiteNetLib UDP, ClientType.Bridge + secret + └──────────────────────────────────────► Listener → BridgeConnection + └ SiteLinkBridge.AttachServerPeer + + TargetServers ◄── 17150 MsgTargetServersList ─── SendTargetServersList + (servers_in_selector filtered) + + PlayerCountReporter ── 17151 MsgPlayerCount ─────► Server.BridgePlayerCount + └ ScpServerListHandler +``` + +`SiteLink.API.dll` (net48) stays the only dependency; its LiteNetLib types resolve from the +game's `Assembly-CSharp.dll`, which embeds LiteNetLib. No extra dependency DLL is needed. + +## Components + +### SiteLink.Bridge (new, net48) + +| File | Purpose | +|---|---| +| `SiteLink.Bridge.csproj` | net48, `Northwood.LabAPI 1.1.7`, `Microsoft.NETFramework.ReferenceAssemblies`, `ProjectReference` to `SiteLink.API` with `Private=false` so the dependency DLL is not duplicated next to the plugin | +| `BridgeConfig.cs` | `ip` (`127.0.0.1`), `port` (`7777`), `secret_key` (`---`), `debug` (`true`), `player_count_report_interval` (`5.0` seconds) | +| `SiteLinkBridgePlugin.cs` | `Plugin`; `Enable()` calls `Initialize` and registers connected/disconnected handlers; `Disable()` unregisters | +| `PlayerCountReporter.cs` | Counts real players and pushes `MsgPlayerCount` | +| `BridgeStatusCommand.cs` | `.slbridge` — connection state, target endpoint, last reported count, raw/dummy counts, `TargetServers` | + +**Player counting.** Follows the rules established in `PrometheusConnector`: + +``` +foreach (Player player in Player.List) + if (player == null || player.IsHost) continue; // host is not a player + if (player.IsDummy) { dummies++; continue; } // dummies are not players + counted++; +``` + +Reported on a `MonoBehaviour` heartbeat (`InvokeRepeating`) at +`player_count_report_interval`, plus immediately when the count changes (join / leave / +round restart), debounced to one send per tick. Sending the count on a timer rather than +only on change means a bridge reconnect self-heals without extra handshake logic. + +Max players comes from `CustomNetworkManager.slots`, matching what the game server itself +advertises. + +### SiteLink.API changes + +- `MsgPlayerCount = 17151` alongside the existing `MsgTargetServersList = 17150`. +- `SendTargetServersList` filters by `SiteLinkSettings.Singleton.ServersInSelector`, + preserving selector order, matching case-insensitively. +- `Server` gains `BridgePlayerCount` (`-1` = unknown), `BridgeMaxPlayers`, + `BridgePlayerCountUpdatedAt`, and `HasFreshBridgePlayerCount` (bridge attached **and** + updated within 30 s). +- A built-in `MsgPlayerCount` handler registered in the `NET10_0` static constructor writes + those fields; `DetachServerPeer` resets them. +- `ScpServerListHandler` prefers `HasFreshBridgePlayerCount` over `SessionsCount`, with a + one-shot warning when it falls back. + +The 30-second freshness window is deliberately longer than the 5-second report interval: +one lost UDP packet must not flip the source of truth. A bridge that dies silently degrades +to the old behaviour within 30 s instead of freezing a stale count forever. + +### release.yml changes + +- Add `mscorlib.dll` and `CommandSystem.Core.dll` to `filesToDownload`. +- Build `SiteLink.API` (net48) and `SiteLink.Bridge` (net48) with `-p:Version=$VERSION`. +- Package the net48 `SiteLink.API.dll` as `dependencies.zip` with the layout the user drops + into `LabAPI/dependencies/global`. +- Attach `SiteLink.Bridge.dll` and `dependencies.zip` to the release, and upload both as + workflow artifacts. + +## Error handling + +- `Initialize` is idempotent; a second call is a no-op. +- Handlers registered by the plugin are wrapped so a throwing handler cannot kill the + dispatch loop (`SiteLinkBridge.Dispatch` already catches). +- `Send` no-ops when disconnected; the reporter does not queue, it simply reports the + current count on the next tick after reconnect. +- Proxy-side, an unparseable `MsgPlayerCount` payload leaves the previous value untouched + and does not mark it fresh. + +## Verification + +1. CI builds `SiteLink.Bridge.dll` and `dependencies.zip`. +2. Test proxy on `7800/udp`, test game server on `7801/udp`, both isolated from production. +3. `Bridge connected!` in proxy log, `IsConnected` true in `.slbridge`. +4. `.gsh` lists only servers present in `servers_in_selector`. +5. Spawn 5 dummies → `.slbridge` shows `raw=6, dummy=5, reported=0`; the proxy's + `BridgePlayerCount` reads `0`. +6. Kill the bridge → after 30 s the proxy logs the fallback warning once and reverts to + `SessionsCount`. + +Known limitation: the headless test environment cannot connect a real game client, so +"N real players reported as N" is verified by code review and by the dummy-exclusion +observation, not by a live client. From acd04218d705cb5044fd6817042837543c9794c2 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 15:00:04 +0000 Subject: [PATCH 03/15] feat(api): report game server player count over the bridge The proxy reported Server.SessionsCount to the central servers. With two proxies in front of one game server each only sees its own sessions, so neither number is accurate - CSGD 5.6 requires that it is. Adds MsgPlayerCount (17151). The bridge plugin pushes the game server's real player count and slot count; the proxy prefers it over its own session count while it is fresh (30s), and warns once when it falls back. Also filters the target server list sent to the bridge by servers_in_selector so .gsh matches the in-game selector. --- SiteLink.API/Core/Server.cs | 46 +++++++++++++ SiteLink.API/Handlers/ScpServerListHandler.cs | 25 ++++++- SiteLink.API/SiteLinkBridge.cs | 68 ++++++++++++++++++- 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/SiteLink.API/Core/Server.cs b/SiteLink.API/Core/Server.cs index 72a6937..45f8c86 100644 --- a/SiteLink.API/Core/Server.cs +++ b/SiteLink.API/Core/Server.cs @@ -73,6 +73,52 @@ public static Server Get(string name = null, string ip = null, int port public BridgeConnection BridgeConnection { get; set; } + /// + /// How long a bridge-reported player count stays authoritative after the last update. + /// Deliberately much larger than the plugin's report interval so a single dropped UDP + /// packet does not flip the reported source of truth. + /// + public static readonly TimeSpan BridgePlayerCountTimeout = TimeSpan.FromSeconds(30); + + /// + /// The player count last reported by the bridge plugin running on this game server, or + /// -1 when the bridge has never reported one. + /// + public int BridgePlayerCount { get; private set; } = -1; + + /// + /// The slot count last reported by the bridge plugin, or -1 when unknown. + /// + public int BridgeMaxPlayers { get; private set; } = -1; + + /// + /// UTC timestamp of the last player count reported by the bridge plugin. + /// + public DateTime BridgePlayerCountUpdatedAt { get; private set; } = DateTime.MinValue; + + /// + /// Whether the bridge is attached and its last reported player count is recent enough + /// to be reported to the central servers. + /// + public bool HasFreshBridgePlayerCount => + BridgeConnection != null + && BridgePlayerCount >= 0 + && DateTime.UtcNow - BridgePlayerCountUpdatedAt < BridgePlayerCountTimeout; + + internal void SetBridgePlayerCount(int players, int maxPlayers) + { + BridgePlayerCount = players; + BridgeMaxPlayers = maxPlayers; + BridgePlayerCountUpdatedAt = DateTime.UtcNow; + } + + internal void ResetBridgePlayerCount() + { + BridgePlayerCount = -1; + BridgeMaxPlayers = -1; + BridgePlayerCountUpdatedAt = DateTime.MinValue; + } + public int SessionsCount => _sessions.Count; public Session[] GetSessionsSnapshot() => _sessions.Keys.ToArray(); diff --git a/SiteLink.API/Handlers/ScpServerListHandler.cs b/SiteLink.API/Handlers/ScpServerListHandler.cs index bb67bf9..51a85ee 100644 --- a/SiteLink.API/Handlers/ScpServerListHandler.cs +++ b/SiteLink.API/Handlers/ScpServerListHandler.cs @@ -35,6 +35,7 @@ public class ScpServerListHandler : IDisposable private bool _initialized; private bool _scheduleTokenRefresh; private bool _verifyNotice; + private bool _bridgeCountFallbackNotice; private byte _cycle; public ScpServerListHandler(CancellationToken cancellationToken) @@ -250,7 +251,29 @@ private async Task DoCycleAsync() } else { - playersStr = $"{targetServer.SessionsCount}/{targetServer.MaxSessions}"; + // Rule 5.6 of the CSGD requires the reported player count to be accurate. + // The proxy's own session count is not: with more than one proxy in front + // of the same game server each proxy only sees its own slice. When the + // bridge plugin is reporting, its number is the only correct one. + if (targetServer.HasFreshBridgePlayerCount) + { + int maxPlayers = targetServer.BridgeMaxPlayers > 0 + ? targetServer.BridgeMaxPlayers + : targetServer.MaxSessions; + + playersStr = $"{targetServer.BridgePlayerCount}/{maxPlayers}"; + _bridgeCountFallbackNotice = false; + } + else + { + if (targetServer.Settings.Bridge.Enabled && !_bridgeCountFallbackNotice) + { + _bridgeCountFallbackNotice = true; + SiteLinkLogger.Warn($"{listener.Tag} Bridge player count from server '{listener.Settings.ServerList.TakePlayerCountFromServer}' is unavailable, falling back to proxy session count. Reported numbers may be inaccurate while more than one proxy is in use."); + } + + playersStr = $"{targetServer.SessionsCount}/{targetServer.MaxSessions}"; + } } } diff --git a/SiteLink.API/SiteLinkBridge.cs b/SiteLink.API/SiteLinkBridge.cs index a2c19e3..5b36fed 100644 --- a/SiteLink.API/SiteLinkBridge.cs +++ b/SiteLink.API/SiteLinkBridge.cs @@ -26,6 +26,12 @@ private class BridgeRunner : MonoBehaviour public const ushort MsgTargetServersList = 17150; + /// + /// Sent by the game server to report how many real players it is currently hosting. + /// Payload: int playerCount, int maxPlayers. + /// + public const ushort MsgPlayerCount = 17151; + #if NET48 public static System.Collections.Generic.List TargetServers { get; private set; } = new System.Collections.Generic.List(); #endif @@ -174,6 +180,36 @@ public static void Initialize(string ip, int port, string secret) #endif #if NET10_0 + static SiteLinkBridge() + { + // Player count reporting is built into the API so that every proxy gets accurate + // numbers without requiring an extra plugin. CSGD 5.6 is not optional. + RegisterHandler(MsgPlayerCount, OnBridgePlayerCount); + } + + private static void OnBridgePlayerCount(NetPacketReader reader, Server server) + { + if (server == null) + return; + + if (reader.AvailableBytes < sizeof(int) * 2) + { + SiteLinkLogger.Warn($"{server.Tag} Bridge sent a malformed player count packet."); + return; + } + + int players = reader.GetInt(); + int maxPlayers = reader.GetInt(); + + if (players < 0 || maxPlayers < 0) + { + SiteLinkLogger.Warn($"{server.Tag} Bridge reported a negative player count ({players}/{maxPlayers}), ignoring."); + return; + } + + server.SetBridgePlayerCount(players, maxPlayers); + } + public static void AttachServerPeer(Server server, LiteNetPeer peer) { _serverPeers[server] = peer; @@ -189,11 +225,39 @@ public static void AttachServerPeer(Server server, LiteNetPeer peer) } } + /// + /// Returns the servers that should be exposed to the game server, in + /// servers_in_selector order. Falls back to every registered server when the + /// selector list is empty or unset. + /// + private static List GetSelectorServers() + { + string[] selector = SiteLinkSettings.Singleton?.ServersInSelector; + + if (selector == null || selector.Length == 0) + return Server.List; + + List result = new List(selector.Length); + + foreach (string name in selector) + { + if (string.IsNullOrWhiteSpace(name)) + continue; + + Server match = Server.Get(name: name.Trim()); + + if (match != null && !result.Contains(match)) + result.Add(match); + } + + return result; + } + public static void SendTargetServersList(Server server) { SendTo(server, MsgTargetServersList, writer => { - var servers = Server.List; + var servers = GetSelectorServers(); writer.Put(servers.Count); foreach (var s in servers) { @@ -208,6 +272,8 @@ public static bool DetachServerPeer(Server server, DisconnectInfo info) { var removed = _serverPeers.TryRemove(server, out _); + server?.ResetBridgePlayerCount(); + // Fire disconnected event BridgeDisconnectedHandler[] copy; lock (_disconnectedHandlers) copy = _disconnectedHandlers.ToArray(); From aaa3c7e1a809f63312bd7152ffce19d218df9ffd Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 15:07:55 +0000 Subject: [PATCH 04/15] feat(bridge): add SiteLink.Bridge LabAPI plugin and release it SiteLink shipped a net48 SiteLink.API.dll that exposes the bridge API but nothing called Initialize, so every server owner had to write their own plugin. release.yml could not even build it: the reference download step never fetched CommandSystem.Core.dll. SiteLink.Bridge wires the config (ip / port / secret_key) into SiteLinkBridge.Initialize, logs connection state, and reports the game server's player count every 5 seconds. The host and dummies are excluded - reporting them would send numbers matching nobody who is playing. .slbridge shows connection state, the last reported count and the raw and dummy counts behind it, which is the only way to tell a correct zero from a broken one. release.yml now builds both net48 assemblies and attaches SiteLink.Bridge.dll and dependencies.zip to the release. The net48 build no longer references the game's mscorlib.dll; the identity is ambiguous against the reference assemblies on non-Windows agents, and SiteLinkBridge.cs only uses the standard BCL anyway. --- .github/workflows/release.yml | 77 +++++++++- README.md | 105 ++++++++++++++ SiteLink.API/SiteLink.API.csproj | 9 +- SiteLink.Bridge/BridgeConfig.cs | 30 ++++ SiteLink.Bridge/BridgeStatusCommand.cs | 66 +++++++++ SiteLink.Bridge/PlayerCountReporter.cs | 181 ++++++++++++++++++++++++ SiteLink.Bridge/SiteLink.Bridge.csproj | 57 ++++++++ SiteLink.Bridge/SiteLinkBridgePlugin.cs | 98 +++++++++++++ SiteLink.sln | 6 + 9 files changed, 624 insertions(+), 5 deletions(-) create mode 100644 SiteLink.Bridge/BridgeConfig.cs create mode 100644 SiteLink.Bridge/BridgeStatusCommand.cs create mode 100644 SiteLink.Bridge/PlayerCountReporter.cs create mode 100644 SiteLink.Bridge/SiteLink.Bridge.csproj create mode 100644 SiteLink.Bridge/SiteLinkBridgePlugin.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2172d1..8798a2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,9 @@ env: PROJECT_PATH: "SiteLink/SiteLink.csproj" + BRIDGE_PROJECT_PATH: "SiteLink.Bridge/SiteLink.Bridge.csproj" + API_PROJECT_PATH: "SiteLink.API/SiteLink.API.csproj" + ASSEMBLY_NAME: "SiteLink" WINDOWS_RUNTIME: "win-x64" @@ -27,6 +30,9 @@ env: WINDOWS_OUTPUT: "${{ github.workspace }}/publish/win-x64" LINUX_OUTPUT: "${{ github.workspace }}/publish/linux-x64" + BRIDGE_OUTPUT: "${{ github.workspace }}/publish/bridge" + DEPENDENCIES_OUTPUT: "${{ github.workspace }}/publish/dependencies" + SL_REFERENCES: "${{ github.workspace }}/References" UNITY_REFERENCES: "${{ github.workspace }}/References" @@ -48,7 +54,7 @@ jobs: uses: killers0992/scpsl.downloadfiles@master with: branch: "public" - filesToDownload: "BouncyCastle.Cryptography.dll,UnityEngine.CoreModule.dll,Mirror.dll,Assembly-CSharp.dll,Unity.TextMeshPro.dll" + filesToDownload: "BouncyCastle.Cryptography.dll,UnityEngine.CoreModule.dll,Mirror.dll,Assembly-CSharp.dll,Unity.TextMeshPro.dll,CommandSystem.Core.dll" - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -120,6 +126,59 @@ jobs: -p:DebugType=None \ -p:DebugSymbols=false + - name: Build game server bridge assemblies + run: | + dotnet build "${{ env.API_PROJECT_PATH }}" \ + --configuration Release \ + --framework net48 \ + -p:Version="${{ steps.version.outputs.version }}" \ + -p:GeneratePackageOnBuild=false \ + -p:DebugType=None \ + -p:DebugSymbols=false + + dotnet build "${{ env.BRIDGE_PROJECT_PATH }}" \ + --configuration Release \ + --framework net48 \ + -p:Version="${{ steps.version.outputs.version }}" \ + -p:DebugType=None \ + -p:DebugSymbols=false + + - name: Package game server bridge assets + shell: bash + run: | + API_DLL="SiteLink.API/bin/Release/net48/SiteLink.API.dll" + BRIDGE_DLL="SiteLink.Bridge/bin/Release/net48/SiteLink.Bridge.dll" + + if [[ ! -f "$API_DLL" ]]; then + echo "::error::net48 SiteLink.API.dll was not found at: $API_DLL" + find SiteLink.API/bin -type f -name "*.dll" -print || true + exit 1 + fi + + if [[ ! -f "$BRIDGE_DLL" ]]; then + echo "::error::SiteLink.Bridge.dll was not found at: $BRIDGE_DLL" + find SiteLink.Bridge/bin -type f -name "*.dll" -print || true + exit 1 + fi + + mkdir -p "${{ env.DEPENDENCIES_OUTPUT }}" "${{ env.BRIDGE_OUTPUT }}" + + # dependencies.zip is extracted straight into LabAPI/dependencies/global, so it + # must contain nothing but the assembly the plugin needs. + cp "$API_DLL" "${{ env.DEPENDENCIES_OUTPUT }}/SiteLink.API.dll" + cp "$BRIDGE_DLL" "${{ env.BRIDGE_OUTPUT }}/SiteLink.Bridge.dll" + + DEPENDENCIES_FILE="${{ github.workspace }}/publish/dependencies.zip" + rm -f "$DEPENDENCIES_FILE" + (cd "${{ env.DEPENDENCIES_OUTPUT }}" && zip -q -X "$DEPENDENCIES_FILE" SiteLink.API.dll) + + echo "BRIDGE_FILE=${{ env.BRIDGE_OUTPUT }}/SiteLink.Bridge.dll" >> "$GITHUB_ENV" + echo "DEPENDENCIES_FILE=$DEPENDENCIES_FILE" >> "$GITHUB_ENV" + + echo "Bridge plugin: ${{ env.BRIDGE_OUTPUT }}/SiteLink.Bridge.dll" + echo "Dependencies: $DEPENDENCIES_FILE" + unzip -l "$DEPENDENCIES_FILE" + - name: Verify published applications shell: bash run: | @@ -160,6 +219,20 @@ jobs: path: ${{ env.LINUX_FILE }} if-no-files-found: error + - name: Upload bridge plugin artifact + uses: actions/upload-artifact@v4 + with: + name: SiteLink.Bridge + path: ${{ env.BRIDGE_FILE }} + if-no-files-found: error + + - name: Upload bridge dependencies artifact + uses: actions/upload-artifact@v4 + with: + name: SiteLink-dependencies + path: ${{ env.DEPENDENCIES_FILE }} + if-no-files-found: error + - name: Create GitHub release shell: bash env: @@ -171,6 +244,8 @@ jobs: "$TAG" "$WINDOWS_FILE#SiteLink.exe" "$LINUX_FILE#SiteLink" + "$BRIDGE_FILE#SiteLink.Bridge.dll" + "$DEPENDENCIES_FILE#dependencies.zip" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "SiteLink $TAG" diff --git a/README.md b/README.md index 7a2d376..61bde23 100644 --- a/README.md +++ b/README.md @@ -183,4 +183,109 @@ listeners: If server is still not visbile make sure to run central command: - ``central main public`` ( it shows your main listener on serverlist ) +# 🌉 SiteLink.Bridge (game server plugin) + +`SiteLink.Bridge` is a LabAPI plugin that connects a SCP:SL game server back to the proxy. +It is optional, but without it the proxy has to guess your player count, and with more than +one proxy in front of the same game server that guess is wrong. + +## Why you want it + +Rule 5.6 of the CSGD requires the data reported to the central servers — including the +player count — to be accurate. A proxy only knows about the sessions it is holding itself. +Run two proxies and each one reports its own slice, so neither number matches reality. +The bridge makes the game server report its own count, and the proxy uses that instead. + +Dummies and the host are never counted. + +## Installation + +1. Download `dependencies.zip` and `SiteLink.Bridge.dll` from the + [releases](https://github.com/Killers0992/SiteLink/releases) page. +2. Extract `dependencies.zip` into `LabAPI/dependencies/global` + (this is `SiteLink.API.dll`). +3. Drop `SiteLink.Bridge.dll` into `LabAPI/plugins/global` (or `LabAPI/plugins/`). +4. Start the game server once to generate + `LabAPI/configs//SiteLink.Bridge/config.yml`. + +## Game server configuration + +`LabAPI/configs//SiteLink.Bridge/config.yml`: + +```yml +# Address of the SiteLink proxy this game server should connect to. +ip: 127.0.0.1 + +# Port of the SiteLink proxy this game server should connect to. +port: 7777 + +# Must match 'secret_key' under the server's bridge settings in the proxy config. +secret_key: '---' + +# Print connection state changes and player count reports to the server console. +debug: true + +# How often, in seconds, the current player count is reported to the proxy. +player_count_report_interval: 5 + +# Report the player count to the proxy. +report_player_count: true +``` + +## Proxy configuration + +Enable the bridge on the matching server entry and use the same secret, then point the +listener's player count at that server: + +```yml +servers: +- + name: default + ip: 127.0.0.1 + port: 7777 + + bridge: + enabled: true + secret_key: '---' + +listeners: +- + name: main + server_list: + take_player_count_from_server: default +``` + +When the bridge is connected, the proxy reports the game server's count. If the bridge goes +away, the proxy warns once and falls back to its own session count after 30 seconds. + +## Commands + +| Command | Where | Description | +|---|---|---| +| `.gsh` | game server | Lists the target servers advertised by the proxy (the ones in `servers_in_selector`). | +| `.slbridge` | game server | Connection state, proxy endpoint, last reported count, raw/dummy counts and target servers. | + +## Writing your own plugin against the bridge + +`SiteLink.API.dll` is usable on its own if you would rather write your own plugin: + +```csharp +SiteLinkBridge.Initialize("127.0.0.1", 7777, "---"); + +SiteLinkBridge.RegisterConnectedHandler(() => Logger.Info("Connected")); +SiteLinkBridge.RegisterDisconnectedHandler(info => Logger.Warn($"Lost: {info.Reason}")); + +// Game server -> proxy +SiteLinkBridge.Send(1001, writer => writer.Put("hello")); + +// Proxy -> game server (on the proxy side) +SiteLinkBridge.SendTo(server, 1001, writer => writer.Put("hello")); + +// Both sides +SiteLinkBridge.RegisterHandler(1001, reader => { /* ... */ }); +``` + +Message ids `17150` (target server list) and `17151` (player count) are reserved by +SiteLink itself. + > 🧱 *SiteLink — bridging SCP:SL servers into one connected network.* diff --git a/SiteLink.API/SiteLink.API.csproj b/SiteLink.API/SiteLink.API.csproj index 4eeabe7..fa28494 100644 --- a/SiteLink.API/SiteLink.API.csproj +++ b/SiteLink.API/SiteLink.API.csproj @@ -33,6 +33,7 @@ + @@ -46,9 +47,9 @@ $(SL_REFERENCES)\Assembly-CSharp.dll - - $(SL_REFERENCES)\mscorlib.dll - + $(SL_REFERENCES)\CommandSystem.Core.dll @@ -80,7 +81,7 @@ - + diff --git a/SiteLink.Bridge/BridgeConfig.cs b/SiteLink.Bridge/BridgeConfig.cs new file mode 100644 index 0000000..7aa057d --- /dev/null +++ b/SiteLink.Bridge/BridgeConfig.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; + +namespace SiteLink.Bridge +{ + /// + /// Configuration for . + /// LabAPI serializes these with the underscored naming convention, so + /// SecretKey becomes secret_key in the YAML file. + /// + public class BridgeConfig + { + [Description("Address of the SiteLink proxy this game server should connect to.")] + public string Ip { get; set; } = "127.0.0.1"; + + [Description("Port of the SiteLink proxy this game server should connect to.")] + public int Port { get; set; } = 7777; + + [Description("Shared secret. Must match 'secret_key' under the server's bridge settings in the proxy config.")] + public string SecretKey { get; set; } = "---"; + + [Description("Print connection state changes and player count reports to the server console.")] + public bool Debug { get; set; } = true; + + [Description("How often, in seconds, the current player count is reported to the proxy. Values below 1 are clamped.")] + public float PlayerCountReportInterval { get; set; } = 5f; + + [Description("Report the player count to the proxy. Disabling this makes the proxy fall back to its own session count, which is inaccurate when more than one proxy is used.")] + public bool ReportPlayerCount { get; set; } = true; + } +} diff --git a/SiteLink.Bridge/BridgeStatusCommand.cs b/SiteLink.Bridge/BridgeStatusCommand.cs new file mode 100644 index 0000000..a858f9e --- /dev/null +++ b/SiteLink.Bridge/BridgeStatusCommand.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CommandSystem; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Reports the state of the bridge: whether it is connected, what it last told the + /// proxy, and which target servers the proxy advertised. + /// + [CommandHandler(typeof(ClientCommandHandler))] + [CommandHandler(typeof(GameConsoleCommandHandler))] + [CommandHandler(typeof(RemoteAdminCommandHandler))] + public class BridgeStatusCommand : ICommand + { + public string Command => "slbridge"; + + public string[] Aliases => new[] { "sitelinkbridge" }; + + public string Description => "Shows the SiteLink bridge connection state and the player count reported to the proxy."; + + public bool Execute(ArraySegment arguments, ICommandSender sender, out string response) + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + StringBuilder builder = new StringBuilder(); + + builder.AppendLine("SiteLink bridge status"); + builder.AppendLine($" connected: {SiteLinkBridge.IsConnected}"); + builder.AppendLine(config == null + ? " proxy: " + : $" proxy: {config.Ip}:{config.Port}"); + + int players = PlayerCountReporter.CountPlayers(out int raw, out int dummies); + + builder.AppendLine($" players now: counted={players} raw={raw} dummy={dummies}"); + builder.AppendLine($" last reported: {FormatReported()}"); + + List servers = SiteLinkBridge.TargetServers; + + if (servers == null || servers.Count == 0) + { + builder.AppendLine(" target servers: none received from the proxy"); + } + else + { + builder.AppendLine($" target servers ({servers.Count}):"); + foreach (string server in servers) + builder.AppendLine($" - {server}"); + } + + response = builder.ToString(); + return true; + } + + private static string FormatReported() + { + if (PlayerCountReporter.LastReportedCount < 0) + return "nothing reported yet"; + + return $"{PlayerCountReporter.LastReportedCount}/{PlayerCountReporter.LastReportedMax}"; + } + } +} diff --git a/SiteLink.Bridge/PlayerCountReporter.cs b/SiteLink.Bridge/PlayerCountReporter.cs new file mode 100644 index 0000000..8961fb4 --- /dev/null +++ b/SiteLink.Bridge/PlayerCountReporter.cs @@ -0,0 +1,181 @@ +using System; +using LabApi.Features.Wrappers; +using UnityEngine; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Reports how many real players this game server is hosting to the proxy. + /// + /// The proxy cannot count this itself: with more than one proxy in front of the same + /// game server, each proxy only sees the sessions it owns. CSGD 5.6 requires the number + /// reported to the central servers to be accurate, so the game server is the only + /// authority. + /// + /// + public static class PlayerCountReporter + { + private const float MinimumInterval = 1f; + + private static GameObject _tickerObject; + private static bool _running; + + /// Player count sent in the last report, or -1 if nothing was sent yet. + public static int LastReportedCount { get; private set; } = -1; + + /// Slot count sent in the last report, or -1 if nothing was sent yet. + public static int LastReportedMax { get; private set; } = -1; + + /// Number of entries in Player.List at the last count, dummies and host included. + public static int LastRawCount { get; private set; } = -1; + + /// Number of dummies excluded at the last count. + public static int LastDummyCount { get; private set; } = -1; + + public static void Start() + { + if (_running) + return; + + _running = true; + + _tickerObject = new GameObject("SiteLinkBridgeTicker"); + UnityEngine.Object.DontDestroyOnLoad(_tickerObject); + _tickerObject.AddComponent(); + } + + public static void Stop() + { + _running = false; + + if (_tickerObject != null) + { + UnityEngine.Object.Destroy(_tickerObject); + _tickerObject = null; + } + + LastReportedCount = -1; + LastReportedMax = -1; + LastRawCount = -1; + LastDummyCount = -1; + } + + internal static float Interval + { + get + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config == null) + return 5f; + + return config.PlayerCountReportInterval < MinimumInterval + ? MinimumInterval + : config.PlayerCountReportInterval; + } + } + + /// + /// Counts the players that should be reported to the central servers. + /// The host is not a player, and neither are dummies - counting them would report + /// numbers that do not correspond to anyone actually playing. + /// + public static int CountPlayers(out int raw, out int dummies) + { + raw = 0; + dummies = 0; + + int counted = 0; + + foreach (Player player in Player.List) + { + raw++; + + if (player == null || player.IsHost) + continue; + + if (player.IsDummy) + { + dummies++; + continue; + } + + counted++; + } + + return counted; + } + + /// + /// Counts the current players and pushes them to the proxy. Sending unconditionally + /// on a timer rather than only on change means a bridge reconnect heals itself + /// without any extra handshake. + /// + public static void Report() + { + if (!SiteLinkBridge.IsConnected) + return; + + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config != null && !config.ReportPlayerCount) + return; + + int players = CountPlayers(out int raw, out int dummies); + int maxPlayers = GetMaxPlayers(); + + LastRawCount = raw; + LastDummyCount = dummies; + + bool changed = players != LastReportedCount || maxPlayers != LastReportedMax; + + LastReportedCount = players; + LastReportedMax = maxPlayers; + + SiteLinkBridge.Send(SiteLinkBridge.MsgPlayerCount, writer => + { + writer.Put(players); + writer.Put(maxPlayers); + }); + + if (changed && config != null && config.Debug) + SiteLinkBridgePlugin.Log($"Reported {players}/{maxPlayers} players to the proxy (excluded {dummies} dummies)."); + } + + private static int GetMaxPlayers() + { + try + { + return LabApi.Features.Wrappers.Server.MaxPlayers; + } + catch + { + return 0; + } + } + + private sealed class PlayerCountTicker : MonoBehaviour + { + private void Start() + { + float interval = Interval; + InvokeRepeating(nameof(Tick), interval, interval); + } + + private void Tick() + { + try + { + Report(); + } + catch (Exception ex) + { + // A throwing report must not kill the repeating invoke, otherwise the + // proxy silently falls back to its own inaccurate count forever. + SiteLinkBridgePlugin.LogError($"Player count report failed: {ex}"); + } + } + } + } +} diff --git a/SiteLink.Bridge/SiteLink.Bridge.csproj b/SiteLink.Bridge/SiteLink.Bridge.csproj new file mode 100644 index 0000000..3f69044 --- /dev/null +++ b/SiteLink.Bridge/SiteLink.Bridge.csproj @@ -0,0 +1,57 @@ + + + + net48 + latest + SiteLink.Bridge + SiteLink.Bridge + true + false + true + + + false + + + + SiteLink.Bridge + LabAPI plugin that connects a SCP: Secret Laboratory game server to a SiteLink proxy. + Killers0992 + + + + + + + + + + $(SL_REFERENCES)\Assembly-CSharp.dll + False + + + + $(SL_REFERENCES)\CommandSystem.Core.dll + False + + + + $(SL_REFERENCES)\Mirror.dll + False + + + + $(UNITY_REFERENCES)\UnityEngine.CoreModule.dll + False + + + + + + + + + diff --git a/SiteLink.Bridge/SiteLinkBridgePlugin.cs b/SiteLink.Bridge/SiteLinkBridgePlugin.cs new file mode 100644 index 0000000..7dca5d1 --- /dev/null +++ b/SiteLink.Bridge/SiteLinkBridgePlugin.cs @@ -0,0 +1,98 @@ +using System; +using LabApi.Features; +using LabApi.Features.Console; +using LabApi.Loader.Features.Plugins; +using LiteNetLib; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Connects this game server to a SiteLink proxy and keeps the proxy's player count + /// accurate. + /// + public class SiteLinkBridgePlugin : Plugin + { + public static SiteLinkBridgePlugin Instance { get; private set; } + + public override string Name => "SiteLink.Bridge"; + + public override string Description => "Connects this game server to a SiteLink proxy and reports its player count."; + + public override string Author => "Killers0992"; + + public override Version Version => typeof(SiteLinkBridgePlugin).Assembly.GetName().Version ?? new Version(1, 0, 0, 0); + + public override Version RequiredApiVersion => new Version(LabApiProperties.CompiledVersion); + + public override void Enable() + { + Instance = this; + + if (Config == null) + { + Logger.Error("[SiteLink.Bridge] Config is null, not starting the bridge."); + return; + } + + SiteLinkBridge.RegisterConnectedHandler(OnConnected); + SiteLinkBridge.RegisterDisconnectedHandler(OnDisconnected); + + try + { + SiteLinkBridge.Initialize(Config.Ip, Config.Port, Config.SecretKey); + } + catch (Exception ex) + { + SiteLinkBridge.UnregisterConnectedHandler(OnConnected); + SiteLinkBridge.UnregisterDisconnectedHandler(OnDisconnected); + + Logger.Error($"[SiteLink.Bridge] Failed to initialize the bridge: {ex}"); + return; + } + + PlayerCountReporter.Start(); + + Logger.Info($"[SiteLink.Bridge] Connecting to proxy {Config.Ip}:{Config.Port}..."); + } + + public override void Disable() + { + PlayerCountReporter.Stop(); + + SiteLinkBridge.UnregisterConnectedHandler(OnConnected); + SiteLinkBridge.UnregisterDisconnectedHandler(OnDisconnected); + + Instance = null; + + Logger.Info("[SiteLink.Bridge] Disabled."); + } + + private void OnConnected() + { + if (Config != null && Config.Debug) + Logger.Info($"[SiteLink.Bridge] Connected to proxy {Config.Ip}:{Config.Port}."); + + // Report immediately so the proxy is not stuck on an unknown count until the + // first heartbeat. + try + { + PlayerCountReporter.Report(); + } + catch (Exception ex) + { + Logger.Error($"[SiteLink.Bridge] Initial player count report failed: {ex}"); + } + } + + private void OnDisconnected(DisconnectInfo info) + { + if (Config != null && Config.Debug) + Logger.Warn($"[SiteLink.Bridge] Disconnected from proxy: {info.Reason}. Retrying..."); + } + + internal static void Log(string message) => Logger.Info($"[SiteLink.Bridge] {message}"); + + internal static void LogError(string message) => Logger.Error($"[SiteLink.Bridge] {message}"); + } +} diff --git a/SiteLink.sln b/SiteLink.sln index 2af1766..8d46cb8 100644 --- a/SiteLink.sln +++ b/SiteLink.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink", "SiteLink\SiteLi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink.API", "SiteLink.API\SiteLink.API.csproj", "{BA9DD9A9-19CD-7522-0667-9135C3B9776D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink.Bridge", "SiteLink.Bridge\SiteLink.Bridge.csproj", "{6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SiteLink.Generator", "SiteLink.Generator\SiteLink.Generator.csproj", "{D09039C6-3EC4-7E5C-80DE-C6453007602A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{518A6ADA-5AF3-4B43-9CAC-74D412A2C77F}" @@ -37,6 +39,10 @@ Global {BA9DD9A9-19CD-7522-0667-9135C3B9776D}.Debug|Any CPU.Build.0 = Debug|Any CPU {BA9DD9A9-19CD-7522-0667-9135C3B9776D}.Release|Any CPU.ActiveCfg = Release|Any CPU {BA9DD9A9-19CD-7522-0667-9135C3B9776D}.Release|Any CPU.Build.0 = Release|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D1F2A44-9C7E-4B10-B0A2-2C3D5E8F1A73}.Release|Any CPU.Build.0 = Release|Any CPU {D09039C6-3EC4-7E5C-80DE-C6453007602A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D09039C6-3EC4-7E5C-80DE-C6453007602A}.Debug|Any CPU.Build.0 = Debug|Any CPU {D09039C6-3EC4-7E5C-80DE-C6453007602A}.Release|Any CPU.ActiveCfg = Release|Any CPU From 4a937a3d5d15dd894a70487f46de8da93652f864 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 15:14:21 +0000 Subject: [PATCH 05/15] fix(build): restore the game's mscorlib reference for net48 Dropping it was wrong. LiteNetLib inside Assembly-CSharp exposes ReadOnlySpan overloads, and the .NET Framework 4.8 reference assemblies do not define that type, so the net48 build failed with CS0518 on every NetDataWriter/NetPacketReader call site. The game's Unity corlib is referenced explicitly again in both SiteLink.API and SiteLink.Bridge, and mscorlib.dll is downloaded by the release workflow. --- .github/workflows/release.yml | 2 +- SiteLink.API/SiteLink.API.csproj | 9 ++++++--- SiteLink.Bridge/SiteLink.Bridge.csproj | 8 ++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8798a2e..8b7d7bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: uses: killers0992/scpsl.downloadfiles@master with: branch: "public" - filesToDownload: "BouncyCastle.Cryptography.dll,UnityEngine.CoreModule.dll,Mirror.dll,Assembly-CSharp.dll,Unity.TextMeshPro.dll,CommandSystem.Core.dll" + filesToDownload: "BouncyCastle.Cryptography.dll,UnityEngine.CoreModule.dll,Mirror.dll,Assembly-CSharp.dll,Unity.TextMeshPro.dll,CommandSystem.Core.dll,mscorlib.dll" - name: Set up .NET uses: actions/setup-dotnet@v4 diff --git a/SiteLink.API/SiteLink.API.csproj b/SiteLink.API/SiteLink.API.csproj index fa28494..37c959c 100644 --- a/SiteLink.API/SiteLink.API.csproj +++ b/SiteLink.API/SiteLink.API.csproj @@ -47,9 +47,12 @@ $(SL_REFERENCES)\Assembly-CSharp.dll - + + + $(SL_REFERENCES)\mscorlib.dll + $(SL_REFERENCES)\CommandSystem.Core.dll diff --git a/SiteLink.Bridge/SiteLink.Bridge.csproj b/SiteLink.Bridge/SiteLink.Bridge.csproj index 3f69044..1cd466c 100644 --- a/SiteLink.Bridge/SiteLink.Bridge.csproj +++ b/SiteLink.Bridge/SiteLink.Bridge.csproj @@ -32,6 +32,14 @@ False + + + $(SL_REFERENCES)\mscorlib.dll + False + + $(SL_REFERENCES)\CommandSystem.Core.dll False From 1a30a9775e51b2158c32183251b1773ab37b3bb8 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 15:23:14 +0000 Subject: [PATCH 06/15] fix(build): compile net48 against the game's corlib, not the reference assemblies Microsoft.NETFramework.ReferenceAssemblies injects its own bare "mscorlib" Reference item from its .targets, which wins the simple-name dedupe against a HintPath declared in the project. The 4.8 reference corlib has no ReadOnlySpan, so every LiteNetLib call site failed with CS0518. Both net48 projects now swap that item for the game's mscorlib by full path, right before reference resolution, and fail loudly if it is missing. Verified locally against a real SCPSL_Data/Managed: dotnet build SiteLink.API -f net48 -> 0 warnings, 0 errors dotnet build SiteLink.Bridge -f net48 -> 0 warnings, 0 errors --- SiteLink.API/SiteLink.API.csproj | 27 +++++++++++++++++++------- SiteLink.Bridge/SiteLink.Bridge.csproj | 20 +++++++++++-------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/SiteLink.API/SiteLink.API.csproj b/SiteLink.API/SiteLink.API.csproj index 37c959c..96ff85e 100644 --- a/SiteLink.API/SiteLink.API.csproj +++ b/SiteLink.API/SiteLink.API.csproj @@ -47,13 +47,6 @@ $(SL_REFERENCES)\Assembly-CSharp.dll - - - $(SL_REFERENCES)\mscorlib.dll - - $(SL_REFERENCES)\CommandSystem.Core.dll @@ -84,6 +77,26 @@ + + + + + + + False + + + + diff --git a/SiteLink.Bridge/SiteLink.Bridge.csproj b/SiteLink.Bridge/SiteLink.Bridge.csproj index 1cd466c..93c1027 100644 --- a/SiteLink.Bridge/SiteLink.Bridge.csproj +++ b/SiteLink.Bridge/SiteLink.Bridge.csproj @@ -32,14 +32,6 @@ False - - - $(SL_REFERENCES)\mscorlib.dll - False - - $(SL_REFERENCES)\CommandSystem.Core.dll False @@ -62,4 +54,16 @@ + + + + + + + False + + + + From d0f315466bea5d7790eff39f4f07cb73828d3949 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 16:34:48 +0000 Subject: [PATCH 07/15] feat(bridge): multi-proxy support, round state reporting and a dedicated bridge endpoint The bridge now keeps one connection per proxy instead of assuming a single one. Endpoints are configured as a list, each with its own secret key, and the player count and round state go to every connected proxy. The target server list is the de-duplicated union of what the proxies advertise. The proxy side gained a single bridge endpoint (bridge.listen_address / bridge.listen_port). Bridges are matched to their game server by secret key, so one port serves any number of game servers. Game clients are rejected there. Round state is reported instead of inferred: waiting for players, in progress, ended, restarting (full/fast/redirect) and idle mode. RoundRestart.OnRestartTriggered and ServerEvents.RoundRestarted both feed it, because the game only clears IsRoundRestarting in a client-side hook that never runs on a dedicated server. Fixes: - Server.BridgeConnection was never assigned, so HasFreshBridgePlayerCount stayed false forever and a bridge that connected late never took over the count reported to the central servers. - servers_in_selector never reached the client. The proxy sent a competing SSSEntriesPack and the client keeps one set of entries per server, so the game server's pack overwrote it. The pack is now intercepted and the proxy's entries are appended to it; a standalone pack is only sent when the game server has none. Proxy setting ids start at Server.ProxySettingIdBase to stay out of the game server's id space, and responses in that range are answered and dropped instead of being forwarded. - The bridge runner lived on GameCore.Console's GameObject and died on the scene reload a round restart performs. It has its own DontDestroyOnLoad object now. debug defaults to false. --- README.md | 94 +++- SiteLink.API/Core/RemoteServer.cs | 19 +- SiteLink.API/Core/Server.cs | 75 +++ SiteLink.API/Handlers/ScpServerListHandler.cs | 2 +- SiteLink.API/Misc/SiteLinkSettings.cs | 7 + SiteLink.API/Models/BridgeListenerSettings.cs | 38 ++ .../Connections/BridgeConnection.cs | 5 + SiteLink.API/Networking/Listener.cs | 85 ++- SiteLink.API/Networking/Session.cs | 59 ++ SiteLink.API/SiteLinkBridge.cs | 524 +++++++++++++++--- SiteLink.Bridge/BridgeConfig.cs | 42 +- SiteLink.Bridge/BridgeStatusCommand.cs | 31 +- SiteLink.Bridge/PlayerCountReporter.cs | 54 +- SiteLink.Bridge/RoundStateReporter.cs | 297 ++++++++++ SiteLink.Bridge/SiteLinkBridgePlugin.cs | 96 +++- .../ServerSpecific/SSSClientResponse.cs | 26 + SiteLink/Services/ListenersService.cs | 16 + .../2026-07-29-sitelink-bridge-design.md | 2 +- 18 files changed, 1324 insertions(+), 148 deletions(-) create mode 100644 SiteLink.API/Models/BridgeListenerSettings.cs create mode 100644 SiteLink.Bridge/RoundStateReporter.cs create mode 100644 SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs diff --git a/README.md b/README.md index 61bde23..bf69751 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ one proxy in front of the same game server that guess is wrong. ## Why you want it -Rule 5.6 of the CSGD requires the data reported to the central servers — including the +Rule 5.6 of the CSG requires the data reported to the central servers — including the player count — to be accurate. A proxy only knows about the sessions it is holding itself. Run two proxies and each one reports its own slice, so neither number matches reality. The bridge makes the game server report its own count, and the proxy uses that instead. @@ -213,31 +213,55 @@ Dummies and the host are never counted. `LabAPI/configs//SiteLink.Bridge/config.yml`: ```yml -# Address of the SiteLink proxy this game server should connect to. -ip: 127.0.0.1 +# Proxies this game server reports to. Add one entry per proxy - the bridge keeps a +# connection to each of them and reports the same numbers to all of them. +proxies: +- ip: 127.0.0.1 + port: 7900 + secret_key: '---' + +# Print connection state changes, player count reports and round state changes to the +# server console. +debug: false + +# How often, in seconds, the current player count is reported to the proxies. +player_count_report_interval: 5 -# Port of the SiteLink proxy this game server should connect to. -port: 7777 +# Report the player count to the proxies. +report_player_count: true -# Must match 'secret_key' under the server's bridge settings in the proxy config. -secret_key: '---' +# Report round state changes (round start/end, restart, soft restart, idle mode). +report_round_state: true +``` -# Print connection state changes and player count reports to the server console. -debug: true +`port` is the proxy's **bridge** port (`bridge.listen_port` in the proxy config), not a +game client listener port. Every game server behind the same proxy connects to that one +port; the proxy tells them apart by `secret_key`. -# How often, in seconds, the current player count is reported to the proxy. -player_count_report_interval: 5 +Two proxies in front of the same game server: -# Report the player count to the proxy. -report_player_count: true +```yml +proxies: +- ip: 10.0.0.1 + port: 7900 + secret_key: 'first-proxy-secret' +- ip: 10.0.0.2 + port: 7900 + secret_key: 'second-proxy-secret' ``` ## Proxy configuration -Enable the bridge on the matching server entry and use the same secret, then point the -listener's player count at that server: +Open the bridge endpoint once, then enable the bridge on each server entry with its own +secret and point the listener's player count at that server: ```yml +# One endpoint for every bridge, no matter how many game servers you run. +bridge: + enabled: true + listen_address: 0.0.0.0 + listen_port: 7900 + servers: - name: default @@ -255,29 +279,52 @@ listeners: take_player_count_from_server: default ``` +The `secret_key` is what identifies the game server, so give every server its own. Changing +the `bridge` block requires a proxy restart; `reload` does not pick it up. + When the bridge is connected, the proxy reports the game server's count. If the bridge goes away, the proxy warns once and falls back to its own session count after 30 seconds. +## Round state + +The bridge tells the proxy what the round is doing instead of letting it infer state from +player traffic: waiting for players, in progress, ended, restarting (full, fast or +redirect) and idle mode. Round restarts triggered by `sr` (soft restart) and fast restart +are reported the same way. On the proxy side this is available as `Server.BridgeRoundState`, +`Server.BridgeRestartType`, `Server.BridgeIdleMode` and `Server.IsBridgeRestarting`, plus +the `Server.OnBridgeRoundStateChanged` override. + ## Commands | Command | Where | Description | |---|---|---| -| `.gsh` | game server | Lists the target servers advertised by the proxy (the ones in `servers_in_selector`). | -| `.slbridge` | game server | Connection state, proxy endpoint, last reported count, raw/dummy counts and target servers. | +| `.gsh` | game server | Lists the target servers advertised by the proxies (the ones in `servers_in_selector`). | +| `.slbridge` | game server | Per-proxy connection state, last reported count, raw/dummy counts, round state and target servers. | ## Writing your own plugin against the bridge `SiteLink.API.dll` is usable on its own if you would rather write your own plugin: ```csharp -SiteLinkBridge.Initialize("127.0.0.1", 7777, "---"); +// Single proxy. +SiteLinkBridge.Initialize("127.0.0.1", 7900, "---"); -SiteLinkBridge.RegisterConnectedHandler(() => Logger.Info("Connected")); -SiteLinkBridge.RegisterDisconnectedHandler(info => Logger.Warn($"Lost: {info.Reason}")); +// Or several. +SiteLinkBridge.Initialize(new[] +{ + new BridgeEndpoint("10.0.0.1", 7900, "first-proxy-secret"), + new BridgeEndpoint("10.0.0.2", 7900, "second-proxy-secret"), +}); -// Game server -> proxy +SiteLinkBridge.RegisterConnectedHandler(endpoint => Logger.Info($"Connected to {endpoint}")); +SiteLinkBridge.RegisterDisconnectedHandler((endpoint, info) => Logger.Warn($"Lost {endpoint}: {info.Reason}")); + +// Game server -> every connected proxy, returns how many it reached. SiteLinkBridge.Send(1001, writer => writer.Put("hello")); +// Game server -> one proxy. +SiteLinkBridge.SendTo(endpoint, 1001, writer => writer.Put("hello")); + // Proxy -> game server (on the proxy side) SiteLinkBridge.SendTo(server, 1001, writer => writer.Put("hello")); @@ -285,7 +332,8 @@ SiteLinkBridge.SendTo(server, 1001, writer => writer.Put("hello")); SiteLinkBridge.RegisterHandler(1001, reader => { /* ... */ }); ``` -Message ids `17150` (target server list) and `17151` (player count) are reserved by -SiteLink itself. +Message ids `17150` (target server list), `17151` (player count) and `17152` (round state) +are reserved by SiteLink itself. + > 🧱 *SiteLink — bridging SCP:SL servers into one connected network.* diff --git a/SiteLink.API/Core/RemoteServer.cs b/SiteLink.API/Core/RemoteServer.cs index 9998b30..160b62e 100644 --- a/SiteLink.API/Core/RemoteServer.cs +++ b/SiteLink.API/Core/RemoteServer.cs @@ -19,7 +19,9 @@ public ServerSpecificSettingBase[] ServerSettings new SSGroupHeader("Servers"), }; - int id = 0; + // Ids start at the proxy range so they never collide with whatever the game + // server or its plugins registered - the client keeps a single flat id space. + int id = ProxySettingIdBase; foreach (string server in SiteLinkSettings.Singleton.ServersInSelector) { Server target = Get(name: server); @@ -40,9 +42,22 @@ public ServerSpecificSettingBase[] ServerSettings public RemoteServer(string name) : base(name) { } + /// + /// Appended to the entries pack the game server sends. Rewriting the game server's own + /// pack is the only way both sets survive: the client stores one collection per server, + /// so sending a competing pack would simply overwrite whichever arrived first. + /// + public override ServerSpecificSettingBase[] GetExtraServerSpecificEntries(Session session) => ServerSettings; + public override void OnSessionSpawned(Session session) { - //session.Connection?.AsServer.ServerSpecificEntries(ServerSettings); + // Vanilla servers and servers without a single server-specific setting never send an + // entries pack, so there is nothing to append to and the selector has to be sent on + // its own. + if (session.HasGameServerSettings) + return; + + session.Connection?.AsServer.ServerSpecificEntries(ServerSettings); } public override void OnSessionSSSReponse(Session session, int id) diff --git a/SiteLink.API/Core/Server.cs b/SiteLink.API/Core/Server.cs index 45f8c86..de56ea5 100644 --- a/SiteLink.API/Core/Server.cs +++ b/SiteLink.API/Core/Server.cs @@ -2,11 +2,23 @@ using SiteLink.API.Events; using SiteLink.API.Events.Args; using SiteLink.API.Networking.Connections; +using UserSettings.ServerSpecific; namespace SiteLink.API.Core; public class Server { + /// + /// First setting id the proxy uses for its own server-specific settings entries. + /// + /// Ids are a flat namespace shared with whatever the game server and its plugins + /// define, and the game derives unspecified ids from a label hash, so low sequential + /// numbers collide easily. Everything below this value is treated as belonging to the + /// game server and forwarded untouched. + /// + /// + public const int ProxySettingIdBase = 1_500_000_000; + public static Dictionary RegisteredServers = new Dictionary(); public static List List { get; set; } = new List(); @@ -119,6 +131,57 @@ internal void ResetBridgePlayerCount() BridgePlayerCountUpdatedAt = DateTime.MinValue; } + /// + /// Round state as last reported by the bridge. + /// while no bridge is attached. + /// + public BridgeRoundState BridgeRoundState { get; private set; } = BridgeRoundState.Unknown; + + /// + /// The kind of restart the game server is currently performing, as reported by the + /// bridge. while it is not restarting. + /// + public BridgeRestartType BridgeRestartType { get; private set; } = BridgeRestartType.None; + + /// + /// Whether the game server has entered idle mode, as reported by the bridge. + /// + public bool BridgeIdleMode { get; private set; } + + /// + /// UTC timestamp of the last round state reported by the bridge. + /// + public DateTime BridgeRoundStateUpdatedAt { get; private set; } = DateTime.MinValue; + + /// + /// Whether the game server is restarting right now. Unlike the old inference from a + /// session's RoundRestartMessage, this stays correct on an empty server. + /// + public bool IsBridgeRestarting => BridgeRoundState == BridgeRoundState.Restarting; + + internal void SetBridgeRoundState(BridgeRoundState state, BridgeRestartType restartType, bool idle) + { + bool changed = BridgeRoundState != state + || BridgeRestartType != restartType + || BridgeIdleMode != idle; + + BridgeRoundState = state; + BridgeRestartType = restartType; + BridgeIdleMode = idle; + BridgeRoundStateUpdatedAt = DateTime.UtcNow; + + if (changed) + OnBridgeRoundStateChanged(state, restartType, idle); + } + + internal void ResetBridgeRoundState() + { + BridgeRoundState = BridgeRoundState.Unknown; + BridgeRestartType = BridgeRestartType.None; + BridgeIdleMode = false; + BridgeRoundStateUpdatedAt = DateTime.MinValue; + } + public int SessionsCount => _sessions.Count; public Session[] GetSessionsSnapshot() => _sessions.Keys.ToArray(); @@ -232,6 +295,18 @@ public virtual void OnSessionReady(Session session) { } public virtual void OnSessionAddPlayer(Session session) { } + /// + /// Called when the bridge reports a change of round state on the game server. + /// + public virtual void OnBridgeRoundStateChanged(BridgeRoundState state, BridgeRestartType restartType, bool idle) { } + + /// + /// Extra server-specific settings this server wants appended to the entries the game + /// server sends to the given session. Returning null or an empty array leaves the game + /// server's own settings untouched. + /// + public virtual ServerSpecificSettingBase[] GetExtraServerSpecificEntries(Session session) => null; + public virtual void OnUpdate() { } public void Destroy() diff --git a/SiteLink.API/Handlers/ScpServerListHandler.cs b/SiteLink.API/Handlers/ScpServerListHandler.cs index 51a85ee..e9c1e41 100644 --- a/SiteLink.API/Handlers/ScpServerListHandler.cs +++ b/SiteLink.API/Handlers/ScpServerListHandler.cs @@ -251,7 +251,7 @@ private async Task DoCycleAsync() } else { - // Rule 5.6 of the CSGD requires the reported player count to be accurate. + // Rule 5.6 of the CSG requires the reported player count to be accurate. // The proxy's own session count is not: with more than one proxy in front // of the same game server each proxy only sees its own slice. When the // bridge plugin is reporting, its number is the only correct one. diff --git a/SiteLink.API/Misc/SiteLinkSettings.cs b/SiteLink.API/Misc/SiteLinkSettings.cs index a9e42f5..727a38b 100644 --- a/SiteLink.API/Misc/SiteLinkSettings.cs +++ b/SiteLink.API/Misc/SiteLinkSettings.cs @@ -108,6 +108,13 @@ public static void Save() new ListenerSettings() }; + /// + /// The single endpoint every bridge plugin connects to. Bridges are matched to their game + /// server by secret key, so one endpoint serves every server. + /// + [Description("Dedicated endpoint every bridge plugin connects to, regardless of the number of game servers.")] + public BridgeListenerSettings Bridge { get; set; } = new BridgeListenerSettings(); + /// /// The list of backend servers managed by SiteLink. /// These entries define connection targets and display names for each proxied server. diff --git a/SiteLink.API/Models/BridgeListenerSettings.cs b/SiteLink.API/Models/BridgeListenerSettings.cs new file mode 100644 index 0000000..b2e1b10 --- /dev/null +++ b/SiteLink.API/Models/BridgeListenerSettings.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; + +namespace SiteLink.API.Models; + +/// +/// Configuration of the single UDP endpoint every bridge plugin connects to. +/// +/// Bridges are routed to their game server by the secret key they present, not by the port +/// they connected to, so any number of game servers share this one endpoint. +/// +/// +public class BridgeListenerSettings +{ + /// + /// Whether the proxy opens a dedicated endpoint for bridge plugins. + /// + [Description("Opens a dedicated endpoint that every bridge plugin connects to.")] + public bool Enabled { get; set; } = true; + + /// + /// The internal name of the bridge listener, used in logs. + /// + [Description("Internal identifier for the bridge listener, used in logs.")] + public string Name { get; set; } = "bridge"; + + /// + /// The IP address the bridge endpoint binds to. Use 0.0.0.0 for every interface. + /// + [Description("Local IP address the bridge endpoint binds to (use 0.0.0.0 to bind to all interfaces).")] + public string ListenAddress { get; set; } = "0.0.0.0"; + + /// + /// The UDP port bridge plugins connect to. Every game server uses this same port and is + /// told apart by its secret key. + /// + [Description("UDP port every bridge plugin connects to, regardless of how many game servers there are.")] + public int ListenPort { get; set; } = 7900; +} diff --git a/SiteLink.API/Networking/Connections/BridgeConnection.cs b/SiteLink.API/Networking/Connections/BridgeConnection.cs index ea06a7f..8222e9f 100644 --- a/SiteLink.API/Networking/Connections/BridgeConnection.cs +++ b/SiteLink.API/Networking/Connections/BridgeConnection.cs @@ -17,6 +17,11 @@ public override void ReceiveDataFromListener(int length, NetPacketReader reader, public override void Disconnected() { + // Only clear the back-reference when it still points at us: a bridge that + // reconnects before the old connection is torn down has already replaced it. + if (TargetServer != null && ReferenceEquals(TargetServer.BridgeConnection, this)) + TargetServer.BridgeConnection = null; + SiteLinkBridge.DetachServerPeer(TargetServer, new DisconnectInfo()); } } diff --git a/SiteLink.API/Networking/Listener.cs b/SiteLink.API/Networking/Listener.cs index 75cae86..08da5e9 100644 --- a/SiteLink.API/Networking/Listener.cs +++ b/SiteLink.API/Networking/Listener.cs @@ -5,6 +5,7 @@ using SiteLink.API.Networking.Connections; using SiteLink.API.Threading; using System.Buffers; +using UserSettings.ServerSpecific; using Extensions = SiteLink.API.Misc.Extensions; namespace SiteLink.API.Networking; @@ -47,6 +48,7 @@ public static List List private NetManager _manager; private EventBasedNetListener _listener; private int _index = -1; + private readonly ListenerSettings _customSettings; private readonly ConcurrentQueue _connectionsToRemove = new(); private readonly Timer _connectionCleanupTimer; @@ -65,6 +67,9 @@ public ListenerSettings Settings { get { + if (_customSettings != null) + return _customSettings; + if (_index == -1) { _index = SiteLinkSettings.Singleton.Listeners.FindIndex(x => x.Name == Name); @@ -115,10 +120,43 @@ public Version GameVersion readonly NetDataWriter RequestWriter = new NetDataWriter(); - public Listener(string name) + /// + /// Whether this listener only accepts bridge plugins. A bridge listener never serves game + /// clients and never publishes itself to the SCP:SL server list. + /// + public bool IsBridgeOnly { get; } + + public Listener(string name) : this(name, null, false) { } + + /// + /// Creates the single endpoint every bridge plugin connects to. Which game server a bridge + /// belongs to is decided by its secret key, so one endpoint is enough no matter how many + /// game servers are configured. + /// + public Listener(BridgeListenerSettings bridgeSettings) : this( + bridgeSettings.Name, + new ListenerSettings + { + Name = bridgeSettings.Name, + ListenAddress = bridgeSettings.ListenAddress, + ListenPort = bridgeSettings.ListenPort, + Priorities = [], + ServerList = new ServerListSettings + { + // A bridge endpoint has no game clients to advertise. + ShowServerOnServerList = false, + }, + }, + true) + { } + + private Listener(string name, ListenerSettings customSettings, bool isBridgeOnly) { Name = name; + _customSettings = customSettings; + IsBridgeOnly = isBridgeOnly; + _listener = new EventBasedNetListener(); _listener.ConnectionRequestEvent += OnConnectionRequest; _listener.NetworkReceiveEvent += OnNetworkReceive; @@ -154,6 +192,14 @@ public Listener(string name) Task.Run(() => RunEventPolling(Token), Token); + if (IsBridgeOnly) + { + SiteLinkLogger.Info($"{Tag} Listening for bridges on (f=green){ListenAddress}:{ListenPort}(f=white), every game server is matched by its bridge secret key"); + + EventManager.Listener.InvokeListenerRegistered(new ListenerRegisteredEvent(this)); + return; + } + SiteLinkLogger.Info($"{Tag} Listening for clients on (f=green){ListenAddress}:{ListenPort}(f=white), allow clients with game version (f=green){Settings.GameVersion.ParseVersion()}(f=white)"); EventManager.Listener.InvokeListenerRegistered(new ListenerRegisteredEvent(this)); @@ -254,11 +300,28 @@ static InterceptResult OnReady(ushort id, NetworkReader r, ArraySegment or static InterceptResult OnSSSClientResponse(ushort id, NetworkReader r, ArraySegment original, Session session) { - //SSSClientResponse response = new SSSClientResponse(r); + SSSClientResponse response; - //session.Server?.OnSessionSSSReponse(session, response.Id); + try + { + response = new SSSClientResponse(r); + } + catch (Exception ex) + { + SiteLinkLogger.Warn($"{session?.Connection?.Tag} Malformed server-specific settings response: {ex.Message}"); + return InterceptResult.Pass(); + } - return InterceptResult.Pass(); + // Everything below the proxy's id range belongs to the game server's own settings and + // has to reach it untouched. + if (response.Id < Server.ProxySettingIdBase) + return InterceptResult.Pass(); + + session?.Server?.OnSessionSSSReponse(session, response.Id); + + // The game server has no setting with this id, so forwarding it would only make it + // allocate a placeholder setting for a control it does not own. + return InterceptResult.Drop(); } static InterceptResult OnPosition(ushort id, NetworkReader r, ArraySegment original, Session session) @@ -493,6 +556,14 @@ void OnConnectionRequest(ConnectionRequest request) return; } + // The bridge endpoint is not a game endpoint. Anything that is not a bridge gets + // rejected here instead of being routed to a backend server by accident. + if (IsBridgeOnly && preAuth.ClientType != ClientType.Bridge) + { + request.RejectWithReason(RequestWriter, RejectionReason.VerificationRejected); + return; + } + switch (preAuth.ClientType) { case ClientType.Bridge: @@ -501,6 +572,12 @@ void OnConnectionRequest(ConnectionRequest request) LiteNetPeer peer = bridgeConnection.AcceptRequest(); bridgeConnection.TargetServer = preAuth.TargetServer; + + // Without this the server never knows it has a bridge, so + // HasFreshBridgePlayerCount stays false forever and the listener silently + // keeps reporting its own session count to the central servers. + preAuth.TargetServer.BridgeConnection = bridgeConnection; + SiteLinkBridge.AttachServerPeer(preAuth.TargetServer, peer); SiteLinkLogger.Info($"{bridgeConnection.Tag} {preAuth.TargetServer.Tag} Bridge connected!"); diff --git a/SiteLink.API/Networking/Session.cs b/SiteLink.API/Networking/Session.cs index e9527cf..9b5de7e 100644 --- a/SiteLink.API/Networking/Session.cs +++ b/SiteLink.API/Networking/Session.cs @@ -6,6 +6,7 @@ using SiteLink.Core; using System.Buffers; using RoundRestarting; +using UserSettings.ServerSpecific; namespace SiteLink.API.Networking { @@ -305,6 +306,64 @@ public Session(RemoteConnection connection, Server[] servers, int ownerThreadId, _serverToClient.Register(NetworkMessages.NetworkPingMessage, OnPing); _serverToClient.Register(NetworkMessages.SpawnMessage, OnSpawn); _serverToClient.Register(NetworkMessages.RoundRestartMessage, OnRestart); + _serverToClient.Register(NetworkMessages.SSSEntriesPack, OnServerSpecificEntries); + } + + /// + /// True once the game server has sent its own server-specific settings for this + /// session. Until then there is nothing to append to. + /// + public bool HasGameServerSettings { get; private set; } + + /// + /// Appends the proxy's own server-specific settings to the pack the game server + /// sends. + /// + /// Sending a separate pack does not work: the client keeps one set of entries per + /// server, so whichever pack arrives last wins and the other side's settings vanish. + /// The entries the game server wrote are copied through verbatim - they never need to + /// be deserialized, only counted. + /// + /// + private InterceptResult OnServerSpecificEntries(ushort id, NetworkReader reader, ArraySegment original, Session session) + { + session.HasGameServerSettings = true; + + ServerSpecificSettingBase[] extra = session.Server?.GetExtraServerSpecificEntries(session); + + if (extra == null || extra.Length == 0) + return InterceptResult.Pass(); + + if (reader.Remaining < sizeof(int) + sizeof(byte)) + return InterceptResult.Pass(); + + int version = reader.ReadInt(); + int count = reader.ReadByte(); + + if (count + extra.Length > byte.MaxValue) + { + SiteLinkLogger.Warn($"{Connection?.Tag} Cannot append {extra.Length} server-specific entries: the game server already sent {count} and the wire format caps the total at {byte.MaxValue}."); + return InterceptResult.Pass(); + } + + ArraySegment gameServerEntries = reader.ReadBytesSegment(reader.Remaining); + + NetworkWriter writer = new NetworkWriter(); + + writer.WriteUShort(NetworkMessages.SSSEntriesPack); + writer.WriteInt(version); + writer.WriteByte((byte)(count + extra.Length)); + + if (gameServerEntries.Count > 0) + writer.WriteBytes(gameServerEntries.Array, gameServerEntries.Offset, gameServerEntries.Count); + + foreach (ServerSpecificSettingBase setting in extra) + { + writer.WriteByte(ServerSpecificSettingsSync.GetCodeFromType(setting.GetType())); + setting.SerializeEntry(writer); + } + + return InterceptResult.Replace(writer.ToArraySegment()); } private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment original, Session session) diff --git a/SiteLink.API/SiteLinkBridge.cs b/SiteLink.API/SiteLinkBridge.cs index 5b36fed..1cf09ae 100644 --- a/SiteLink.API/SiteLinkBridge.cs +++ b/SiteLink.API/SiteLinkBridge.cs @@ -12,6 +12,61 @@ NetPacketReader reader #endif ); +/// +/// Round state as reported by the game server itself. +/// +/// The proxy previously had to infer this from the RoundRestartMessage of whichever +/// session happened to be attached, which only works while somebody is connected. An empty +/// server restarting was invisible. +/// +/// +public enum BridgeRoundState : byte +{ + Unknown = 0, + WaitingForPlayers = 1, + InProgress = 2, + Ended = 3, + Restarting = 4, + Shutdown = 5, +} + +/// +/// How the game server is restarting. Mirrors RoundRestarting.RoundRestartType plus an +/// explicit "not restarting" value, which the game's own enum does not have. +/// +public enum BridgeRestartType : byte +{ + None = 0, + Full = 1, + + /// Fast restart, which is what sr / fast restart produce. + Fast = 2, + + Redirect = 3, +} + +/// +/// A SiteLink proxy the game server talks to. More than one is normal: a game server sitting +/// behind two proxies has to report to both of them. +/// +public sealed class BridgeEndpoint +{ + public BridgeEndpoint(string ip, int port, string secretKey) + { + Ip = ip; + Port = port; + SecretKey = secretKey ?? string.Empty; + } + + public string Ip { get; } + + public int Port { get; } + + public string SecretKey { get; } + + public override string ToString() => $"{Ip}:{Port}"; +} + public static class SiteLinkBridge { @@ -32,8 +87,134 @@ private class BridgeRunner : MonoBehaviour /// public const ushort MsgPlayerCount = 17151; + /// + /// Sent by the game server whenever its round state changes. + /// Payload: byte , byte , bool idle. + /// + public const ushort MsgRoundState = 17152; + #if NET48 - public static System.Collections.Generic.List TargetServers { get; private set; } = new System.Collections.Generic.List(); + + private sealed class ProxyState + { + public ProxyState(BridgeEndpoint endpoint) + { + Endpoint = endpoint; + } + + public readonly BridgeEndpoint Endpoint; + + public NetPeer Peer; + public bool Connecting; + public DateTime NextRetry = DateTime.MinValue; + + /// Target servers advertised by this proxy. + public List TargetServers = new List(); + + public bool IsConnected => Peer != null && Peer.ConnectionState == ConnectionState.Connected; + } + + private static NetManager _manager; + private static EventBasedNetListener _listener; + + private static readonly List _proxies = new List(); + + /// Every proxy this game server is configured to talk to. + public static List Endpoints + { + get + { + List result = new List(); + + lock (_proxies) + { + foreach (ProxyState state in _proxies) + result.Add(state.Endpoint); + } + + return result; + } + } + + /// How many proxies are currently connected. + public static int ConnectedCount + { + get + { + int count = 0; + + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.IsConnected) + count++; + } + } + + return count; + } + } + + /// True when at least one proxy is connected. + public static bool IsConnected => ConnectedCount > 0; + + /// + /// Target servers advertised by the proxies, de-duplicated. Several proxies in front of + /// the same network normally advertise the same list. + /// + public static List TargetServers + { + get + { + List result = new List(); + + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + foreach (string server in state.TargetServers) + { + if (!result.Contains(server)) + result.Add(server); + } + } + } + + return result; + } + } + + /// Target servers advertised by one specific proxy. + public static List GetTargetServers(BridgeEndpoint endpoint) + { + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.Endpoint == endpoint) + return new List(state.TargetServers); + } + } + + return new List(); + } + + /// Whether one specific proxy is currently connected. + public static bool IsConnectedTo(BridgeEndpoint endpoint) + { + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.Endpoint == endpoint) + return state.IsConnected; + } + } + + return false; + } + #endif #if NET10_0 @@ -51,22 +232,16 @@ private class BridgeRunner : MonoBehaviour public delegate void BridgeConnectedHandler(); public delegate void BridgeDisconnectedHandler(DisconnectInfo info); - private static readonly List _connectedHandlers = new(); - private static readonly List _disconnectedHandlers = new(); - - private static NetManager _manager; - private static EventBasedNetListener _listener; + /// Raised for the specific proxy that connected. + public delegate void BridgeEndpointConnectedHandler(BridgeEndpoint endpoint); - private static NetPeer _peer => _manager?.FirstPeer; + /// Raised for the specific proxy that dropped. + public delegate void BridgeEndpointDisconnectedHandler(BridgeEndpoint endpoint, DisconnectInfo info); - private static bool _isConnecting; - private static DateTime _nextRetry; - - private static string _ip; - private static int _port; - private static string _secret; - - public static bool IsConnected => _peer != null && _peer.ConnectionState == ConnectionState.Connected; + private static readonly List _connectedHandlers = new(); + private static readonly List _disconnectedHandlers = new(); + private static readonly List _endpointConnectedHandlers = new(); + private static readonly List _endpointDisconnectedHandlers = new(); #endif #if NET10_0 @@ -109,73 +284,188 @@ public static void UnregisterDisconnectedHandler(BridgeDisconnectedHandler handl { lock (_disconnectedHandlers) _disconnectedHandlers.Remove(handler); } + + public static void RegisterConnectedHandler(BridgeEndpointConnectedHandler handler) + { + lock (_endpointConnectedHandlers) _endpointConnectedHandlers.Add(handler); + } + + public static void UnregisterConnectedHandler(BridgeEndpointConnectedHandler handler) + { + lock (_endpointConnectedHandlers) _endpointConnectedHandlers.Remove(handler); + } + + public static void RegisterDisconnectedHandler(BridgeEndpointDisconnectedHandler handler) + { + lock (_endpointDisconnectedHandlers) _endpointDisconnectedHandlers.Add(handler); + } + + public static void UnregisterDisconnectedHandler(BridgeEndpointDisconnectedHandler handler) + { + lock (_endpointDisconnectedHandlers) _endpointDisconnectedHandlers.Remove(handler); + } #endif #if NET48 + /// + /// Connects this game server to a single proxy. Kept for plugins that only ever talk to + /// one; the overload taking a list is the general form. + /// public static void Initialize(string ip, int port, string secret) + => Initialize(new[] { new BridgeEndpoint(ip, port, secret) }); + + /// + /// Connects this game server to every given proxy. Calling this again later adds the + /// endpoints that are not registered yet instead of throwing them away. + /// + public static void Initialize(System.Collections.Generic.IEnumerable endpoints) { - if (_manager != null) - return; + if (endpoints == null) + throw new ArgumentNullException(nameof(endpoints)); - _ip = ip; - _port = port; - _secret = secret; + if (_manager == null) + { + _listener = new EventBasedNetListener(); - _listener = new EventBasedNetListener(); + _listener.PeerConnectedEvent += OnPeerConnected; + _listener.PeerDisconnectedEvent += OnPeerDisconnected; + _listener.NetworkReceiveEvent += OnNetworkReceive; - _listener.PeerConnectedEvent += peer => - { - _isConnecting = false; + _manager = new NetManager(_listener); + _manager.Start(); - BridgeConnectedHandler[] copy; - lock (_connectedHandlers) copy = _connectedHandlers.ToArray(); - foreach (var h in copy) + // The ticker has to survive the scene reload a round restart performs. Hanging it + // off an existing singleton meant polling silently stopped after the first round. + GameObject runner = new GameObject("SiteLinkBridge"); + UnityEngine.Object.DontDestroyOnLoad(runner); + runner.AddComponent(); + } + + lock (_proxies) + { + foreach (BridgeEndpoint endpoint in endpoints) { - try { h(); } catch { } + if (endpoint == null || string.IsNullOrEmpty(endpoint.Ip)) + continue; + + bool exists = false; + + foreach (ProxyState state in _proxies) + { + if (state.Endpoint.Ip == endpoint.Ip && state.Endpoint.Port == endpoint.Port) + { + exists = true; + break; + } + } + + if (!exists) + _proxies.Add(new ProxyState(endpoint)); } - }; + } + } - _listener.PeerDisconnectedEvent += (peer, info) => - { - _isConnecting = false; - _nextRetry = DateTime.Now.AddSeconds(5); + private static ProxyState FindByPeer(NetPeer peer) + { + if (peer == null) + return null; - BridgeDisconnectedHandler[] copy; - lock (_disconnectedHandlers) copy = _disconnectedHandlers.ToArray(); - foreach (var h in copy) + lock (_proxies) + { + foreach (ProxyState state in _proxies) { - try { h(info); } catch { } + if (ReferenceEquals(state.Peer, peer)) + return state; } - }; + } + + return null; + } + + private static void OnPeerConnected(NetPeer peer) + { + ProxyState state = FindByPeer(peer); + + if (state == null) + return; + + state.Connecting = false; + + BridgeConnectedHandler[] copy; + lock (_connectedHandlers) copy = _connectedHandlers.ToArray(); + foreach (var h in copy) + { + try { h(); } catch { } + } - _listener.NetworkReceiveEvent += (peer, reader, channel, delivery) => + BridgeEndpointConnectedHandler[] endpointCopy; + lock (_endpointConnectedHandlers) endpointCopy = _endpointConnectedHandlers.ToArray(); + foreach (var h in endpointCopy) { - if (reader.AvailableBytes < 2) - return; + try { h(state.Endpoint); } catch { } + } + } - ushort messageId = reader.GetUShort(); - Dispatch(messageId, reader); - }; + private static void OnPeerDisconnected(NetPeer peer, DisconnectInfo info) + { + ProxyState state = FindByPeer(peer); - RegisterHandler(MsgTargetServersList, reader => + if (state == null) + return; + + state.Connecting = false; + state.Peer = null; + state.NextRetry = DateTime.Now.AddSeconds(5); + state.TargetServers = new List(); + + BridgeDisconnectedHandler[] copy; + lock (_disconnectedHandlers) copy = _disconnectedHandlers.ToArray(); + foreach (var h in copy) { - int count = reader.GetInt(); - var list = new System.Collections.Generic.List(); - for (int i = 0; i < count; i++) - { - string name = reader.GetString(); - string ip = reader.GetString(); - int port = reader.GetInt(); - list.Add($"{name} ({ip}:{port})"); - } - TargetServers = list; - }); + try { h(info); } catch { } + } - _manager = new NetManager(_listener); - _manager.Start(); + BridgeEndpointDisconnectedHandler[] endpointCopy; + lock (_endpointDisconnectedHandlers) endpointCopy = _endpointDisconnectedHandlers.ToArray(); + foreach (var h in endpointCopy) + { + try { h(state.Endpoint, info); } catch { } + } + } - if (GameCore.Console.Singleton.gameObject.GetComponent() == null) - GameCore.Console.Singleton.gameObject.AddComponent(); + private static void OnNetworkReceive(NetPeer peer, NetPacketReader reader, byte channel, DeliveryMethod delivery) + { + if (reader.AvailableBytes < 2) + return; + + ushort messageId = reader.GetUShort(); + + // 17150 carries per-proxy state, so it is read here rather than through the shared + // handler table, which has no idea which proxy a message came from. + if (messageId == MsgTargetServersList) + { + ReadTargetServersList(FindByPeer(peer), reader); + return; + } + + Dispatch(messageId, reader); + } + + private static void ReadTargetServersList(ProxyState state, NetPacketReader reader) + { + int count = reader.GetInt(); + var list = new List(); + + for (int i = 0; i < count; i++) + { + string name = reader.GetString(); + string ip = reader.GetString(); + int port = reader.GetInt(); + list.Add($"{name} ({ip}:{port})"); + } + + if (state != null) + state.TargetServers = list; } #endif @@ -183,8 +473,9 @@ public static void Initialize(string ip, int port, string secret) static SiteLinkBridge() { // Player count reporting is built into the API so that every proxy gets accurate - // numbers without requiring an extra plugin. CSGD 5.6 is not optional. + // numbers without requiring an extra plugin. CSG 5.6 is not optional. RegisterHandler(MsgPlayerCount, OnBridgePlayerCount); + RegisterHandler(MsgRoundState, OnBridgeRoundState); } private static void OnBridgePlayerCount(NetPacketReader reader, Server server) @@ -210,6 +501,24 @@ private static void OnBridgePlayerCount(NetPacketReader reader, Server server) server.SetBridgePlayerCount(players, maxPlayers); } + private static void OnBridgeRoundState(NetPacketReader reader, Server server) + { + if (server == null) + return; + + if (reader.AvailableBytes < 3) + { + SiteLinkLogger.Warn($"{server.Tag} Bridge sent a malformed round state packet."); + return; + } + + BridgeRoundState state = (BridgeRoundState)reader.GetByte(); + BridgeRestartType restartType = (BridgeRestartType)reader.GetByte(); + bool idle = reader.GetBool(); + + server.SetBridgeRoundState(state, restartType, idle); + } + public static void AttachServerPeer(Server server, LiteNetPeer peer) { _serverPeers[server] = peer; @@ -230,7 +539,7 @@ public static void AttachServerPeer(Server server, LiteNetPeer peer) /// servers_in_selector order. Falls back to every registered server when the /// selector list is empty or unset. /// - private static List GetSelectorServers() + internal static List GetSelectorServers() { string[] selector = SiteLinkSettings.Singleton?.ServersInSelector; @@ -270,9 +579,13 @@ public static void SendTargetServersList(Server server) public static bool DetachServerPeer(Server server, DisconnectInfo info) { + if (server == null) + return false; + var removed = _serverPeers.TryRemove(server, out _); - server?.ResetBridgePlayerCount(); + server.ResetBridgePlayerCount(); + server.ResetBridgeRoundState(); // Fire disconnected event BridgeDisconnectedHandler[] copy; @@ -318,34 +631,91 @@ public static void Update() if (_manager.IsRunning) _manager.PollEvents(); - if (!IsConnected && !_isConnecting && _nextRetry < DateTime.Now) + DateTime now = DateTime.Now; + + lock (_proxies) { - var writer = new NetDataWriter(); + foreach (ProxyState state in _proxies) + { + if (state.Connecting || state.IsConnected || state.NextRetry > now) + continue; + + var writer = new NetDataWriter(); + + // client type bridge = 2 + writer.Put((byte)2); + writer.Put(state.Endpoint.SecretKey); - // client type bridge = 2 - writer.Put((byte)2); - writer.Put(_secret); + state.Peer = _manager.Connect(state.Endpoint.Ip, state.Endpoint.Port, writer); + state.Connecting = state.Peer != null; - _manager.Connect(_ip, _port, writer); - _isConnecting = true; + // A Connect that never produced a peer (unresolvable host, for instance) must + // not turn into a busy loop hammering DNS every frame. + if (state.Peer == null) + state.NextRetry = now.AddSeconds(5); + } } } #endif #if NET48 - public static void Send( + /// + /// Sends a message to every connected proxy and returns how many of them received it. + /// + public static int Send( ushort messageId, Action payload, DeliveryMethod method = DeliveryMethod.ReliableOrdered) { - if (!IsConnected) - return; + NetDataWriter writer = null; + int sent = 0; - var writer = new NetDataWriter(); - writer.Put(messageId); - payload?.Invoke(writer); + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (!state.IsConnected) + continue; + + if (writer == null) + { + writer = new NetDataWriter(); + writer.Put(messageId); + payload?.Invoke(writer); + } + + state.Peer.Send(writer, method); + sent++; + } + } + + return sent; + } + + /// Sends a message to one specific proxy. + public static bool SendTo( + BridgeEndpoint endpoint, + ushort messageId, + Action payload, + DeliveryMethod method = DeliveryMethod.ReliableOrdered) + { + lock (_proxies) + { + foreach (ProxyState state in _proxies) + { + if (state.Endpoint != endpoint || !state.IsConnected) + continue; + + var writer = new NetDataWriter(); + writer.Put(messageId); + payload?.Invoke(writer); + + state.Peer.Send(writer, method); + return true; + } + } - _peer.Send(writer, method); + return false; } #endif diff --git a/SiteLink.Bridge/BridgeConfig.cs b/SiteLink.Bridge/BridgeConfig.cs index 7aa057d..61108fb 100644 --- a/SiteLink.Bridge/BridgeConfig.cs +++ b/SiteLink.Bridge/BridgeConfig.cs @@ -1,30 +1,48 @@ +using System.Collections.Generic; using System.ComponentModel; namespace SiteLink.Bridge { /// - /// Configuration for . - /// LabAPI serializes these with the underscored naming convention, so - /// SecretKey becomes secret_key in the YAML file. + /// A single SiteLink proxy this game server should connect to. /// - public class BridgeConfig + public class ProxyEntry { - [Description("Address of the SiteLink proxy this game server should connect to.")] + [Description("Address of the SiteLink proxy.")] public string Ip { get; set; } = "127.0.0.1"; - [Description("Port of the SiteLink proxy this game server should connect to.")] - public int Port { get; set; } = 7777; + [Description("Bridge port of the SiteLink proxy. This is the proxy's dedicated bridge endpoint, not a game client listener port - every game server behind that proxy uses the same one.")] + public int Port { get; set; } = 7900; - [Description("Shared secret. Must match 'secret_key' under the server's bridge settings in the proxy config.")] + [Description("Shared secret. Must match 'secret_key' under this server's bridge settings in that proxy's config.")] public string SecretKey { get; set; } = "---"; - [Description("Print connection state changes and player count reports to the server console.")] - public bool Debug { get; set; } = true; + public override string ToString() => $"{Ip}:{Port}"; + } - [Description("How often, in seconds, the current player count is reported to the proxy. Values below 1 are clamped.")] + /// + /// Configuration for . + /// LabAPI serializes these with the underscored naming convention, so + /// SecretKey becomes secret_key in the YAML file. + /// + public class BridgeConfig + { + [Description("Every SiteLink proxy this game server sits behind. Each one gets the player count and round state, because a proxy only ever sees the sessions it owns itself.")] + public List Proxies { get; set; } = new List + { + new ProxyEntry(), + }; + + [Description("Print connection state changes, player count reports and round state reports to the server console.")] + public bool Debug { get; set; } = false; + + [Description("How often, in seconds, the current player count is reported to the proxies. Values below 1 are clamped.")] public float PlayerCountReportInterval { get; set; } = 5f; - [Description("Report the player count to the proxy. Disabling this makes the proxy fall back to its own session count, which is inaccurate when more than one proxy is used.")] + [Description("Report the player count to the proxies. Disabling this makes a proxy fall back to its own session count, which is inaccurate when more than one proxy is used.")] public bool ReportPlayerCount { get; set; } = true; + + [Description("Report round state (waiting, in progress, ended, restarting, idle) to the proxies instead of leaving them to guess it from relayed traffic.")] + public bool ReportRoundState { get; set; } = true; } } diff --git a/SiteLink.Bridge/BridgeStatusCommand.cs b/SiteLink.Bridge/BridgeStatusCommand.cs index a858f9e..736978d 100644 --- a/SiteLink.Bridge/BridgeStatusCommand.cs +++ b/SiteLink.Bridge/BridgeStatusCommand.cs @@ -7,8 +7,8 @@ namespace SiteLink.Bridge { /// - /// Reports the state of the bridge: whether it is connected, what it last told the - /// proxy, and which target servers the proxy advertised. + /// Reports the state of the bridge: which proxies are connected, what it last told them, + /// and which target servers they advertised. /// [CommandHandler(typeof(ClientCommandHandler))] [CommandHandler(typeof(GameConsoleCommandHandler))] @@ -19,30 +19,41 @@ public class BridgeStatusCommand : ICommand public string[] Aliases => new[] { "sitelinkbridge" }; - public string Description => "Shows the SiteLink bridge connection state and the player count reported to the proxy."; + public string Description => "Shows the SiteLink bridge connection state, the player count and the round state reported to the proxies."; public bool Execute(ArraySegment arguments, ICommandSender sender, out string response) { - BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; - StringBuilder builder = new StringBuilder(); + List endpoints = SiteLinkBridge.Endpoints; + builder.AppendLine("SiteLink bridge status"); - builder.AppendLine($" connected: {SiteLinkBridge.IsConnected}"); - builder.AppendLine(config == null - ? " proxy: " - : $" proxy: {config.Ip}:{config.Port}"); + builder.AppendLine($" connected: {SiteLinkBridge.ConnectedCount}/{endpoints.Count} proxies"); + + if (endpoints.Count == 0) + { + builder.AppendLine(" proxies: "); + } + else + { + builder.AppendLine(" proxies:"); + foreach (BridgeEndpoint endpoint in endpoints) + { + builder.AppendLine($" - {endpoint}: {(SiteLinkBridge.IsConnectedTo(endpoint) ? "connected" : "disconnected")}"); + } + } int players = PlayerCountReporter.CountPlayers(out int raw, out int dummies); builder.AppendLine($" players now: counted={players} raw={raw} dummy={dummies}"); builder.AppendLine($" last reported: {FormatReported()}"); + builder.AppendLine($" round state: {RoundStateReporter.LastState} (restart: {RoundStateReporter.LastRestartType}, idle: {RoundStateReporter.LastIdle})"); List servers = SiteLinkBridge.TargetServers; if (servers == null || servers.Count == 0) { - builder.AppendLine(" target servers: none received from the proxy"); + builder.AppendLine(" target servers: none received from the proxies"); } else { diff --git a/SiteLink.Bridge/PlayerCountReporter.cs b/SiteLink.Bridge/PlayerCountReporter.cs index 8961fb4..6cf78af 100644 --- a/SiteLink.Bridge/PlayerCountReporter.cs +++ b/SiteLink.Bridge/PlayerCountReporter.cs @@ -6,10 +6,10 @@ namespace SiteLink.Bridge { /// - /// Reports how many real players this game server is hosting to the proxy. + /// Reports how many real players this game server is hosting to every connected proxy. /// - /// The proxy cannot count this itself: with more than one proxy in front of the same - /// game server, each proxy only sees the sessions it owns. CSGD 5.6 requires the number + /// A proxy cannot count this itself: with more than one proxy in front of the same game + /// server, each proxy only sees the sessions it owns. CSG 5.6 requires the number /// reported to the central servers to be accurate, so the game server is the only /// authority. /// @@ -108,9 +108,9 @@ public static int CountPlayers(out int raw, out int dummies) } /// - /// Counts the current players and pushes them to the proxy. Sending unconditionally - /// on a timer rather than only on change means a bridge reconnect heals itself - /// without any extra handshake. + /// Counts the current players and pushes them to every connected proxy. Sending + /// unconditionally on a timer rather than only on change means a bridge reconnect + /// heals itself without any extra handshake. /// public static void Report() { @@ -140,7 +140,37 @@ public static void Report() }); if (changed && config != null && config.Debug) - SiteLinkBridgePlugin.Log($"Reported {players}/{maxPlayers} players to the proxy (excluded {dummies} dummies)."); + SiteLinkBridgePlugin.Log($"Reported {players}/{maxPlayers} players to the proxies (excluded {dummies} dummies)."); + } + + /// + /// Reports the current count to one specific proxy. Used when a proxy connects late: + /// the other proxies are already up to date and must not be told again just to catch + /// this one up. + /// + public static void ReportTo(BridgeEndpoint endpoint) + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config != null && !config.ReportPlayerCount) + return; + + int players = CountPlayers(out int raw, out int dummies); + int maxPlayers = GetMaxPlayers(); + + LastRawCount = raw; + LastDummyCount = dummies; + LastReportedCount = players; + LastReportedMax = maxPlayers; + + SiteLinkBridge.SendTo(endpoint, SiteLinkBridge.MsgPlayerCount, writer => + { + writer.Put(players); + writer.Put(maxPlayers); + }); + + if (config != null && config.Debug) + SiteLinkBridgePlugin.Log($"Reported {players}/{maxPlayers} players to {endpoint} (excluded {dummies} dummies)."); } private static int GetMaxPlayers() @@ -175,6 +205,16 @@ private void Tick() // proxy silently falls back to its own inaccurate count forever. SiteLinkBridgePlugin.LogError($"Player count report failed: {ex}"); } + + try + { + // Idle mode has no event to hook, so it rides along on this timer. + RoundStateReporter.Poll(); + } + catch (Exception ex) + { + SiteLinkBridgePlugin.LogError($"Round state report failed: {ex}"); + } } } } diff --git a/SiteLink.Bridge/RoundStateReporter.cs b/SiteLink.Bridge/RoundStateReporter.cs new file mode 100644 index 0000000..9bfafd8 --- /dev/null +++ b/SiteLink.Bridge/RoundStateReporter.cs @@ -0,0 +1,297 @@ +using System; +using LabApi.Events.Arguments.ServerEvents; +using LabApi.Events.Handlers; +using RoundRestarting; +using SiteLink.API; + +namespace SiteLink.Bridge +{ + /// + /// Tells the proxy what the game server's round is actually doing. + /// + /// The proxy used to infer this from the RoundRestartMessage of whichever session + /// it happened to be relaying, which is a guess: an empty server restarting produced no + /// message at all, and a soft restart looked identical to nothing happening. The game + /// server knows, so the game server says so. + /// + /// + public static class RoundStateReporter + { + private static bool _running; + private static bool _subscribed; + + /// Set by the round-ended event, cleared once a new round starts or restarts. + private static bool _roundEnded; + + /// + /// Own restart flag. RoundRestart.IsRoundRestarting is only cleared by the + /// client-side hook, which a dedicated server never runs, so it is not trustworthy + /// as an "is it over yet" signal. + /// + private static bool _restarting; + + /// Restart kind captured when the restart began. + private static BridgeRestartType _restartType = BridgeRestartType.None; + + private static bool _shuttingDown; + + private static BridgeRoundState _lastState = BridgeRoundState.Unknown; + private static BridgeRestartType _lastRestartType = BridgeRestartType.None; + private static bool _lastIdle; + private static bool _sentOnce; + + /// The state sent to the proxy in the last report. + public static BridgeRoundState LastState => _lastState; + + /// The restart type sent to the proxy in the last report. + public static BridgeRestartType LastRestartType => _lastRestartType; + + /// Whether the last report said the server is idling. + public static bool LastIdle => _lastIdle; + + public static void Start() + { + if (_running) + return; + + _running = true; + _roundEnded = false; + _restarting = false; + _restartType = BridgeRestartType.None; + _shuttingDown = false; + + Subscribe(); + + SiteLinkBridge.RegisterConnectedHandler(OnProxyConnected); + + // Nothing has been sent yet, so the first Report() must go out even if the + // computed state happens to equal the default. + _sentOnce = false; + Report(); + } + + public static void Stop() + { + if (!_running) + return; + + _running = false; + + SiteLinkBridge.UnregisterConnectedHandler(OnProxyConnected); + + Unsubscribe(); + + _lastState = BridgeRoundState.Unknown; + _lastRestartType = BridgeRestartType.None; + _lastIdle = false; + _sentOnce = false; + } + + private static void Subscribe() + { + if (_subscribed) + return; + + ServerEvents.WaitingForPlayers += OnWaitingForPlayers; + ServerEvents.RoundStarted += OnRoundStarted; + ServerEvents.RoundEnded += OnRoundEnded; + ServerEvents.RoundRestarted += OnRoundRestarted; + ServerEvents.Shutdown += OnShutdown; + + // Fires for every restart kind, including `sr` and fast restart, before the + // server actually tears the round down. + RoundRestart.OnRestartTriggered += OnRestartTriggered; + + _subscribed = true; + } + + private static void Unsubscribe() + { + if (!_subscribed) + return; + + ServerEvents.WaitingForPlayers -= OnWaitingForPlayers; + ServerEvents.RoundStarted -= OnRoundStarted; + ServerEvents.RoundEnded -= OnRoundEnded; + ServerEvents.RoundRestarted -= OnRoundRestarted; + ServerEvents.Shutdown -= OnShutdown; + + RoundRestart.OnRestartTriggered -= OnRestartTriggered; + + _subscribed = false; + } + + private static void OnWaitingForPlayers() + { + _roundEnded = false; + _restarting = false; + _restartType = BridgeRestartType.None; + Report(); + } + + private static void OnRoundStarted() + { + _roundEnded = false; + _restarting = false; + _restartType = BridgeRestartType.None; + Report(); + } + + private static void OnRoundEnded(RoundEndedEventArgs args) + { + _roundEnded = true; + Report(); + } + + /// + /// Despite the name, the game fires this at the very start of + /// InitiateRoundRestart, before it has torn anything down. It means "a restart + /// was requested", which is exactly the moment the proxy wants to hear about. + /// + private static void OnRoundRestarted() => BeginRestart(); + + private static void OnRestartTriggered() => BeginRestart(); + + private static void BeginRestart() + { + _roundEnded = false; + + if (!_restarting) + { + _restarting = true; + + // The game reads this flag when it decides which RoundRestartMessage to + // send, so reading it now yields the same answer the clients get. + _restartType = CustomNetworkManager.EnableFastRestart + ? BridgeRestartType.Fast + : BridgeRestartType.Full; + } + + Report(); + } + + private static void OnShutdown() + { + _shuttingDown = true; + Report(); + } + + private static void OnProxyConnected(BridgeEndpoint endpoint) + { + if (!IsEnabled()) + return; + + // A bridge that connects mid-round would otherwise leave the proxy on Unknown + // until the next round event, which on a quiet server can be a long time. + SendTo(endpoint, GetState(), GetRestartType(), IsIdle()); + } + + /// + /// Polled alongside the player count, because idle mode has no event to hook. + /// + internal static void Poll() + { + if (!_running) + return; + + Report(); + } + + /// + /// Computes the current state and pushes it to every connected proxy when it + /// changed. Restart states are always sent, since they are short-lived and missing + /// one is worse than sending it twice. + /// + public static void Report() + { + if (!IsEnabled() || !SiteLinkBridge.IsConnected) + return; + + BridgeRoundState state = GetState(); + BridgeRestartType restartType = GetRestartType(); + bool idle = IsIdle(); + + bool changed = !_sentOnce + || state != _lastState + || restartType != _lastRestartType + || idle != _lastIdle; + + if (!changed) + return; + + _lastState = state; + _lastRestartType = restartType; + _lastIdle = idle; + _sentOnce = true; + + SiteLinkBridge.Send(SiteLinkBridge.MsgRoundState, writer => + { + writer.Put((byte)state); + writer.Put((byte)restartType); + writer.Put(idle); + }); + + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + if (config != null && config.Debug) + SiteLinkBridgePlugin.Log($"Reported round state {state} (restart: {restartType}, idle: {idle}) to the proxy."); + } + + private static void SendTo(BridgeEndpoint endpoint, BridgeRoundState state, BridgeRestartType restartType, bool idle) + { + SiteLinkBridge.SendTo(endpoint, SiteLinkBridge.MsgRoundState, writer => + { + writer.Put((byte)state); + writer.Put((byte)restartType); + writer.Put(idle); + }); + } + + private static BridgeRoundState GetState() + { + if (_shuttingDown) + return BridgeRoundState.Shutdown; + + if (_restarting || RoundRestart.IsRoundRestarting) + return BridgeRoundState.Restarting; + + if (RoundSummary.RoundInProgress()) + return BridgeRoundState.InProgress; + + return _roundEnded + ? BridgeRoundState.Ended + : BridgeRoundState.WaitingForPlayers; + } + + private static BridgeRestartType GetRestartType() + { + if (_restarting || RoundRestart.IsRoundRestarting) + { + return _restartType == BridgeRestartType.None + ? (CustomNetworkManager.EnableFastRestart ? BridgeRestartType.Fast : BridgeRestartType.Full) + : _restartType; + } + + return BridgeRestartType.None; + } + + private static bool IsEnabled() + { + BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; + + return config == null || config.ReportRoundState; + } + + private static bool IsIdle() + { + try + { + return IdleMode.IdleModeActive; + } + catch (Exception) + { + return false; + } + } + } +} diff --git a/SiteLink.Bridge/SiteLinkBridgePlugin.cs b/SiteLink.Bridge/SiteLinkBridgePlugin.cs index 7dca5d1..621ddce 100644 --- a/SiteLink.Bridge/SiteLinkBridgePlugin.cs +++ b/SiteLink.Bridge/SiteLinkBridgePlugin.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using LabApi.Features; using LabApi.Features.Console; using LabApi.Loader.Features.Plugins; @@ -8,8 +9,8 @@ namespace SiteLink.Bridge { /// - /// Connects this game server to a SiteLink proxy and keeps the proxy's player count - /// accurate. + /// Connects this game server to every configured SiteLink proxy and keeps their player + /// count and round state accurate. /// public class SiteLinkBridgePlugin : Plugin { @@ -17,7 +18,7 @@ public class SiteLinkBridgePlugin : Plugin public override string Name => "SiteLink.Bridge"; - public override string Description => "Connects this game server to a SiteLink proxy and reports its player count."; + public override string Description => "Connects this game server to SiteLink proxies and reports its player count and round state."; public override string Author => "Killers0992"; @@ -35,12 +36,20 @@ public override void Enable() return; } + List endpoints = BuildEndpoints(); + + if (endpoints.Count == 0) + { + Logger.Error("[SiteLink.Bridge] No usable entries under 'proxies', not starting the bridge."); + return; + } + SiteLinkBridge.RegisterConnectedHandler(OnConnected); SiteLinkBridge.RegisterDisconnectedHandler(OnDisconnected); try { - SiteLinkBridge.Initialize(Config.Ip, Config.Port, Config.SecretKey); + SiteLinkBridge.Initialize(endpoints); } catch (Exception ex) { @@ -52,12 +61,14 @@ public override void Enable() } PlayerCountReporter.Start(); + RoundStateReporter.Start(); - Logger.Info($"[SiteLink.Bridge] Connecting to proxy {Config.Ip}:{Config.Port}..."); + Logger.Info($"[SiteLink.Bridge] Connecting to {endpoints.Count} proxy/proxies: {FormatEndpoints(endpoints)}"); } public override void Disable() { + RoundStateReporter.Stop(); PlayerCountReporter.Stop(); SiteLinkBridge.UnregisterConnectedHandler(OnConnected); @@ -68,31 +79,94 @@ public override void Disable() Logger.Info("[SiteLink.Bridge] Disabled."); } - private void OnConnected() + /// + /// Turns the configured proxy list into endpoints, dropping entries that cannot work + /// instead of letting the bridge retry against them forever. + /// + private List BuildEndpoints() + { + List endpoints = new List(); + + if (Config.Proxies == null) + return endpoints; + + foreach (ProxyEntry entry in Config.Proxies) + { + if (entry == null) + continue; + + if (string.IsNullOrEmpty(entry.Ip)) + { + Logger.Warn("[SiteLink.Bridge] Skipping a proxy entry with an empty 'ip'."); + continue; + } + + if (entry.Port <= 0 || entry.Port > 65535) + { + Logger.Warn($"[SiteLink.Bridge] Skipping proxy '{entry.Ip}': port {entry.Port} is out of range."); + continue; + } + + bool duplicate = false; + + foreach (BridgeEndpoint existing in endpoints) + { + if (existing.Ip == entry.Ip && existing.Port == entry.Port) + { + duplicate = true; + break; + } + } + + if (duplicate) + { + Logger.Warn($"[SiteLink.Bridge] Skipping duplicate proxy entry {entry}."); + continue; + } + + endpoints.Add(new BridgeEndpoint(entry.Ip, entry.Port, entry.SecretKey)); + } + + return endpoints; + } + + private static string FormatEndpoints(List endpoints) + { + string[] parts = new string[endpoints.Count]; + + for (int i = 0; i < endpoints.Count; i++) + parts[i] = endpoints[i].ToString(); + + return string.Join(", ", parts); + } + + private void OnConnected(BridgeEndpoint endpoint) { if (Config != null && Config.Debug) - Logger.Info($"[SiteLink.Bridge] Connected to proxy {Config.Ip}:{Config.Port}."); + Logger.Info($"[SiteLink.Bridge] Connected to proxy {endpoint}."); // Report immediately so the proxy is not stuck on an unknown count until the // first heartbeat. try { - PlayerCountReporter.Report(); + PlayerCountReporter.ReportTo(endpoint); } catch (Exception ex) { - Logger.Error($"[SiteLink.Bridge] Initial player count report failed: {ex}"); + Logger.Error($"[SiteLink.Bridge] Initial player count report to {endpoint} failed: {ex}"); } } - private void OnDisconnected(DisconnectInfo info) + private void OnDisconnected(BridgeEndpoint endpoint, DisconnectInfo info) { if (Config != null && Config.Debug) - Logger.Warn($"[SiteLink.Bridge] Disconnected from proxy: {info.Reason}. Retrying..."); + Logger.Warn($"[SiteLink.Bridge] Disconnected from proxy {endpoint}: {info.Reason}. Retrying..."); } internal static void Log(string message) => Logger.Info($"[SiteLink.Bridge] {message}"); + internal static void LogWarn(string message) => Logger.Warn($"[SiteLink.Bridge] {message}"); + internal static void LogError(string message) => Logger.Error($"[SiteLink.Bridge] {message}"); } } diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs new file mode 100644 index 0000000..7015b8c --- /dev/null +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSClientResponse.cs @@ -0,0 +1,26 @@ +using System; +using Mirror; + +namespace UserSettings.ServerSpecific +{ + /// + /// Client -> server answer to a server-specific setting. Mirrors the game's own struct; + /// the payload is kept raw because the proxy only needs the id to decide whether the + /// response is one of its own entries. + /// + public readonly struct SSSClientResponse + { + public readonly Type SettingType; + + public readonly int Id; + + public readonly ArraySegment Payload; + + public SSSClientResponse (NetworkReader reader) + { + SettingType = ServerSpecificSettingsSync.GetTypeFromCode (reader.ReadByte ()); + Id = reader.ReadInt (); + Payload = reader.ReadBytesSegment (reader.ReadInt ()); + } + } +} diff --git a/SiteLink/Services/ListenersService.cs b/SiteLink/Services/ListenersService.cs index f4afd39..d97b0ea 100644 --- a/SiteLink/Services/ListenersService.cs +++ b/SiteLink/Services/ListenersService.cs @@ -27,6 +27,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { new Listener(settings.Name); } + + // One endpoint for every bridge: they identify their game server with a secret + // key, so the number of game servers does not change the number of ports. + BridgeListenerSettings bridgeSettings = SiteLinkSettings.Singleton.Bridge; + + if (bridgeSettings is { Enabled: true }) + { + if (SiteLinkSettings.Singleton.Listeners.Any(x => x.ListenPort == bridgeSettings.ListenPort)) + { + SiteLinkLogger.Error($"Bridge listener port {bridgeSettings.ListenPort} is already used by a game client listener, bridge endpoint not started.", "Listener"); + } + else + { + new Listener(bridgeSettings); + } + } } catch (Exception ex) { diff --git a/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md b/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md index 4bc79e0..60b8ec3 100644 --- a/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md +++ b/docs/superpowers/specs/2026-07-29-sitelink-bridge-design.md @@ -18,7 +18,7 @@ Two consequences: 2. Player counts reported to the SCP:SL central servers come from `Server.SessionsCount` — the number of sessions *this proxy* is holding. With two proxies in front of one game server, each reports its own slice, so neither number is correct. - CSGD 5.6 requires accurate data. Northwood's guidance is to report the game server's + CSG 5.6 requires accurate data. Northwood's guidance is to report the game server's count, not the proxy's. ## Scope From 4fe7bb997adbd9e2027c93e669998fd0469e3aec Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 17:18:02 +0000 Subject: [PATCH 08/15] Explain why the proxy rejects a bridge A rejected bridge produced nothing but 'ConnectionRejected' on the game server, with every reject path on the proxy side silent, so there was no way to tell a wrong secret key from a disabled bridge. - Log the reason on the proxy, listing the servers that have the bridge enabled with their key lengths instead of their keys. - Skip servers whose Settings is null (registered by a plugin, absent from settings.yml) instead of throwing inside the lookup. - Warn on the game server when a proxy rejects the bridge, even with debug off, and when a proxy entry has an empty secret key. - Warn when a bridge lands on a game client listener instead of the dedicated bridge endpoint. --- README.md | 10 ++++++++++ SiteLink.API/Networking/Listener.cs | 7 +++++++ SiteLink.API/Structs/PreAuth.cs | 23 +++++++++++++++++++++-- SiteLink.Bridge/SiteLinkBridgePlugin.cs | 12 ++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf69751..39324a7 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,16 @@ listeners: The `secret_key` is what identifies the game server, so give every server its own. Changing the `bridge` block requires a proxy restart; `reload` does not pick it up. +If the game server logs `Proxy : rejected the bridge`, the proxy refused the +handshake and its console says why on the same line, for example: + +``` +[WARN] [Bridge] Bridge from 10.0.0.5 rejected, its secret key (length 11) matches no server with the bridge enabled: vanilla (key length 10). +``` + +The key lengths are printed instead of the keys, which is usually enough to spot a trailing +space or a mismatched value. + When the bridge is connected, the proxy reports the game server's count. If the bridge goes away, the proxy warns once and falls back to its own session count after 30 seconds. diff --git a/SiteLink.API/Networking/Listener.cs b/SiteLink.API/Networking/Listener.cs index 08da5e9..ffab7a9 100644 --- a/SiteLink.API/Networking/Listener.cs +++ b/SiteLink.API/Networking/Listener.cs @@ -560,10 +560,17 @@ void OnConnectionRequest(ConnectionRequest request) // rejected here instead of being routed to a backend server by accident. if (IsBridgeOnly && preAuth.ClientType != ClientType.Bridge) { + SiteLinkLogger.Warn($"{Tag} Rejected {preAuth.ClientType} from {connectionIpAddress}, this endpoint only accepts bridges."); + request.RejectWithReason(RequestWriter, RejectionReason.VerificationRejected); return; } + // A bridge that reaches a game listener still works, but the operator wanted the + // dedicated endpoint and should be told the plugin is aimed at the wrong port. + if (!IsBridgeOnly && preAuth.ClientType == ClientType.Bridge) + SiteLinkLogger.Warn($"{Tag} Bridge from {connectionIpAddress} connected to a game client listener. Point its 'port' at the bridge endpoint instead."); + switch (preAuth.ClientType) { case ClientType.Bridge: diff --git a/SiteLink.API/Structs/PreAuth.cs b/SiteLink.API/Structs/PreAuth.cs index b777b83..a104a77 100644 --- a/SiteLink.API/Structs/PreAuth.cs +++ b/SiteLink.API/Structs/PreAuth.cs @@ -116,18 +116,37 @@ public static bool TryRead(Networking.Listener listener, string connectionIp, Ne case ClientType.Bridge: if (!reader.TryGetString(out string secretKey)) { + SiteLinkLogger.Warn($"Bridge from {connectionIp} rejected, the handshake carried no secret key.", "Bridge"); + rejectForce = true; response = DisconnectType.ForbiddenClientType; return false; } + // Settings is null for servers a plugin registered without a settings.yml + // entry, and those must not take the whole lookup down with them. Server targetServer = Server.RegisteredServers.Values .FirstOrDefault(x => - x.Settings.Bridge.Enabled && - x.Settings.Bridge.SecretKey == secretKey); + x.Settings?.Bridge is { Enabled: true } bridge && + bridge.SecretKey == secretKey); if (targetServer == null) { + // Servers are registered twice - once by name, once by address - so the + // list has to be deduplicated before it is shown to anyone. + string[] candidates = Server.RegisteredServers.Values + .Distinct() + .Where(x => x.Settings?.Bridge?.Enabled == true) + .Select(x => $"{x.Name} (key length {x.Settings.Bridge.SecretKey?.Length ?? 0})") + .ToArray(); + + // The game server only ever sees "ConnectionRejected", so the reason has + // to be on this side. Key lengths instead of keys: enough to spot a typo, + // a stray space or a quoting accident without printing the secret. + SiteLinkLogger.Warn(candidates.Length == 0 + ? $"Bridge from {connectionIp} rejected, no server in settings.yml has 'bridge.enabled: true'." + : $"Bridge from {connectionIp} rejected, its secret key (length {secretKey.Length}) matches no server with the bridge enabled: {string.Join(", ", candidates)}.", "Bridge"); + rejectForce = true; response = DisconnectType.ForbiddenClientType; return false; diff --git a/SiteLink.Bridge/SiteLinkBridgePlugin.cs b/SiteLink.Bridge/SiteLinkBridgePlugin.cs index 621ddce..610709f 100644 --- a/SiteLink.Bridge/SiteLinkBridgePlugin.cs +++ b/SiteLink.Bridge/SiteLinkBridgePlugin.cs @@ -124,6 +124,9 @@ private List BuildEndpoints() continue; } + if (string.IsNullOrEmpty(entry.SecretKey)) + Logger.Warn($"[SiteLink.Bridge] Proxy {entry} has an empty 'secret_key'; it will reject this bridge."); + endpoints.Add(new BridgeEndpoint(entry.Ip, entry.Port, entry.SecretKey)); } @@ -159,6 +162,15 @@ private void OnConnected(BridgeEndpoint endpoint) private void OnDisconnected(BridgeEndpoint endpoint, DisconnectInfo info) { + // ConnectionRejected is always a configuration problem, never a transient one, so + // it is worth a line even with debug off - otherwise the bridge retries forever + // in silence and nobody learns why. + if (info.Reason == DisconnectReason.ConnectionRejected) + { + Logger.Warn($"[SiteLink.Bridge] Proxy {endpoint} rejected the bridge. Its 'secret_key' ({endpoint.SecretKey?.Length ?? 0} characters) has to match 'bridge.secret_key' of a server with 'bridge.enabled: true' in that proxy's settings.yml. Retrying..."); + return; + } + if (Config != null && Config.Debug) Logger.Warn($"[SiteLink.Bridge] Disconnected from proxy {endpoint}: {info.Reason}. Retrying..."); } From 14b384ee968e6941e37c4f3eff95892b4a98e0d7 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 17:59:00 +0000 Subject: [PATCH 09/15] Fix server-specific settings serialization and idle-starved player counts None of the ServerSpecificSettingBase subclasses overrode SerializeEntry, so every entry the proxy appended to the game server's SSSEntriesPack was written with only the five base fields. The client's deserializer then read the next entry's bytes as the missing ones, walked off the end of the batch and dropped the connection - which is why the settings never showed up in the player list and why joining through the proxy kicked the player off the game server. All eight subclasses now serialize their own fields in the exact order the game's own overrides use. Verified by a round-trip: the pack the proxy emits decodes cleanly and the reader is left with zero remaining bytes. Idle mode sets Time.timeScale to 0.01, and InvokeRepeating runs on scaled time, so the player count ticker's 5 second interval became 500 real seconds as soon as the server went idle. The proxy hit its 30 second bridge timeout and fell back to counting its own sessions, which looks exactly like a disconnected bridge. The ticker now runs off a Stopwatch, which idle mode cannot slow down. --- SiteLink.Bridge/PlayerCountReporter.cs | 20 ++++++++++++++----- .../UserSettings/ServerSpecific/SSButton.cs | 7 +++++++ .../ServerSpecific/SSDropdownSetting.cs | 11 ++++++++++ .../ServerSpecific/SSGroupHeader.cs | 6 ++++++ .../ServerSpecific/SSKeybindSetting.cs | 8 ++++++++ .../ServerSpecific/SSPlaintextSetting.cs | 9 +++++++++ .../ServerSpecific/SSSliderSetting.cs | 11 ++++++++++ .../UserSettings/ServerSpecific/SSTextArea.cs | 7 +++++++ .../ServerSpecific/SSTwoButtonsSetting.cs | 8 ++++++++ 9 files changed, 82 insertions(+), 5 deletions(-) diff --git a/SiteLink.Bridge/PlayerCountReporter.cs b/SiteLink.Bridge/PlayerCountReporter.cs index 6cf78af..fa19d84 100644 --- a/SiteLink.Bridge/PlayerCountReporter.cs +++ b/SiteLink.Bridge/PlayerCountReporter.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using LabApi.Features.Wrappers; using UnityEngine; using SiteLink.API; @@ -187,10 +188,19 @@ private static int GetMaxPlayers() private sealed class PlayerCountTicker : MonoBehaviour { - private void Start() + // Idle mode sets Time.timeScale to 0.01, and InvokeRepeating runs on scaled time, + // so a 5 second interval turned into 500 real seconds the moment the server went + // idle. The proxy then hit its 30 second bridge timeout and fell back to counting + // its own sessions. A stopwatch is the only clock idle mode cannot slow down. + private readonly Stopwatch _sinceLastTick = Stopwatch.StartNew(); + + private void Update() { - float interval = Interval; - InvokeRepeating(nameof(Tick), interval, interval); + if (_sinceLastTick.Elapsed.TotalSeconds < Interval) + return; + + _sinceLastTick.Restart(); + Tick(); } private void Tick() @@ -201,8 +211,8 @@ private void Tick() } catch (Exception ex) { - // A throwing report must not kill the repeating invoke, otherwise the - // proxy silently falls back to its own inaccurate count forever. + // A throwing report must not kill the ticker, otherwise the proxy + // silently falls back to its own inaccurate count forever. SiteLinkBridgePlugin.LogError($"Player count report failed: {ex}"); } diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs index f8520e8..9700c00 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSButton.cs @@ -22,6 +22,13 @@ public SSButton (int? id, string label, string buttonText, float? holdTimeSecond HoldTimeSeconds = Mathf.Max (holdTimeSeconds.GetValueOrDefault (), 0f); } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteFloat (HoldTimeSeconds); + writer.WriteString (ButtonText); + } + public override void ApplyDefaultValues () { SyncLastPress.Reset (); diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs index 5d25439..1136f38 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSDropdownSetting.cs @@ -38,6 +38,17 @@ public SSDropdownSetting (int? id, string label, string[] options, int defaultOp DefaultOptionIndex = defaultOptionIndex; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteByte ((byte)DefaultOptionIndex); + writer.WriteByte ((byte)EntryType); + writer.WriteByte ((byte)Options.Length); + foreach (string option in Options) { + writer.WriteString (option); + } + } + public override void ApplyDefaultValues () { SyncSelectionIndexRaw = DefaultOptionIndex; diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs index d9822ff..164216b 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSGroupHeader.cs @@ -19,6 +19,12 @@ public SSGroupHeader (int? id, string label, bool reducedPadding = false, string ReducedPadding = reducedPadding; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteBool (ReducedPadding); + } + public override void ApplyDefaultValues () { } diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs index ab2c864..2c887ea 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSKeybindSetting.cs @@ -24,6 +24,14 @@ public SSKeybindSetting (int? id, string label, KeyCode suggestedKey = KeyCode.N base.HintDescription = hint; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteBool (PreventInteractionOnGUI); + writer.WriteBool (AllowSpectatorTrigger); + writer.WriteInt ((int)SuggestedKey); + } + public override void ApplyDefaultValues () { SyncIsPressed = false; diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs index 14505f3..7e6e8ef 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSPlaintextSetting.cs @@ -28,6 +28,15 @@ public SSPlaintextSetting (int? id, string label, string placeholder = "...", in ContentType = contentType; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteString (DefaultText); + writer.WriteString (Placeholder); + writer.WriteUShort ((ushort)CharacterLimit); + writer.WriteByte ((byte)ContentType); + } + public override void ApplyDefaultValues () { SyncInputText = (base.IsServerOnly ? DefaultText : string.Empty); diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs index 2de5e19..604ec79 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSSliderSetting.cs @@ -38,6 +38,17 @@ public SSSliderSetting (int? id, string label, float minValue, float maxValue, f } } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteFloat (DefaultValue); + writer.WriteFloat (MinValue); + writer.WriteFloat (MaxValue); + writer.WriteBool (Integer); + writer.WriteString (ValueToStringFormat); + writer.WriteString (FinalDisplayFormat); + } + public override void ApplyDefaultValues () { SyncFloatValue = DefaultValue; diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs index 3203725..7a4ea5e 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTextArea.cs @@ -28,6 +28,13 @@ public SSTextArea (int? id, string content, FoldoutMode foldoutMode = FoldoutMod AlignmentOptions = textAlignment; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteByte ((byte)Foldout); + writer.WriteInt ((int)AlignmentOptions); + } + public override void ApplyDefaultValues () { } diff --git a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs index b39c367..241e0ab 100644 --- a/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs +++ b/SiteLink.Protocol/UserSettings/ServerSpecific/SSTwoButtonsSetting.cs @@ -25,6 +25,14 @@ public SSTwoButtonsSetting (int? id, string label, string optionA, string option base.HintDescription = hint; } + public override void SerializeEntry (NetworkWriter writer) + { + base.SerializeEntry (writer); + writer.WriteString (OptionA); + writer.WriteString (OptionB); + writer.WriteBool (DefaultIsB); + } + public override void ApplyDefaultValues () { SyncIsB = DefaultIsB; From 1b2349a528e9474cf9cce67e496b3265aa6ea81b Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 18:18:57 +0000 Subject: [PATCH 10/15] Fix batch length prefixes using the wrong variable-length encoding BatchInterceptor rewrote batches with an LEB128 length prefix, but Mirror uses the SQLite4 style scheme: everything up to 240 is a single byte, and the ranges above it are keyed off a marker byte. The two agree only below 128. Every rewritten batch whose message was 128 bytes or longer therefore went out with a two byte prefix where the client expected one. The client read the first byte as the length, took the continuation byte as message data, and every message after it in the batch was shifted by one. Mirror throws on the garbage and closes the connection without a kick reason - which is what appending the server selector to the settings pack finally made large enough to trigger. Compression.VarUIntSize, which the rewriter already used to size the output buffer, follows Mirror's scheme, so the allocation was short by a byte on top of that. The prefix is now written with Mirror's encoding. A test drives the shipped method over every length from 0 to 70000 plus every branch boundary and checks it byte for byte against Compression.CompressVarUInt, feeds the result back through Compression.DecompressVarUInt, and asserts the width matches Compression.VarUIntSize: 70039 values, all matching. --- SiteLink.API/Misc/BatchInterceptor.cs | 83 +++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/SiteLink.API/Misc/BatchInterceptor.cs b/SiteLink.API/Misc/BatchInterceptor.cs index a78b9c2..22dc75f 100644 --- a/SiteLink.API/Misc/BatchInterceptor.cs +++ b/SiteLink.API/Misc/BatchInterceptor.cs @@ -162,8 +162,8 @@ public bool TryRewrite( foreach (var seg in kept) { - // Write length prefix (varuint) - WriteVarUInt(dst, ref p, (uint)seg.Count); + // Write length prefix (Mirror's variable-length encoding) + WriteVarUInt(dst, ref p, (ulong)seg.Count); // Copy message bytes Buffer.BlockCopy(seg.Array!, seg.Offset, dst, p, seg.Count); @@ -174,14 +174,85 @@ public bool TryRewrite( return true; } - private static void WriteVarUInt(byte[] buffer, ref int pos, uint value) + /// + /// Writes a length prefix in Mirror's variable-length encoding. + /// + /// This is deliberately not LEB128. Mirror uses the SQLite4 style scheme, where + /// anything up to 240 fits in a single byte and the ranges above it are keyed off a + /// marker byte. An LEB128 encoder agrees with it only for values below 128, so it + /// silently produced correct batches until a rewritten message first grew past that - + /// then the client read the length prefix as one byte, treated the continuation byte + /// as message data, and every message after it in the batch was shifted by one. Mirror + /// throws on the garbage and closes the connection with no kick reason, which is + /// exactly what appending the server selector to the settings pack triggered. + /// + /// + /// Kept byte-for-byte identical to Mirror.Compression.CompressVarUInt; the + /// round-trip against Mirror's own decoder is covered by a test. + /// + /// + internal static void WriteVarUInt(byte[] buffer, ref int pos, ulong value) { - while (value >= 0x80) + if (value <= 240) { - buffer[pos++] = (byte)(value | 0x80); - value >>= 7; + buffer[pos++] = (byte)value; } + else if (value <= 2287) + { + buffer[pos++] = (byte)(((value - 240) >> 8) + 241); + buffer[pos++] = (byte)((value - 240) & 0xFF); + } + else if (value <= 67823) + { + buffer[pos++] = 249; + buffer[pos++] = (byte)((value - 2288) >> 8); + buffer[pos++] = (byte)((value - 2288) & 0xFF); + } + else if (value <= 16777215) + { + buffer[pos++] = 250; + buffer[pos++] = (byte)value; + buffer[pos++] = (byte)(value >> 8); + buffer[pos++] = (byte)(value >> 16); + } + else if (value <= uint.MaxValue) + { + buffer[pos++] = 251; + WriteUInt32(buffer, ref pos, (uint)value); + } + else if (value <= 1099511627775UL) + { + buffer[pos++] = 252; + buffer[pos++] = (byte)value; + WriteUInt32(buffer, ref pos, (uint)(value >> 8)); + } + else if (value <= 281474976710655UL) + { + buffer[pos++] = 253; + buffer[pos++] = (byte)value; + buffer[pos++] = (byte)(value >> 8); + WriteUInt32(buffer, ref pos, (uint)(value >> 16)); + } + else if (value <= 72057594037927935UL) + { + buffer[pos++] = 254; + for (int i = 0; i < 7; i++) + buffer[pos++] = (byte)(value >> (i * 8)); + } + else + { + buffer[pos++] = byte.MaxValue; + for (int i = 0; i < 8; i++) + buffer[pos++] = (byte)(value >> (i * 8)); + } + } + + private static void WriteUInt32(byte[] buffer, ref int pos, uint value) + { buffer[pos++] = (byte)value; + buffer[pos++] = (byte)(value >> 8); + buffer[pos++] = (byte)(value >> 16); + buffer[pos++] = (byte)(value >> 24); } From b6d2aeee18303083b7b44d0b0f6c5e9f3acd2b0e Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 18:51:52 +0000 Subject: [PATCH 11/15] Keep players informed while the game server restarts Three separate defects meant a player watched a frozen facility with no explanation for the entire restart, decided the server had died, and left: - Hint() dropped every hint whose session had never spawned. Restart recovery replaces the session, so the new one has IsSpawned == false at exactly the moment the message matters. The flag now lives on the connection, which outlives the session, like the client-side HUD does. - The recovery message was sent once and then allowed to fade, leaving the screen blank for most of the outage. It is now refreshed once a second with a live countdown to the next reconnect attempt. - A fast restart (sr) was passed through to the client, which made it disconnect from the proxy and re-authenticate; the proxy then read the game server's own disconnect as a shutdown and ran the slow shutdown recovery with the wrong message. It is handled like a full restart now. The proxy also stops guessing when the game server is back. The bridge reports CustomLiteNetLib4MirrorTransport.DelayConnections - the game's own gate on accepting preauth - alongside the round state, once a second. With that the proxy waits while the server says it is not ready instead of burning retry attempts against it, reconnects on the first tick it opens, and drops the blind ten second initial wait to one. Bridges that predate the field report nothing extra and fall back to the old timers. --- SiteLink.API/Core/Server.cs | 41 ++++- SiteLink.API/Misc/MirrorMessagesExtensions.cs | 6 +- .../Connections/RemoteConnection.cs | 12 ++ SiteLink.API/Networking/Session.cs | 160 +++++++++++++++++- SiteLink.API/SiteLinkBridge.cs | 14 +- .../Translations/LanguageTranslations.cs | 12 +- SiteLink.Bridge/PlayerCountReporter.cs | 38 +++-- SiteLink.Bridge/RoundStateReporter.cs | 61 ++++++- SiteLink/Translations/language_en.json | 8 +- 9 files changed, 315 insertions(+), 37 deletions(-) diff --git a/SiteLink.API/Core/Server.cs b/SiteLink.API/Core/Server.cs index de56ea5..8e8d5f2 100644 --- a/SiteLink.API/Core/Server.cs +++ b/SiteLink.API/Core/Server.cs @@ -159,15 +159,50 @@ internal void ResetBridgePlayerCount() /// public bool IsBridgeRestarting => BridgeRoundState == BridgeRoundState.Restarting; - internal void SetBridgeRoundState(BridgeRoundState state, BridgeRestartType restartType, bool idle) + /// + /// Whether the game server's transport is currently accepting player connections, as + /// reported by the bridge. Mirrors CustomLiteNetLib4MirrorTransport.DelayConnections + /// inverted: while the game server delays connections every join attempt is rejected, + /// so this is the only honest answer to "is it up yet". + /// + public bool BridgeAcceptingConnections { get; private set; } + + /// + /// How many seconds the game server said it delays incoming connections by. Only + /// meaningful while is false. + /// + public int BridgeConnectionDelaySeconds { get; private set; } + + /// + /// Whether the last round state reported by the bridge is recent enough to act on. + /// A bridge that died with the game server stops refreshing this, which is itself the + /// signal that the server is gone. + /// + public bool HasFreshBridgeRoundState => + BridgeConnection != null + && BridgeRoundState != BridgeRoundState.Unknown + && DateTime.UtcNow - BridgeRoundStateUpdatedAt < BridgePlayerCountTimeout; + + /// + /// Whether the bridge says the game server is deliberately unavailable - restarting or + /// shutting down - as opposed to merely unreachable. + /// + public bool IsBridgeBusyRestarting => + BridgeRoundState == BridgeRoundState.Restarting + || BridgeRoundState == BridgeRoundState.Shutdown; + + internal void SetBridgeRoundState(BridgeRoundState state, BridgeRestartType restartType, bool idle, bool acceptingConnections, int connectionDelaySeconds) { bool changed = BridgeRoundState != state || BridgeRestartType != restartType - || BridgeIdleMode != idle; + || BridgeIdleMode != idle + || BridgeAcceptingConnections != acceptingConnections; BridgeRoundState = state; BridgeRestartType = restartType; BridgeIdleMode = idle; + BridgeAcceptingConnections = acceptingConnections; + BridgeConnectionDelaySeconds = connectionDelaySeconds; BridgeRoundStateUpdatedAt = DateTime.UtcNow; if (changed) @@ -179,6 +214,8 @@ internal void ResetBridgeRoundState() BridgeRoundState = BridgeRoundState.Unknown; BridgeRestartType = BridgeRestartType.None; BridgeIdleMode = false; + BridgeAcceptingConnections = false; + BridgeConnectionDelaySeconds = 0; BridgeRoundStateUpdatedAt = DateTime.MinValue; } diff --git a/SiteLink.API/Misc/MirrorMessagesExtensions.cs b/SiteLink.API/Misc/MirrorMessagesExtensions.cs index c5c6e18..14c74e0 100644 --- a/SiteLink.API/Misc/MirrorMessagesExtensions.cs +++ b/SiteLink.API/Misc/MirrorMessagesExtensions.cs @@ -72,7 +72,11 @@ public static void Reconnect(this MirrorSender sender) public static void Hint(this MirrorSender sender, string message, float duration = 3) { - if (!sender.Connection.Session.IsSpawned) + // The client only renders hints once it owns a player object. A session that was + // replaced during recovery reports IsSpawned == false even though the client is + // still standing in the facility, so the connection-level flag is the one that + // reflects what the player can actually see. + if (!sender.Connection.HasEverSpawned && sender.Connection.Session?.IsSpawned != true) return; sender.Send(w => diff --git a/SiteLink.API/Networking/Connections/RemoteConnection.cs b/SiteLink.API/Networking/Connections/RemoteConnection.cs index 8f639b9..3ef39cf 100644 --- a/SiteLink.API/Networking/Connections/RemoteConnection.cs +++ b/SiteLink.API/Networking/Connections/RemoteConnection.cs @@ -28,6 +28,18 @@ internal enum DisconnectDelivery /// public bool IsSwitchingServers { get; set; } + /// + /// Whether this client has ever had a spawned player object on any session. + /// + /// Hints are gated on this rather than on + /// because recovery replaces the session: a player who was mid-round when the game + /// server restarted is attached to a brand new, never-spawned session while the proxy + /// waits, which is exactly when the "reconnecting..." hint has to be visible. The + /// client-side HUD outlives the session, so the connection is the right owner of the flag. + /// + /// + public bool HasEverSpawned { get; internal set; } + public RemoteConnection(Listener listener, ConnectionRequest request, PreAuth preAuth) : base(listener, request, preAuth) { AsServer = new MirrorSender(this, diff --git a/SiteLink.API/Networking/Session.cs b/SiteLink.API/Networking/Session.cs index 9b5de7e..3500d10 100644 --- a/SiteLink.API/Networking/Session.cs +++ b/SiteLink.API/Networking/Session.cs @@ -77,6 +77,9 @@ public bool IsSpawned Server?.OnSessionSpawned(this); _isSpawned = value; + + if (value && Connection != null) + Connection.HasEverSpawned = true; } } @@ -239,6 +242,28 @@ private set private bool _shutdownRetryFinished; private float _recoveryInitialDelay; + /// + /// How often the recovery hint is re-sent while the game server is away. + /// + /// Hints replace each other on the client and then fade, so a single hint sent once + /// per reconnect attempt left the screen blank for most of the outage - which is + /// what made players think the server had frozen and quit. Refreshing faster than + /// the hint's own lifetime keeps one continuous message on screen. + /// + /// + private static readonly TimeSpan RecoveryHintInterval = TimeSpan.FromSeconds(1); + + /// Hint lifetime, deliberately longer than the refresh interval so it never blinks. + private const float RecoveryHintDuration = 2.5f; + + private DateTime _nextRecoveryHint = DateTime.MaxValue; + + /// + /// Set once the bridge has told us the game server came back and is accepting + /// connections, so the countdown can stop lying about waiting. + /// + private bool _recoveryServerBack; + public uint NetworkId { get; private set; } public string Nickname { get; set; } public string UserId { get; private set; } @@ -372,8 +397,22 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< switch (type) { + // A fast restart is still the game server tearing the round down and dropping + // its transport. Passing it through makes the client disconnect from the proxy + // and re-authenticate, and the proxy then reads the game server's own + // disconnect as a shutdown and starts the slow shutdown recovery. Handling it + // exactly like a full restart keeps the client attached and lets the recovery + // loop - which the bridge now drives - put it back on the server. case RoundRestartType.FastRestart: - return InterceptResult.Pass(); + SiteLinkLogger.Info($"{Connection?.Tag} Server is performing a fast restart."); + + IsRestarting = true; + + // A fast restart announces no delay of its own; without a bridge to tell + // us when it finished, the transport's own connection delay is the best + // estimate we have. + session.BeginRestartRecovery(3f, extendedReconnectionPeriod: true); + return InterceptResult.Drop(); case RoundRestartType.RedirectRestart: return InterceptResult.Pass(); @@ -566,6 +605,7 @@ public void Update() AsClient?.Update(); UpdateShutdownRetry(); + UpdateRecoveryHint(); if (ConnectingToServer == null && ConnectToServers != null && ConnectToServers.Count > 0) { @@ -744,7 +784,16 @@ private void BeginRestartRecovery(float restartDelay, bool extendedReconnectionP _shutdownRetryAttemptsMade = 0; _shutdownRetryInterval = TimeSpan.FromSeconds(Math.Max(0.1f, settings?.RestartRetryInterval ?? 3f)); - _recoveryInitialDelay = 10f; + // With a bridge attached we do not have to guess how long the restart takes: the + // bridge holds the retry back until the game server reports it is accepting + // connections again, so the initial wait only exists to avoid a pointless first + // attempt. Without one, fall back to the delay the game server announced, and to + // the old fixed ten seconds when it announced nothing useful. + _recoveryInitialDelay = restartingServer.HasFreshBridgeRoundState + ? 1f + : (restartDelay > 0.5f ? Math.Min(restartDelay, 30f) : 10f); + + _recoveryServerBack = false; _nextShutdownRetry = DateTime.UtcNow.AddSeconds(_recoveryInitialDelay); _shutdownWaitingMessage = TranslationManager.For(this).Recovery.RestartWaiting; @@ -843,6 +892,7 @@ private void ConfigureShutdownRetry(Server shutdownServer) _shutdownUnreachableMessage = TranslationManager.For(this).Recovery.ShutdownUnreachable; _shutdownRetryFinished = false; _recoveryInitialDelay = (float)_shutdownRetryInterval.TotalSeconds; + _recoveryServerBack = false; } private void ShowShutdownRetryStatus() @@ -850,10 +900,37 @@ private void ShowShutdownRetryStatus() if (_shutdownRetryServer == null || _shutdownRetryFinished) return; - Connection?.AsServer.Hint( - FormatShutdownRetryMessage(_shutdownWaitingMessage), - Math.Max(3f, (float)_shutdownRetryInterval.TotalSeconds + 0.5f) - ); + _nextRecoveryHint = DateTime.UtcNow.Add(RecoveryHintInterval); + + string message = FormatShutdownRetryMessage(_shutdownWaitingMessage); + + if (string.IsNullOrEmpty(message)) + return; + + Connection?.AsServer.Hint(message, RecoveryHintDuration); + } + + /// + /// Keeps the recovery message on screen for the whole outage. + /// + /// Without this the player saw one hint, watched it fade, and then stared at a frozen + /// facility for the rest of the restart with no indication that anything was still + /// happening. Re-sending on a fixed cadence also means the countdown in the message + /// actually counts down. + /// + /// + private void UpdateRecoveryHint() + { + if (_shutdownRetryServer == null || _shutdownRetryFinished) + { + _nextRecoveryHint = DateTime.MaxValue; + return; + } + + if (_nextRecoveryHint > DateTime.UtcNow) + return; + + ShowShutdownRetryStatus(); } private void UpdateShutdownRetry() @@ -861,6 +938,10 @@ private void UpdateShutdownRetry() if (_shutdownRetryServer == null || _shutdownRetryFinished || Status != SessionStatus.Connected) return; + // The bridge is the only source that knows whether the game server is actually + // down or merely swapping scenes, so let it drive the schedule when it is there. + ApplyBridgeRecoverySignal(); + if (_nextShutdownRetry > DateTime.UtcNow) return; @@ -915,17 +996,84 @@ private string FormatShutdownRetryMessage(string message) { message ??= string.Empty; + // Seconds left until the next reconnect attempt. This is what makes the message + // read as progress rather than as a stuck screen: a static "waiting..." is + // indistinguishable from a crash from the player's side. + double countdown = Math.Max(0d, (_nextShutdownRetry - DateTime.UtcNow).TotalSeconds); + return TranslationManager.Format( message, TranslationContext.For(this, _shutdownRetryServer)) .Add("server", _shutdownRetryServer?.DisplayName) .Add("server_name", _shutdownRetryServer?.Name) .Add("attempts", _shutdownRetryAttempts) + .Add("attempt", Math.Min(_shutdownRetryAttemptsMade + 1, Math.Max(1, _shutdownRetryAttempts))) .Add("interval", _shutdownRetryInterval.TotalSeconds, "0.##") .Add("restart_delay", _recoveryInitialDelay, "0.##") + .Add("countdown", countdown, "0") .Format(); } + /// + /// Lets the bridge, rather than a fixed timer, decide when the game server is worth + /// reconnecting to. + /// + /// Without a bridge the proxy can only guess: it waits ten seconds, then retries on a + /// fixed interval and burns its attempt budget against a server that is still loading + /// a scene. The bridge reports both the round state and whether the transport is + /// still delaying incoming connections, which is exactly the question the retry loop + /// is trying to answer. + /// + /// + private void ApplyBridgeRecoverySignal() + { + Server server = _shutdownRetryServer; + + if (server == null || !server.HasFreshBridgeRoundState) + return; + + if (server.IsBridgeBusyRestarting) + { + _recoveryServerBack = false; + + // Do not spend an attempt on a server that has told us it is not ready. Keep + // the next check just past the point where the bridge would have to have + // reported again for us to still trust it. + DateTime hold = DateTime.UtcNow.Add(_shutdownRetryInterval); + + if (_nextShutdownRetry < hold) + _nextShutdownRetry = hold; + + return; + } + + if (!server.BridgeAcceptingConnections) + { + _recoveryServerBack = false; + + // The transport is up but still delaying preauth. Connecting now earns a + // rejection and a wasted attempt; wait out the delay the server declared. + DateTime hold = DateTime.UtcNow.AddSeconds(Math.Max(1, server.BridgeConnectionDelaySeconds)); + + if (_nextShutdownRetry < hold) + _nextShutdownRetry = hold; + + return; + } + + if (_recoveryServerBack) + return; + + // First tick where the bridge says the server is live and accepting. Stop waiting. + _recoveryServerBack = true; + _nextShutdownRetry = DateTime.UtcNow; + + SiteLinkLogger.Info( + $"{Connection?.Tag} Bridge reports (f=yellow){server.Name}(f=white) is accepting connections again; reconnecting now." + ); + } + + internal void ShowConnectionDelayedStatus(Server server, byte delay) { string message = TranslationManager.For(this).Connection.ConnectionDelayed; diff --git a/SiteLink.API/SiteLinkBridge.cs b/SiteLink.API/SiteLinkBridge.cs index 1cf09ae..3b55587 100644 --- a/SiteLink.API/SiteLinkBridge.cs +++ b/SiteLink.API/SiteLinkBridge.cs @@ -516,7 +516,19 @@ private static void OnBridgeRoundState(NetPacketReader reader, Server server) BridgeRestartType restartType = (BridgeRestartType)reader.GetByte(); bool idle = reader.GetBool(); - server.SetBridgeRoundState(state, restartType, idle); + // Older bridges stop here. Treat their silence as "accepting", which is what the + // proxy assumed before the field existed, so a stale plugin degrades to the old + // timer-driven behaviour instead of never reconnecting anyone. + bool acceptingConnections = true; + int connectionDelaySeconds = 0; + + if (reader.AvailableBytes >= 2) + { + acceptingConnections = reader.GetBool(); + connectionDelaySeconds = reader.GetByte(); + } + + server.SetBridgeRoundState(state, restartType, idle, acceptingConnections, connectionDelaySeconds); } public static void AttachServerPeer(Server server, LiteNetPeer peer) diff --git a/SiteLink.API/Translations/LanguageTranslations.cs b/SiteLink.API/Translations/LanguageTranslations.cs index add33c7..9fc72f4 100644 --- a/SiteLink.API/Translations/LanguageTranslations.cs +++ b/SiteLink.API/Translations/LanguageTranslations.cs @@ -67,18 +67,18 @@ public class ConnectionTranslations public class RecoveryTranslations { - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string ShutdownWaiting { get; set; } = - "{tag}\nServer {server} shut down, waiting for it to be online..."; + "{tag}\nServer {server} went offline.\nReconnecting in {countdown}s... (attempt {attempt}/{attempts})"; - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string ShutdownUnreachable { get; set; } = "{tag}\nServer {server} is not reachable!"; - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string RestartWaiting { get; set; } = - "{tag}\nServer {server} is restarting, reconnecting in {restart_delay} seconds..."; + "{tag}\nServer {server} is restarting.\nDo not leave - you will be reconnected automatically.\nReconnecting in {countdown}s..."; - [Description("Placeholders: {server}, {server_name}, {attempts}, {interval}, {restart_delay}")] + [Description("Placeholders: {server}, {server_name}, {attempts}, {attempt}, {interval}, {restart_delay}, {countdown}")] public string RestartUnreachable { get; set; } = "{tag}\nServer {server} did not come back online!"; } diff --git a/SiteLink.Bridge/PlayerCountReporter.cs b/SiteLink.Bridge/PlayerCountReporter.cs index fa19d84..caa8d25 100644 --- a/SiteLink.Bridge/PlayerCountReporter.cs +++ b/SiteLink.Bridge/PlayerCountReporter.cs @@ -188,23 +188,43 @@ private static int GetMaxPlayers() private sealed class PlayerCountTicker : MonoBehaviour { + /// + /// Round state is polled far more often than the player count. Nothing raises an + /// event when the transport stops delaying connections, and every second spent + /// not noticing is a second the proxy keeps players staring at a frozen facility. + /// + private const double RoundStatePollSeconds = 1d; + // Idle mode sets Time.timeScale to 0.01, and InvokeRepeating runs on scaled time, // so a 5 second interval turned into 500 real seconds the moment the server went // idle. The proxy then hit its 30 second bridge timeout and fell back to counting // its own sessions. A stopwatch is the only clock idle mode cannot slow down. private readonly Stopwatch _sinceLastTick = Stopwatch.StartNew(); + private readonly Stopwatch _sinceLastRoundState = Stopwatch.StartNew(); private void Update() { + if (_sinceLastRoundState.Elapsed.TotalSeconds >= RoundStatePollSeconds) + { + _sinceLastRoundState.Restart(); + + try + { + // Idle mode and the connection delay flag have no events to hook, so + // they ride along on this timer. + RoundStateReporter.Poll(); + } + catch (Exception ex) + { + SiteLinkBridgePlugin.LogError($"Round state report failed: {ex}"); + } + } + if (_sinceLastTick.Elapsed.TotalSeconds < Interval) return; _sinceLastTick.Restart(); - Tick(); - } - private void Tick() - { try { Report(); @@ -215,16 +235,6 @@ private void Tick() // silently falls back to its own inaccurate count forever. SiteLinkBridgePlugin.LogError($"Player count report failed: {ex}"); } - - try - { - // Idle mode has no event to hook, so it rides along on this timer. - RoundStateReporter.Poll(); - } - catch (Exception ex) - { - SiteLinkBridgePlugin.LogError($"Round state report failed: {ex}"); - } } } } diff --git a/SiteLink.Bridge/RoundStateReporter.cs b/SiteLink.Bridge/RoundStateReporter.cs index 9bfafd8..7ef3cc4 100644 --- a/SiteLink.Bridge/RoundStateReporter.cs +++ b/SiteLink.Bridge/RoundStateReporter.cs @@ -38,6 +38,7 @@ public static class RoundStateReporter private static BridgeRoundState _lastState = BridgeRoundState.Unknown; private static BridgeRestartType _lastRestartType = BridgeRestartType.None; private static bool _lastIdle; + private static bool _lastAccepting; private static bool _sentOnce; /// The state sent to the proxy in the last report. @@ -49,6 +50,9 @@ public static class RoundStateReporter /// Whether the last report said the server is idling. public static bool LastIdle => _lastIdle; + /// Whether the last report said the transport is accepting connections. + public static bool LastAcceptingConnections => _lastAccepting; + public static void Start() { if (_running) @@ -183,7 +187,7 @@ private static void OnProxyConnected(BridgeEndpoint endpoint) // A bridge that connects mid-round would otherwise leave the proxy on Unknown // until the next round event, which on a quiet server can be a long time. - SendTo(endpoint, GetState(), GetRestartType(), IsIdle()); + SendTo(endpoint, GetState(), GetRestartType(), IsIdle(), IsAcceptingConnections(), GetConnectionDelay()); } /// @@ -210,11 +214,14 @@ public static void Report() BridgeRoundState state = GetState(); BridgeRestartType restartType = GetRestartType(); bool idle = IsIdle(); + bool accepting = IsAcceptingConnections(); + byte delaySeconds = GetConnectionDelay(); bool changed = !_sentOnce || state != _lastState || restartType != _lastRestartType - || idle != _lastIdle; + || idle != _lastIdle + || accepting != _lastAccepting; if (!changed) return; @@ -222,6 +229,7 @@ public static void Report() _lastState = state; _lastRestartType = restartType; _lastIdle = idle; + _lastAccepting = accepting; _sentOnce = true; SiteLinkBridge.Send(SiteLinkBridge.MsgRoundState, writer => @@ -229,21 +237,25 @@ public static void Report() writer.Put((byte)state); writer.Put((byte)restartType); writer.Put(idle); + writer.Put(accepting); + writer.Put(delaySeconds); }); BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; if (config != null && config.Debug) - SiteLinkBridgePlugin.Log($"Reported round state {state} (restart: {restartType}, idle: {idle}) to the proxy."); + SiteLinkBridgePlugin.Log($"Reported round state {state} (restart: {restartType}, idle: {idle}, accepting: {accepting}) to the proxy."); } - private static void SendTo(BridgeEndpoint endpoint, BridgeRoundState state, BridgeRestartType restartType, bool idle) + private static void SendTo(BridgeEndpoint endpoint, BridgeRoundState state, BridgeRestartType restartType, bool idle, bool accepting, byte delaySeconds) { SiteLinkBridge.SendTo(endpoint, SiteLinkBridge.MsgRoundState, writer => { writer.Put((byte)state); writer.Put((byte)restartType); writer.Put(idle); + writer.Put(accepting); + writer.Put(delaySeconds); }); } @@ -293,5 +305,46 @@ private static bool IsIdle() return false; } } + + /// + /// Whether the transport will let a player in right now. + /// + /// The game server sets DelayConnections for the whole restart and clears it + /// only once it is ready, rejecting every preauth in between. The proxy used to guess + /// this with a fixed ten second wait; reporting the flag directly is the difference + /// between reconnecting when the server is up and reconnecting when a timer says so. + /// + /// + private static bool IsAcceptingConnections() + { + try + { + if (_shuttingDown) + return false; + + return !CustomLiteNetLib4MirrorTransport.DelayConnections; + } + catch (Exception) + { + // Never claim the server is closed because a field moved; the proxy falls + // back to its own timers when we say "accepting" and it turns out not to be. + return true; + } + } + + /// How long the game server delays incoming connections by, clamped to a byte. + private static byte GetConnectionDelay() + { + try + { + byte delay = CustomLiteNetLib4MirrorTransport.DelayTime; + + return delay == 0 ? (byte)1 : delay; + } + catch (Exception) + { + return 3; + } + } } } diff --git a/SiteLink/Translations/language_en.json b/SiteLink/Translations/language_en.json index d771008..3eb0fae 100644 --- a/SiteLink/Translations/language_en.json +++ b/SiteLink/Translations/language_en.json @@ -1,5 +1,7 @@ { - "General": { "Tag": "[SiteLink]" }, + "General": { + "Tag": "[SiteLink]" + }, "Connection": { "ServerNotFound": "{tag}\nServer {server_name} was not found.", "ServerRemoved": "{tag}\nServer {server} was removed from the configuration.", @@ -14,9 +16,9 @@ "SessionReplaced": "{tag}\nYour server session was replaced." }, "Recovery": { - "ShutdownWaiting": "{tag}\nServer {server} shut down, waiting for it to be online...", + "ShutdownWaiting": "{tag}\nServer {server} went offline.\nReconnecting in {countdown}s... (attempt {attempt}/{attempts})", "ShutdownUnreachable": "{tag}\nServer {server} is not reachable!", - "RestartWaiting": "{tag}\nServer {server} is restarting, reconnecting in {restart_delay} seconds...", + "RestartWaiting": "{tag}\nServer {server} is restarting.\nDo not leave - you will be reconnected automatically.\nReconnecting in {countdown}s...", "RestartUnreachable": "{tag}\nServer {server} did not come back online!" }, "Commands": { From 0bac560cde184e73d4cb012aa3d59e2543c8f1a5 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 19:20:05 +0000 Subject: [PATCH 12/15] Stop restart recovery from kicking the player it is recovering Recovery does not reconnect the session that lost its server; it builds a replacement session and swaps it in. FinalizeConnection therefore saw an existing Connection.Session and took the server-switch path, which sends the client a RoundRestartMessage, while PromotePendingToActive additionally sent the replaced session's disconnect RPC. The client left the proxy, spent about six seconds re-authenticating, and came back through TryReattachConnection - long enough to look like a freeze, and it wiped the recovery hint that was supposed to explain the wait. Depending on which message the client processed first it surfaced as either a round restart or a bare kick. Recovery reconnects now hand the connection over in place and tell the client nothing. The game server's own SceneMessage reloads the facility, so the player sees a loading screen instead of a disconnect, and the hint stays on screen for the whole outage. Also move the bridge's periodic reports to Logger.Debug. Round state is polled every second, so the console filled with "Reported round state ..." lines that nobody asked for. --- SiteLink.API/Networking/Session.cs | 52 +++++++++++++++++++++++++ SiteLink.Bridge/PlayerCountReporter.cs | 7 ++-- SiteLink.Bridge/RoundStateReporter.cs | 5 +-- SiteLink.Bridge/SiteLinkBridgePlugin.cs | 7 +++- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/SiteLink.API/Networking/Session.cs b/SiteLink.API/Networking/Session.cs index 3500d10..0cb0ae7 100644 --- a/SiteLink.API/Networking/Session.cs +++ b/SiteLink.API/Networking/Session.cs @@ -271,6 +271,13 @@ private set public int MapSeed { get; private set; } = -1; public bool IsRestarting { get; private set; } + + /// + /// Set on the replacement session that a shutdown/restart recovery creates, so that + /// connecting resumes the player in place instead of running the server-switch path. + /// + internal bool IsRecoveryRetry { get; set; } + public bool IsReady { get; internal set; } public bool IsConnectionConnected => Connection != null; public bool IsConnectedToSimulated { get; private set; } @@ -652,6 +659,12 @@ private void FinalizeConnection(Server server, bool isSimulated) return; } + if (IsRecoveryRetry) + { + ResumeAfterRecovery(); + return; + } + Connection.Session?.Stats.RecordServerSwitch(); SessionManager.Singleton.PromotePendingToActive( @@ -662,6 +675,41 @@ private void FinalizeConnection(Server server, bool isSimulated) Connection.AsServer.Reconnect(); } + /// + /// Hands the client over to this session after a shutdown/restart recovery reconnect, + /// without telling the client anything. + /// + /// Recovery does not reconnect the old session, it builds a new one and swaps it in, + /// so the normal server-switch path used to run here. That path sends a + /// RoundRestartMessage (and, via the replaced session, a disconnect RPC), which + /// threw the player out of the proxy for the six seconds it took to re-authenticate, + /// destroyed the recovery hint that was the whole point, and occasionally surfaced as + /// a bare kick. From the client's point of view nothing happened here: the same + /// connection is now relayed to a freshly restarted server, and the server's own + /// SceneMessage reloads the facility for it. + /// + /// + private void ResumeAfterRecovery() + { + // Take ownership of the connection first. PromotePendingToActive disconnects the + // session it replaces, and that disconnect only fires while the connection still + // points at the old session. + AttachToConnection(Connection); + Connection.Session = this; + + SessionManager.Singleton.PromotePendingToActive( + Connection.PreAuth.UserId, + this + ); + + // Promotion marks the connection as switching servers so that the expected client + // disconnect detaches instead of destroying the session. No disconnect is coming, + // and leaving the flag set would make a genuine quit leak the session. + Connection.IsSwitchingServers = false; + + IsRecoveryRetry = false; + } + private void OnConnected(NetPeer peer) => FinalizeConnection(ConnectingToServer, isSimulated: false); private void OnDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) @@ -966,6 +1014,10 @@ private void UpdateShutdownRetry() return; } + // The player never asked to change servers; they are being put back where they + // already were. Marking the retry keeps FinalizeConnection from kicking them. + retrySession.IsRecoveryRetry = true; + _shutdownRetryAttemptsMade++; _nextShutdownRetry = DateTime.UtcNow.Add(_shutdownRetryInterval); diff --git a/SiteLink.Bridge/PlayerCountReporter.cs b/SiteLink.Bridge/PlayerCountReporter.cs index caa8d25..b96c43f 100644 --- a/SiteLink.Bridge/PlayerCountReporter.cs +++ b/SiteLink.Bridge/PlayerCountReporter.cs @@ -140,8 +140,8 @@ public static void Report() writer.Put(maxPlayers); }); - if (changed && config != null && config.Debug) - SiteLinkBridgePlugin.Log($"Reported {players}/{maxPlayers} players to the proxies (excluded {dummies} dummies)."); + if (changed) + SiteLinkBridgePlugin.LogDebug($"Reported {players}/{maxPlayers} players to the proxies (excluded {dummies} dummies)."); } /// @@ -170,8 +170,7 @@ public static void ReportTo(BridgeEndpoint endpoint) writer.Put(maxPlayers); }); - if (config != null && config.Debug) - SiteLinkBridgePlugin.Log($"Reported {players}/{maxPlayers} players to {endpoint} (excluded {dummies} dummies)."); + SiteLinkBridgePlugin.LogDebug($"Reported {players}/{maxPlayers} players to {endpoint} (excluded {dummies} dummies)."); } private static int GetMaxPlayers() diff --git a/SiteLink.Bridge/RoundStateReporter.cs b/SiteLink.Bridge/RoundStateReporter.cs index 7ef3cc4..39baab3 100644 --- a/SiteLink.Bridge/RoundStateReporter.cs +++ b/SiteLink.Bridge/RoundStateReporter.cs @@ -241,10 +241,7 @@ public static void Report() writer.Put(delaySeconds); }); - BridgeConfig config = SiteLinkBridgePlugin.Instance?.Config; - - if (config != null && config.Debug) - SiteLinkBridgePlugin.Log($"Reported round state {state} (restart: {restartType}, idle: {idle}, accepting: {accepting}) to the proxy."); + SiteLinkBridgePlugin.LogDebug($"Reported round state {state} (restart: {restartType}, idle: {idle}, accepting: {accepting}) to the proxy."); } private static void SendTo(BridgeEndpoint endpoint, BridgeRoundState state, BridgeRestartType restartType, bool idle, bool accepting, byte delaySeconds) diff --git a/SiteLink.Bridge/SiteLinkBridgePlugin.cs b/SiteLink.Bridge/SiteLinkBridgePlugin.cs index 610709f..582c924 100644 --- a/SiteLink.Bridge/SiteLinkBridgePlugin.cs +++ b/SiteLink.Bridge/SiteLinkBridgePlugin.cs @@ -175,7 +175,12 @@ private void OnDisconnected(BridgeEndpoint endpoint, DisconnectInfo info) Logger.Warn($"[SiteLink.Bridge] Disconnected from proxy {endpoint}: {info.Reason}. Retrying..."); } - internal static void Log(string message) => Logger.Info($"[SiteLink.Bridge] {message}"); + /// + /// Routine chatter - periodic reports and state churn. These fire every second on a + /// busy server, so they only reach the console when 'debug' is on. + /// + internal static void LogDebug(string message) => + Logger.Debug($"[SiteLink.Bridge] {message}", Instance?.Config?.Debug ?? false); internal static void LogWarn(string message) => Logger.Warn($"[SiteLink.Bridge] {message}"); From 9e2a92cf6f81f85c5522042d602788558ff9ea90 Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 20:50:02 +0000 Subject: [PATCH 13/15] Let a game server restart look like a round restart again The proxy used to swallow the restart message and resume the client in place. It worked, but the player never saw the vanilla restart screen, so a full restart felt like a fast restart: the world froze for a moment and came back with no explanation. The screen is produced by the client's own reconnect flow, and that flow only starts once the client is actually disconnected. So forward the restart message as-is - its offset is the game server's own estimate of when it will be back - and then close the socket ourselves a second later, standing in for the game server socket a proxy hides. A fast restart carries no offset at all, so it is rewritten as a full restart with the bridge's connection delay. The session has to survive that absence: the client's countdown can run to full_restart_rejoin_time, far past the ten second detach grace, so a restart extends the deadline and marks the departure as a server switch instead of a quit. Recovery holds while the client is away, because the preauth it needs left with them, and the reattach skips handing over a facility that is not being simulated yet. --- SiteLink.API/Misc/MirrorMessagesExtensions.cs | 26 ++++ SiteLink.API/Networking/Session.cs | 124 ++++++++++++++++-- SiteLink.API/Networking/SessionManager.cs | 20 ++- 3 files changed, 160 insertions(+), 10 deletions(-) diff --git a/SiteLink.API/Misc/MirrorMessagesExtensions.cs b/SiteLink.API/Misc/MirrorMessagesExtensions.cs index 14c74e0..66e2e2a 100644 --- a/SiteLink.API/Misc/MirrorMessagesExtensions.cs +++ b/SiteLink.API/Misc/MirrorMessagesExtensions.cs @@ -70,6 +70,32 @@ public static void Reconnect(this MirrorSender sender) }); } + /// + /// Serializes a full round restart message, ready to be handed to the client in place + /// of whatever the game server sent. + /// + /// A fast restart carries no countdown of its own, so forwarding it verbatim through a + /// proxy leaves the player with no restart screen at all. Rewriting it as a full + /// restart with an offset gives them the same screen every other restart produces. + /// + /// + /// Seconds the client waits before reconnecting. + /// Whether the client may keep retrying past its usual window. + public static ArraySegment BuildFullRestart(float offset, bool extendedReconnectionPeriod) + { + NetworkWriter writer = new NetworkWriter(); + + writer.WriteUShort(NetworkMessages.RoundRestartMessage); + + // RoundRestartType.FullRestart + writer.WriteByte(0); + writer.WriteBool(true); + writer.WriteBool(extendedReconnectionPeriod); + writer.WriteFloat(offset); + + return writer.ToArraySegment(); + } + public static void Hint(this MirrorSender sender, string message, float duration = 3) { // The client only renders hints once it owns a player object. A session that was diff --git a/SiteLink.API/Networking/Session.cs b/SiteLink.API/Networking/Session.cs index 0cb0ae7..7881bf1 100644 --- a/SiteLink.API/Networking/Session.cs +++ b/SiteLink.API/Networking/Session.cs @@ -208,6 +208,21 @@ public RemoteConnection Connection public DateTime AliveUntil { get; set; } = DateTime.MinValue; + /// + /// Latest moment a client that left because of a game server restart may still claim + /// this session back. means the default detach grace + /// applies. + /// + /// A restart hands the client back to its own reconnect countdown, which is up to + /// full_restart_rejoin_time (25 seconds by default) - far longer than the ten + /// seconds a detached session normally survives. Without this the session expired + /// while the player was still staring at the vanilla restart screen, and they came + /// back as a brand new connection that got routed by priority instead of back to the + /// server they were playing on. + /// + /// + public DateTime ReconnectDeadline { get; internal set; } = DateTime.MinValue; + public DateTime? DetachedAtUtc { get; set; } public bool WasDetached { get; set; } @@ -258,6 +273,32 @@ private set private DateTime _nextRecoveryHint = DateTime.MaxValue; + /// + /// Slack added on top of the restart offset before a detached session gives up on the + /// client coming back. + /// + /// The client waits the offset, then loads a scene, then re-authenticates against the + /// central servers - none of which is instant, and none of which the proxy can see. + /// Expiring a heartbeat early costs the player their slot for no reason, so the window + /// is generous; a client that really left still frees the session when the grace runs + /// out. + /// + /// + private const double RestartReconnectGraceSeconds = 25.0; + + /// + /// When the proxy drops the client itself to finish the restart it just announced. + /// while no restart is pending. + /// + private DateTime _restartDropClientAt = DateTime.MaxValue; + + /// + /// How long the restart message gets to reach the client before its socket is closed. + /// Long enough for the batch it rides in to be flushed, short enough that the player + /// does not stare at a frozen facility first. + /// + private const double RestartClientDropDelaySeconds = 1.0; + /// /// Set once the bridge has told us the game server came back and is accepting /// connections, so the countdown can stop lying about waiting. @@ -404,12 +445,12 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< switch (type) { - // A fast restart is still the game server tearing the round down and dropping - // its transport. Passing it through makes the client disconnect from the proxy - // and re-authenticate, and the proxy then reads the game server's own - // disconnect as a shutdown and starts the slow shutdown recovery. Handling it - // exactly like a full restart keeps the client attached and lets the recovery - // loop - which the bridge now drives - put it back on the server. + // A fast restart tells the client to come back immediately and gives it no + // countdown to display, which on a proxy reads as the screen simply blinking: + // the facility reloads and the player never learns the round restarted. The + // client is told about a full restart instead, so it gets the same waiting + // screen every other restart produces, and the proxy holds its session for + // the trip. case RoundRestartType.FastRestart: SiteLinkLogger.Info($"{Connection?.Tag} Server is performing a fast restart."); @@ -418,8 +459,12 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< // A fast restart announces no delay of its own; without a bridge to tell // us when it finished, the transport's own connection delay is the best // estimate we have. - session.BeginRestartRecovery(3f, extendedReconnectionPeriod: true); - return InterceptResult.Drop(); + float fastRestartDelay = Math.Max(3f, session.Server?.BridgeConnectionDelaySeconds ?? 0); + + session.BeginRestartRecovery(fastRestartDelay, extendedReconnectionPeriod: true); + return InterceptResult.Replace( + MirrorMessagesEx.BuildFullRestart(fastRestartDelay, extendedReconnectionPeriod: true) + ); case RoundRestartType.RedirectRestart: return InterceptResult.Pass(); @@ -435,8 +480,13 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< SiteLinkLogger.Info($"{Connection?.Tag} Server closed the connection, likely due to restart."); IsRestarting = true; + + // Forwarded untouched. The offset the game server picked is its own + // estimate of when it will be back - a measured average for `rr`, + // full_restart_rejoin_time for `sr` - and the client's restart screen and + // countdown are driven entirely by it. session.BeginRestartRecovery(restartDelay, extendedReconnectionPeriod); - return InterceptResult.Drop(); + return InterceptResult.Pass(); default: return InterceptResult.Pass(); @@ -511,6 +561,9 @@ public void AttachToConnection(RemoteConnection connection) { Connection = connection; IsDetached = false; + + // The client is back; whatever extra grace a restart bought it has been spent. + ReconnectDeadline = DateTime.MinValue; } /// @@ -611,6 +664,7 @@ public void Update() _netManager?.PollEvents(); AsClient?.Update(); + UpdateRestartClientDrop(); UpdateShutdownRetry(); UpdateRecoveryHint(); @@ -850,6 +904,16 @@ private void BeginRestartRecovery(float restartDelay, bool extendedReconnectionP ShowShutdownRetryStatus(); + // The restart message is on its way to the client, and the client answers it by + // leaving. Without this the proxy reads that as "player quit" and tears the whole + // slot down, so the player comes back as a stranger and gets routed by priority + // instead of back to the server they were playing on. + Connection.IsSwitchingServers = true; + ReconnectDeadline = DateTime.UtcNow.AddSeconds( + Math.Max(0f, restartDelay) + RestartReconnectGraceSeconds + ); + _restartDropClientAt = DateTime.UtcNow.AddSeconds(RestartClientDropDelaySeconds); + SiteLinkLogger.Info( $"{Connection.Tag} Server (f=yellow){restartingServer.Name}(f=white) is restarting; " + $"first reconnect in (f=yellow){_recoveryInitialDelay:0.##}(f=white) second(s), then " + @@ -981,11 +1045,53 @@ private void UpdateRecoveryHint() ShowShutdownRetryStatus(); } + /// + /// Closes the client's connection shortly after a restart was announced to it. + /// + /// On a vanilla server the restart message and the transport shutdown arrive together, + /// and it is the shutdown that actually sends the client to its reconnect screen - + /// the message on its own only tells it how long to wait. Behind a proxy nothing ever + /// closes that socket, so the client kept the connection, ignored its own countdown, + /// and sat on a facility that was no longer being simulated. Dropping it here is the + /// proxy standing in for the game server's socket, which is what makes the restart + /// look like a restart. + /// + /// + private void UpdateRestartClientDrop() + { + if (_restartDropClientAt > DateTime.UtcNow) + return; + + _restartDropClientAt = DateTime.MaxValue; + + RemoteConnection connection = Connection; + + if (connection == null) + return; + + // If recovery already handed the client to a newer session, the player is back in + // the facility and dropping them now would be a kick with extra steps. + if (!ReferenceEquals(connection.Session, this)) + return; + + // Transport-level only. A disconnect reason would be rendered as an error over + // the restart screen the client is about to show, and the client is expected + // back: IsSwitchingServers keeps the session alive for it. + connection.Disconnect(); + } + private void UpdateShutdownRetry() { if (_shutdownRetryServer == null || _shutdownRetryFinished || Status != SessionStatus.Connected) return; + // No client to reconnect on behalf of. This is the normal state for most of a + // restart: the player is away watching the game's own restart screen, and the + // preauth the retry needs went with them. Hold the recovery until they are back + // rather than burning attempts - or dereferencing a connection that is gone. + if (Connection == null) + return; + // The bridge is the only source that knows whether the game server is actually // down or merely swapping scenes, so let it drive the schedule when it is there. ApplyBridgeRecoverySignal(); diff --git a/SiteLink.API/Networking/SessionManager.cs b/SiteLink.API/Networking/SessionManager.cs index a3071e6..716a028 100644 --- a/SiteLink.API/Networking/SessionManager.cs +++ b/SiteLink.API/Networking/SessionManager.cs @@ -322,6 +322,15 @@ public bool TryReattachConnection(RemoteConnection connection) s.AttachToConnection(connection); connection.AcceptRequest(); connection.Session = s; + + // Mid-restart the proxy has no game server behind this session yet, so + // there is no facility to hand over. The client is sitting on its own + // loading screen after reconnecting; leaving it there until the recovery + // lands - and letting the game server send its own scene message then - + // beats loading the map twice. + if (s.IsRestarting) + return true; + connection.AsServer.Scene("Facility"); if (!s.Server.IsSimulated) @@ -345,7 +354,16 @@ public void DetachClient(string userId, string reason = null) return; slot.Active.DetachFromConnection(); - slot.Active.AliveUntil = DateTime.UtcNow.AddSeconds(DefaultSessionExpirationSeconds); + + // A client sent away by a game server restart is on its own countdown, which + // can be far longer than the default grace. The session has to outlive that + // countdown or the player loses their place while the vanilla restart screen + // is still counting down for them. + DateTime grace = DateTime.UtcNow.AddSeconds(DefaultSessionExpirationSeconds); + + slot.Active.AliveUntil = slot.Active.ReconnectDeadline > grace + ? slot.Active.ReconnectDeadline + : grace; } //SiteLinkLogger.Info($"Session detached for {userId} {reason}, expires in {DefaultSessionExpirationSeconds}s..."); From 9f99aea00de2e27f9b935e383be46c696e09159f Mon Sep 17 00:00:00 2001 From: Veslys Date: Wed, 29 Jul 2026 22:40:17 +0000 Subject: [PATCH 14/15] Stop losing players to the restart they are waiting out A player sent away by `rr` or `sr` was told to wait as long as the game server itself needed - 25 seconds for a full restart - even though they were reconnecting to the proxy, which never went down. Worse, they were often lost anyway: the recovery refused to reconnect to the game server while the client was away, and the client that came back was accepted into a session with no game server behind it and no facility to load, so it eventually gave up on its own. Both halves are now the mechanism the game server itself uses. The client gets a short countdown and, when it knocks too early, a Delay rejection carrying the number of seconds to wait - the same answer a vanilla server gives while it boots. Meanwhile the session reconnects itself in place, with no client attached, so by the time the player is let back in there is a facility waiting for them. The retry budget restarts on every knock, because a full process restart outlasts any fixed attempt count, and stops refreshing after two minutes so a server that is never coming back releases the player to a fallback instead of holding them in a delay loop. --- SiteLink.API/Misc/Extensions.cs | 26 ++ SiteLink.API/Networking/Listener.cs | 8 + SiteLink.API/Networking/Session.cs | 329 ++++++++++++++++++++-- SiteLink.API/Networking/SessionManager.cs | 64 ++++- 4 files changed, 405 insertions(+), 22 deletions(-) diff --git a/SiteLink.API/Misc/Extensions.cs b/SiteLink.API/Misc/Extensions.cs index d427aa2..5a86009 100644 --- a/SiteLink.API/Misc/Extensions.cs +++ b/SiteLink.API/Misc/Extensions.cs @@ -23,6 +23,32 @@ public static void RejectWithReason( request.RejectForce(writer); } + /// + /// Rejects a connection request the way the game server does while it is restarting: + /// followed by the number of seconds the client + /// should wait before it tries again. + /// + /// This is the mechanism that keeps a player from being lost across a restart. The + /// client stays in its own reconnect loop and comes back by itself, so the proxy does + /// not have to hold a half-connected client on a facility that is not being simulated. + /// + /// + /// Seconds the client is asked to wait before retrying. + public static void RejectWithDelay( + this ConnectionRequest request, + NetDataWriter writer, + byte delaySeconds) + { + writer.Reset(); + writer.Put((byte)RejectionReason.Delay); + writer.Put(delaySeconds); + + // Not forced, matching the game server: the request stays known for a moment so a + // duplicate connect packet from the same attempt gets the same answer instead of + // producing a second peer. + request.Reject(writer); + } + public static string ParseVersion(this string version) { if (_version != version) diff --git a/SiteLink.API/Networking/Listener.cs b/SiteLink.API/Networking/Listener.cs index ffab7a9..854b778 100644 --- a/SiteLink.API/Networking/Listener.cs +++ b/SiteLink.API/Networking/Listener.cs @@ -592,6 +592,14 @@ void OnConnectionRequest(ConnectionRequest request) case ClientType.GameClient: + // Before anything else: the player may be coming back from a restart that has + // not finished yet. They are sent away with a countdown instead of being let in, + // and this has to happen before a RemoteConnection exists - constructing one + // claims their user id, which would make their own next attempt look like a + // duplicate connection. + if (SessionManager.Singleton.TryDelayRestartingClient(request, preAuth, RequestWriter)) + return; + if (RemoteConnection.ConnectionByUserId.ContainsKey(preAuth.UserId)) { SiteLinkLogger.Info($"{Tag} Rejected connection from (f=cyan){preAuth.UserId}(f=white) - already connected."); diff --git a/SiteLink.API/Networking/Session.cs b/SiteLink.API/Networking/Session.cs index 7881bf1..abcc850 100644 --- a/SiteLink.API/Networking/Session.cs +++ b/SiteLink.API/Networking/Session.cs @@ -192,9 +192,23 @@ public RemoteConnection Connection Nickname = $"Unknown"; UserId = value.PreAuth.UserId; + + PreAuth = value.PreAuth; } } + /// + /// The credentials this session connects to game servers with. + /// + /// Kept on the session instead of read from on demand, + /// because a restart recovery has to reconnect to the game server while the client is + /// away on its own reconnect countdown - there is no connection to read at that + /// point. The copy is refreshed every time the client comes back, so the preauth the + /// game server is handed is never older than the client's last attempt. + /// + /// + internal PreAuth PreAuth { get; private set; } + private Server _server; public ChallengeHandler Challenge { get; private set; } @@ -299,12 +313,58 @@ private set /// private const double RestartClientDropDelaySeconds = 1.0; + /// + /// The countdown the client is given when a game server restart sends it away. + /// + /// The game server's own offset (5s for a round restart, full_restart_rejoin_time + /// - 25s by default - for a full one) describes how long it needs. The client is + /// not coming back to it; it is coming back to the proxy, which never went down. So it + /// gets a short countdown and is then delayed at the door, exactly like the game server + /// delays connections while it boots. Waiting 25 seconds on a black screen for a proxy + /// that is already listening is pure loss. + /// + /// + private const float RestartClientReconnectOffsetSeconds = 2f; + + /// + /// Fallback for how long a returning client is asked to wait when the bridge did not + /// report the game server's own connections_delay_time. Matches the vanilla + /// default. + /// + private const float RestartClientRetryIntervalSeconds = 5f; + + /// + /// The longest a recovery is allowed to hold a player's seat on a server that has not + /// come back. + /// + /// Every time the player knocks the retry budget starts over, because a full process + /// restart takes longer than any fixed attempt count covers. That refresh needs an end: + /// without one, a server that is never coming back would keep a player in a delay loop + /// forever instead of letting them be routed to a fallback. Generous enough for a real + /// sr, short enough that a dead server is not a life sentence. + /// + /// + private const double RecoveryMaxSeconds = 120.0; + + /// + /// When the recovery stops refreshing its retry budget. See . + /// + private DateTime _recoveryGiveUpAt = DateTime.MaxValue; + /// /// Set once the bridge has told us the game server came back and is accepting /// connections, so the countdown can stop lying about waiting. /// private bool _recoveryServerBack; + /// + /// Whether a restart recovery reconnect is in flight with no client attached. Failures + /// of such an attempt mean "the game server is not up yet" and nothing else, so they + /// are handled as a reschedule instead of running the normal disconnect logic - there + /// is no player to inform and no fallback to offer while the player is still knocking. + /// + private bool _recoveryInPlace; + public uint NetworkId { get; private set; } public string Nickname { get; set; } public string UserId { get; private set; } @@ -313,6 +373,19 @@ private set public bool IsRestarting { get; private set; } + /// + /// Whether this session is holding a player's seat while the game server behind it + /// restarts, and the recovery still has attempts left. + /// + /// A client that comes back during this window is sent away again with + /// rather than accepted, because there is no + /// facility to hand it yet. Once the recovery has given up this reads false, so the + /// player is let in and routed like a fresh join instead of being delayed forever. + /// + /// + internal bool IsAwaitingRestartRecovery => + IsRestarting && _shutdownRetryServer != null && !_shutdownRetryFinished; + /// /// Set on the replacement session that a shutdown/restart recovery creates, so that /// connecting resumes the player in place instead of running the server-switch path. @@ -463,7 +536,7 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< session.BeginRestartRecovery(fastRestartDelay, extendedReconnectionPeriod: true); return InterceptResult.Replace( - MirrorMessagesEx.BuildFullRestart(fastRestartDelay, extendedReconnectionPeriod: true) + MirrorMessagesEx.BuildFullRestart(RestartClientReconnectOffsetSeconds, extendedReconnectionPeriod: true) ); case RoundRestartType.RedirectRestart: @@ -481,12 +554,14 @@ private InterceptResult OnRestart(ushort id, NetworkReader reader, ArraySegment< IsRestarting = true; - // Forwarded untouched. The offset the game server picked is its own - // estimate of when it will be back - a measured average for `rr`, - // full_restart_rejoin_time for `sr` - and the client's restart screen and - // countdown are driven entirely by it. + // The recovery still runs on the offset the game server picked - that is + // its own estimate of when it will be back. The client gets a short one + // instead: it is reconnecting to the proxy, not to the game server, and it + // is delayed at the door until the game server is actually up. session.BeginRestartRecovery(restartDelay, extendedReconnectionPeriod); - return InterceptResult.Pass(); + return InterceptResult.Replace( + MirrorMessagesEx.BuildFullRestart(RestartClientReconnectOffsetSeconds, extendedReconnectionPeriod) + ); default: return InterceptResult.Pass(); @@ -566,6 +641,53 @@ public void AttachToConnection(RemoteConnection connection) ReconnectDeadline = DateTime.MinValue; } + /// + /// Records the credentials of a client that knocked on the listener but was sent away + /// again, and extends the session's lifetime because the player is provably still + /// waiting. + /// + /// A preauth expires, so the copy taken when the session was created is worthless by + /// the time a long restart is over. The one the client just presented is fresh. + /// + /// + internal void NoteClientStillWaiting(PreAuth preAuth, double keepAliveSeconds) + { + PreAuth = preAuth; + + DateTime until = DateTime.UtcNow.AddSeconds(keepAliveSeconds); + + if (AliveUntil < until) + AliveUntil = until; + + if (ReconnectDeadline < until) + ReconnectDeadline = until; + + // The retry budget exists so a player is not held forever on a server that is never + // coming back. That player is right here, knocking, so the budget starts over - a + // full restart can take longer than any fixed number of attempts covers. The refresh + // stops at _recoveryGiveUpAt, after which the attempts drain and they get routed + // somewhere that actually answers. + if (DateTime.UtcNow < _recoveryGiveUpAt) + _shutdownRetryAttemptsMade = 0; + } + + /// + /// How long a client that arrives while the game server is still coming up should be + /// asked to wait. The game server's own delay is preferred when the bridge reported + /// one, so the proxy and the server behind it count down together. + /// + internal byte GetConnectionDelaySeconds() + { + Server server = _shutdownRetryServer ?? Server; + + int delay = server?.BridgeConnectionDelaySeconds ?? 0; + + if (delay <= 0) + delay = (int)RestartClientRetryIntervalSeconds; + + return (byte)Math.Clamp(delay, 1, 15); + } + /// /// Marshals an action to execute on this session's owning thread (SessionService thread). /// @@ -607,7 +729,7 @@ public void Connect(int challengeId = 0, byte[] challengeResponse = null) ); SessionManager.Singleton.FailPending( - Connection?.PreAuth.UserId, + UserId, this, "Simulated server rejected connection" ); @@ -625,7 +747,7 @@ public void Connect(int challengeId = 0, byte[] challengeResponse = null) EnsureNet(); - _netManager.Connect(ConnectingToServer.IpAddress, ConnectingToServer.Port, Connection.PreAuth.Create(ConnectingToServer.ForwardIpAddress, challengeId, challengeResponse)); + _netManager.Connect(ConnectingToServer.IpAddress, ConnectingToServer.Port, PreAuth.Create(ConnectingToServer.ForwardIpAddress, challengeId, challengeResponse)); } public void RetryConnect(TimeSpan delay) @@ -693,6 +815,15 @@ private void FinalizeConnection(Server server, bool isSimulated) IsRestarting = false; IsConnectedToSimulated = isSimulated; + // A recovery that ran while the player was away has no connection to log against + // and nothing to promote - this session is already the active one in its slot. The + // listener reattaches the client the next time it knocks. + if (_recoveryInPlace) + { + CompleteRecoveryInPlace(); + return; + } + SiteLinkLogger.Info( isSimulated ? $"{Connection.Tag} Connected to simulated server (f=yellow){Server.Name}(f=white)!" @@ -768,6 +899,16 @@ private void ResumeAfterRecovery() private void OnDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) { + // A recovery reconnect that runs while the player is away at the door has nobody to + // inform and no fallback worth picking: every failure means the same thing, "the + // game server is not up yet". Running the normal disconnect logic here would tear + // the session down and take the player's seat with it. + if (_recoveryInPlace) + { + HandleRecoveryInPlaceFailure(disconnectInfo); + return; + } + switch (disconnectInfo.Reason) { default: @@ -896,6 +1037,7 @@ private void BeginRestartRecovery(float restartDelay, bool extendedReconnectionP : (restartDelay > 0.5f ? Math.Min(restartDelay, 30f) : 10f); _recoveryServerBack = false; + _recoveryGiveUpAt = DateTime.UtcNow.AddSeconds(RecoveryMaxSeconds); _nextShutdownRetry = DateTime.UtcNow.AddSeconds(_recoveryInitialDelay); _shutdownWaitingMessage = TranslationManager.For(this).Recovery.RestartWaiting; @@ -937,7 +1079,27 @@ private void TryFallbackServersAfterShutdown() Connection?.AsServer.Hint(unreachableMessage, 8f); - if (fallbackServers.Length == 0 || Connection == null) + // Nobody to move anywhere: the player is still away and is being told to wait at + // the door. Letting the session expire frees their slot, so their next attempt is + // routed like a fresh join instead of being delayed forever by a recovery that has + // already given up. + if (Connection == null) + { + SiteLinkLogger.Info( + $"Server (f=yellow){_shutdownRetryServer.Name}(f=white) did not recover while " + + $"(f=yellow){UserId}(f=white) was reconnecting; releasing their session.", + "Session" + ); + + _shutdownRetryServer = null; + IsRestarting = false; + + AliveUntil = DateTime.UtcNow; + ReconnectDeadline = DateTime.MinValue; + return; + } + + if (fallbackServers.Length == 0) { Disconnect(unreachableMessage); return; @@ -1005,6 +1167,7 @@ private void ConfigureShutdownRetry(Server shutdownServer) _shutdownRetryFinished = false; _recoveryInitialDelay = (float)_shutdownRetryInterval.TotalSeconds; _recoveryServerBack = false; + _recoveryGiveUpAt = DateTime.UtcNow.AddSeconds(RecoveryMaxSeconds); } private void ShowShutdownRetryStatus() @@ -1085,13 +1248,6 @@ private void UpdateShutdownRetry() if (_shutdownRetryServer == null || _shutdownRetryFinished || Status != SessionStatus.Connected) return; - // No client to reconnect on behalf of. This is the normal state for most of a - // restart: the player is away watching the game's own restart screen, and the - // preauth the retry needs went with them. Hold the recovery until they are back - // rather than burning attempts - or dereferencing a connection that is gone. - if (Connection == null) - return; - // The bridge is the only source that knows whether the game server is actually // down or merely swapping scenes, so let it drive the schedule when it is there. ApplyBridgeRecoverySignal(); @@ -1108,6 +1264,17 @@ private void UpdateShutdownRetry() return; } + // Most of a restart is spent with no client attached: the player is away on the + // game's own restart screen and is being sent away again at the listener until the + // game server is back. Reconnect this session in place instead of waiting for them, + // because if the proxy waits for the client while the client waits for the proxy, + // nobody ever comes back and the player is lost. + if (Connection == null) + { + RetryRecoveryInPlace(); + return; + } + Session retrySession = SessionManager.Singleton.CreateOrSwitchSession( Connection, new[] { _shutdownRetryServer }, @@ -1150,6 +1317,136 @@ void FinishImmediatelyAfterLastFailure() retrySession.OnBanned += _ => FinishImmediatelyAfterLastFailure(); } + /// + /// Reconnects this session to the restarting game server with no client attached. + /// + /// The attached recovery path builds a second session and swaps it in, which needs a + /// connection to hang it off. There is none while the player is away, so the session + /// reconnects itself instead: same slot, same session, new socket to the game server. + /// When the player knocks again the listener reattaches them, and the scene message the + /// proxy synthesises takes the place of the one the game server sent while nobody was + /// listening. + /// + /// + private void RetryRecoveryInPlace() + { + Server target = _shutdownRetryServer; + + if (target == null) + return; + + _shutdownRetryAttemptsMade++; + _nextShutdownRetry = DateTime.UtcNow.Add(_shutdownRetryInterval); + + _recoveryInPlace = true; + + ConnectingToServer = target; + Status = SessionStatus.Connecting; + + SiteLinkLogger.Debug( + $"Reconnecting (f=yellow){UserId}(f=white) to (f=yellow){target.Name}(f=white) while the player waits " + + $"(attempt (f=yellow){_shutdownRetryAttemptsMade}(f=white)/(f=yellow){_shutdownRetryAttempts}(f=white)).", + "Session" + ); + + Connect(); + } + + /// + /// Finishes a recovery that ran without a client: the session is back on a real game + /// server and the player's next connection attempt is allowed through. + /// + private void CompleteRecoveryInPlace() + { + _recoveryInPlace = false; + _shutdownRetryFinished = true; + _shutdownRetryServer = null; + _nextShutdownRetry = DateTime.MaxValue; + _nextRecoveryHint = DateTime.MaxValue; + _restartDropClientAt = DateTime.MaxValue; + IsRecoveryRetry = false; + + SiteLinkLogger.Info( + $"Reconnected (f=yellow){UserId}(f=white) to (f=yellow){Server?.Name}(f=white) after its restart; " + + $"letting the player back in.", + "Session" + ); + + RemoteConnection connection = Connection; + + // The client can slip back in during the last moments of the reconnect. It is then + // sitting on its own loading screen with nothing to load, so it needs the same + // handover a reattach would have given it. + if (connection == null || !ReferenceEquals(connection.Session, this)) + return; + + connection.AsServer.Scene("Facility"); + + if (Server?.IsSimulated == false) + connection.AsServer.Seed(MapSeed); + } + + /// + /// Reschedules a failed detached recovery attempt. The session stays alive and keeps + /// reporting as connected, which is what keeps the player's seat - and the recovery + /// loop - from being thrown away because a game server is a few seconds late. + /// + private void HandleRecoveryInPlaceFailure(DisconnectInfo disconnectInfo) + { + double retryIn = _shutdownRetryInterval.TotalSeconds; + + if (disconnectInfo.Reason == DisconnectReason.ConnectionRejected && + disconnectInfo.AdditionalData.RawData != null && + disconnectInfo.AdditionalData.TryGetByte(out byte rawReason)) + { + RejectionReason reason = (RejectionReason)rawReason; + + switch (reason) + { + // Answering the security challenge is part of connecting, not a failure. + case RejectionReason.Challenge: + Challenge.ProcessChallenge(disconnectInfo.AdditionalData); + return; + + // The game server is up but still delaying preauth. It just told us for + // exactly how long, so there is no reason to guess. + case RejectionReason.Delay: + if (disconnectInfo.AdditionalData.TryGetByte(out byte offset)) + retryIn = Math.Max(1, (int)offset); + break; + } + + SiteLinkLogger.Debug( + $"Restart recovery for (f=yellow){UserId}(f=white) was rejected by " + + $"(f=yellow){ConnectingToServer?.Name}(f=white) ((f=red){reason}(f=white)); " + + $"retrying in (f=yellow){retryIn:0.##}(f=white) second(s).", + "Session" + ); + } + else + { + SiteLinkLogger.Debug( + $"Restart recovery for (f=yellow){UserId}(f=white) could not reach " + + $"(f=yellow){ConnectingToServer?.Name}(f=white) ((f=red){disconnectInfo.Reason}(f=white)); " + + $"retrying in (f=yellow){retryIn:0.##}(f=white) second(s).", + "Session" + ); + } + + _recoveryInPlace = false; + + // ConnectingToServer deliberately keeps pointing at the restarting server. Clearing + // it would let Update() dequeue the next priority server and quietly move the player + // somewhere else, which is not what a restart recovery is for. + // + // Status has to read Connected again or UpdateShutdownRetry never looks at this + // session again. + Status = SessionStatus.Connected; + _nextShutdownRetry = DateTime.UtcNow.AddSeconds(retryIn); + + DestroyNet(); + } + private string FormatShutdownRetryMessage(string message) { message ??= string.Empty; diff --git a/SiteLink.API/Networking/SessionManager.cs b/SiteLink.API/Networking/SessionManager.cs index 716a028..9e2173f 100644 --- a/SiteLink.API/Networking/SessionManager.cs +++ b/SiteLink.API/Networking/SessionManager.cs @@ -9,6 +9,15 @@ public class SessionManager public static SessionManager Singleton { get; private set; } private const double DefaultSessionExpirationSeconds = 10.0; + + /// + /// How long a session survives each time its player knocks during a restart and is sent + /// away again. The client retries every few seconds, so this only has to outlive a + /// couple of missed attempts - long enough that a slow game server does not cost the + /// player their seat, short enough that a player who gave up does not hold one. + /// + private const double RestartingClientKeepAliveSeconds = 20.0; + private readonly Lazy _disconnectServer = new(() => new DisconnectServer()); public ConcurrentDictionary Slots { get; } = new(); @@ -304,6 +313,50 @@ public void FailPending(string userId, Session pending, string reason) RemoveSlotIfEmpty(userId, slot); } + /// + /// Sends a returning client away again while the game server behind its session is + /// still restarting, the same way the game server itself delays connections while it + /// boots: rejection reason 17 plus the number of seconds to wait. + /// + /// This is what keeps the player from being lost. Accepting them into a session with no + /// game server behind it leaves them on a loading screen that never finishes until the + /// client gives up; sending them away keeps them in their own reconnect loop, which is + /// the mechanism the game already has for exactly this situation. + /// + /// + /// Whether the request was rejected and must not be processed any further. + public bool TryDelayRestartingClient(ConnectionRequest request, PreAuth preAuth, NetDataWriter writer) + { + if (preAuth.UserId == null || !Slots.TryGetValue(preAuth.UserId, out SessionSlot slot)) + return false; + + byte delay; + + lock (slot) + { + Session session = slot.Active; + + if (session == null || !session.IsAwaitingRestartRecovery) + return false; + + // The client is provably still waiting, so the session has to outlive this + // attempt - and the preauth it presented is fresher than the one the session + // was created with, which matters because a preauth expires. + session.NoteClientStillWaiting(preAuth, RestartingClientKeepAliveSeconds); + + delay = session.GetConnectionDelaySeconds(); + } + + SiteLinkLogger.Debug( + $"Delaying (f=yellow){preAuth.UserId}(f=white) by (f=yellow){delay}(f=white) second(s), " + + $"their server is still restarting.", + "Session" + ); + + request.RejectWithDelay(writer, delay); + return true; + } + public bool TryReattachConnection(RemoteConnection connection) { string userId = connection.PreAuth.UserId; @@ -320,14 +373,13 @@ public bool TryReattachConnection(RemoteConnection connection) return false; s.AttachToConnection(connection); - connection.AcceptRequest(); connection.Session = s; + connection.AcceptRequest(); - // Mid-restart the proxy has no game server behind this session yet, so - // there is no facility to hand over. The client is sitting on its own - // loading screen after reconnecting; leaving it there until the recovery - // lands - and letting the game server send its own scene message then - - // beats loading the map twice. + // Normally unreachable: a client whose session is mid-restart is delayed at + // the listener before it ever gets here. If it does slip through, there is + // no facility to hand over yet - the session sends the handover itself as + // soon as its reconnect lands. if (s.IsRestarting) return true; From 738e1c185e3c03193044fed7afa3d26dc0b9dc47 Mon Sep 17 00:00:00 2001 From: Veslys Date: Sat, 1 Aug 2026 18:02:20 +0000 Subject: [PATCH 15/15] Say plainly when the bridge player count is used The count on the server list has to match the server the player actually lands on. That only exists as a single number when a listener puts everyone on one game server, so the bridge count is opt-in per listener through take_player_count_from_server and always was - the README just read as if connecting a bridge changed what every listener reports. --- README.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 39324a7..ca9a41d 100644 --- a/README.md +++ b/README.md @@ -186,15 +186,28 @@ If server is still not visbile make sure to run central command: # 🌉 SiteLink.Bridge (game server plugin) `SiteLink.Bridge` is a LabAPI plugin that connects a SCP:SL game server back to the proxy. -It is optional, but without it the proxy has to guess your player count, and with more than -one proxy in front of the same game server that guess is wrong. +It is optional. It reports the game server's real player count and what the round is +doing, both of which the proxy would otherwise have to infer from the outside. ## Why you want it Rule 5.6 of the CSG requires the data reported to the central servers — including the -player count — to be accurate. A proxy only knows about the sessions it is holding itself. -Run two proxies and each one reports its own slice, so neither number matches reality. -The bridge makes the game server report its own count, and the proxy uses that instead. +player count — to be accurate. What "accurate" means is that the number on the list +matches the server the player actually ends up on: nobody should join a 50/50 listing and +land in an empty room. + +A proxy only knows about the sessions it is holding itself. Run two proxies in front of +one game server and each reports its own slice, so neither number is the count of the +server the player will connect to. The bridge fixes that by letting the game server report +its own count. + +This only applies to a listener that puts everyone on one game server — a lobby setup. +Nothing changes unless you ask for it: the bridge count is used only when a listener sets +`server_list.take_player_count_from_server` to a server that has the bridge enabled. +Leave that setting empty and the listener reports its own session count exactly as before. + +A listener that routes players across several servers has no single number to report, so +it should not use this at all; put the individual counts in the server name instead. Dummies and the host are never counted. @@ -292,8 +305,9 @@ handshake and its console says why on the same line, for example: The key lengths are printed instead of the keys, which is usually enough to spot a trailing space or a mismatched value. -When the bridge is connected, the proxy reports the game server's count. If the bridge goes -away, the proxy warns once and falls back to its own session count after 30 seconds. +With `take_player_count_from_server` pointed at a bridged server, the proxy reports that +game server's count. If the bridge goes away, the proxy warns once and falls back to its +own session count for that server after 30 seconds. ## Round state