Skip to content

feat: SiteLink.Bridge plugin, accurate player counts and restarts that keep the player - #10

Open
Veslydev wants to merge 16 commits into
Killers0992:mainfrom
Veslydev:feat/bridge-plugin-release
Open

feat: SiteLink.Bridge plugin, accurate player counts and restarts that keep the player#10
Veslydev wants to merge 16 commits into
Killers0992:mainfrom
Veslydev:feat/bridge-plugin-release

Conversation

@Veslydev

@Veslydev Veslydev commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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 is ip / port /
    secret_key; SiteLinkBridge.IsConnected and SiteLinkBridge.TargetServers
    are available to other plugins; sl_bridge reports status in the console.
  • One bridge endpoint serves every game server. Thirty servers connect to the
    same port and are told apart by their key, so the operator configures one
    host/port and nothing else.
  • Multiple proxies can be bridged at once. The plugin reports to all of them.
  • A bridge that connects late does not leave a stale player count behind: the
    first report replaces whatever the proxy was reporting on its own.
  • release.yml now publishes dependencies.zip (net48 SiteLink.API.dll
    LabAPI/dependencies/global) and SiteLink.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.

  • The player is no longer lost across a restart. The recovery refused to
    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
    Delay rejection carrying the number of seconds to wait - the same answer a
    vanilla server gives while it boots. By the time the player is let back in
    there is a facility waiting for them.
  • The wait is no longer as long as the game server's own. The offset the
    game server picks describes how long it needs; the client is reconnecting
    to the proxy, which never went down. Forwarding full_restart_rejoin_time
    verbatim meant 28 seconds of black screen after sr.
  • A restart looks like a restart. A fast restart carries no countdown of
    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.
  • The recovery no longer kicks the player it is recovering, and it tells them
    what is happening while they wait instead of leaving them to assume the
    server froze.

Fixes

  • Batch length prefixes were written with the wrong variable-length encoding,
    which corrupted any batch at the size boundary.
  • Server-specific settings (servers_in_selector) were not serialized
    correctly, so the per-server settings never reached the client.
  • The idle-mode player count starved.
  • Includes fix(updater): fallback to GitHub API for release checking #9 (versioning and the auto-updater's GitHub API fallback), so the
    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.

Veslydev and others added 16 commits July 29, 2026 16:29
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant