feat: SiteLink.Bridge plugin, accurate player counts and restarts that keep the player - #10
Open
Veslydev wants to merge 16 commits into
Open
feat: SiteLink.Bridge plugin, accurate player counts and restarts that keep the player#10Veslydev wants to merge 16 commits into
Veslydev wants to merge 16 commits into
Conversation
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 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.
Dropping it was wrong. LiteNetLib inside Assembly-CSharp exposes ReadOnlySpan<T> 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.
…e 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<T>, 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
…ted 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.
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.
…unts 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.
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.
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.
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.
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.
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.
The updater and the bridge release both describe what version this build is, so they ship together rather than racing each other through main.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SiteLink.Bridge
A LabAPI plugin that runs on the game server and tells the proxy things the
proxy cannot observe from the outside.
Why it exists. With two proxies in front of one game server, neither of
them knows the other's sessions, so both report a player count that is wrong.
CSG rule 5.6 requires the number reported to the central servers to be
accurate, and Northwood's answer was to report the game server's own count
rather than the proxy's. That is what the bridge does. Dummies are excluded,
since they are not players.
The proxy also had to guess at the game server's state. A restart looked
exactly like a crash from the outside, so the recovery treated it like one.
The bridge reports the round state instead - restart type, idle mode, and
whether the server is accepting connections yet - so the recovery waits for
the server to actually come back instead of burning attempts against a
process that is still booting.
SiteLink.Bridge(net48, LabAPI plugin): config isip/port/secret_key;SiteLinkBridge.IsConnectedandSiteLinkBridge.TargetServersare available to other plugins;
sl_bridgereports status in the console.same port and are told apart by their key, so the operator configures one
host/port and nothing else.
first report replaces whatever the proxy was reporting on its own.
release.ymlnow publishesdependencies.zip(net48SiteLink.API.dll→LabAPI/dependencies/global) andSiteLink.Bridge.dll→LabAPI/plugins/global, so the plugin installs through the plugin manager.Restarts
Found while testing the above, and the reason it took several betas to settle.
reconnect 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. The session now reconnects itself
in place with no client attached, and the client is sent away with a
Delayrejection carrying the number of seconds to wait - the same answer avanilla server gives while it boots. By the time the player is let back in
there is a facility waiting for them.
game server picks describes how long it needs; the client is reconnecting
to the proxy, which never went down. Forwarding
full_restart_rejoin_timeverbatim meant 28 seconds of black screen after
sr.its own, so forwarding it through a proxy left the player with no restart
screen at all. It is rewritten as a full restart with an offset.
what is happening while they wait instead of leaving them to assume the
server froze.
Fixes
which corrupted any batch at the size boundary.
servers_in_selector) were not serializedcorrectly, so the per-server settings never reached the client.
version this build reports matches what the updater looks for. That PR can
be closed in favour of this one.
Verification
Both projects build clean. The bridge, the player count, the server-specific
settings and the restart flow were tested manually against a live game server
on a test port.