Matching Clients and Servers
[!SUMMARY] This page explains:
- Why a client and a server from different builds break each other instead of refusing to connect
- How builds are stamped, grouped and checked so that matching pairs are easy to find
- What to do when a mismatched session breaks
- Why builds from the same commit still disagreed in October 2026, and the rule that follows from it
A client and a server that were built from different commits do not refuse each other on their
own. They connect, and then misread each other's messages: FishNet numbers RPCs by their position
within a NetworkBehaviour rather than by name, so one added or removed RPC renumbers everything
below it and the receiving side deserialises a payload with the wrong method's parameter list. The
1.0.4 testing pair failed exactly this way — the server read a player name where it expected a lobby
Guid, logged Size of -50 is invalid, and kicked the client with unhandled PacketId.
Three things now keep that from happening. A fourth trap, where even builds from the same commit disagree, is described at the end of this page.
Every player knows which commit it is
BuildCommand stamps the commit into the player (Assets/Resources/build-stamp.json, generated per
build and deleted afterwards) and writes build-info.json next to the executable, inside the .zip,
the .tar.gz and the server Docker image. The .apk cannot carry a loose file, so its
build-info.json is placed beside it in Dist/.
Every Player.log now opens with the build it came from:
[BuildStamp] Windows 1.0.4, commit a1b2c3d, built 2026-09-21T16:30:00Z, Unity 6000.3.8f1
Downloads are grouped per commit
Testing pipelines upload to Testing/<branch>/<commit>/, pre-releases to
Pre-Release/<X.Y.Z-pre.N>/, never to a shared name. A latest.json next to those folders names
the newest folder for which every platform job succeeded and reported the same commit — take
the client and the server from the folder it names and they are guaranteed to belong together.
Releases go to Build Archive/<X.Y.Z>/. Only once every platform has passed that check does the
publish stage copy them into Public/, all at once, where they keep the same names from release to
release — that is what the website's static download links point at.
The server image works the same way: build-server pushes itlfi/anywhere-academy-server:staging-<commit>-<pipeline>,
and the deployable tags (:pre-release, or :X.Y.Z, :latest and :stable) are moved onto it
only after the same check. The staging tag is deleted again once it has been promoted. How those
tags reach the hosted servers is described in Server Images.
The server warns about a mismatched client
BuildStampAuthenticator exchanges commits before a connection is authenticated. A mismatch is
logged as a warning on each side, naming both builds, and the connection goes through:
[BuildStamp] Build mismatch: client '0' (Windows 1.0.4 @ a1b2c3d...) was built from commit
'a1b2c3d...', this server from 'e4f5a6b...'. Accepting it: ...
It is not rejected, because a client-only change (UI, settings, art) is expected to work against the previous server, and a commit comparison cannot tell such a change apart from one that touched networked code. When the session then breaks with reader errors or "unhandled PacketId" kicks, this warning is the first line to look for.
A player with no stamp (a build made outside the build pipeline, or from a checkout git could not
read) and a client from before this check, which never reports its build, are let through with a
warning as well. The latter only after a 15-second wait for the report it never sends.
If a mismatched session breaks
- Read the commit from the client's
Player.logand from the server's log. - Download both from the same
Testing/<branch>/<commit>/folder: the client, and theAnywhere_Academy_Server.tar.gznext to it. (There is no per-commit Docker tag to deploy; the:staging-<commit>-<pipeline>tag is deleted once it has been promoted.) - Re-run the whole pipeline rather than a single job: a retried job builds from the current branch head, which may no longer be the commit the other platforms were built from. The publish stage stops a pipeline whose platforms disagree, so this shows up as a failed job rather than as a broken download.
Incident: same commit, different build OS (October 2026)
A matching commit is not always enough. In October 2026 a Windows client and a Linux server, both built by CI from the same commit, failed exactly like the 1.0.4 pair. The Quest APK, also built on Linux, worked with the same server.
Cause. Three behaviours combined:
- Unity sorts the source files of an assembly before compiling them, and the sort depends on the
OS the editor runs on (
NiceIO.NPath): case-sensitive on Linux, case-insensitive on Windows and macOS.GlobalPlayerController.Server.cstherefore comes beforeGlobalPlayerController.cson Linux (S<c) and after it on Windows. - The compiler emits the members of a
partialclass in source file order. - FishNet numbers RPCs and SyncTypes by member position (see above), not by name.
AnyVR's GlobalPlayerController declares its RPCs across two partial files, so its RPC ids
differed between Windows and Linux builds. A Windows client's ServerRPC_SetPlatformType (id 4)
reached the Linux server as ServerRPC_JoinLobby, which read the following packet as a lobby
Guid — the "lobby id" in the server log contained bytes of the player name.
The commit check above could not catch this: both builds carried the same commit. It was
found by reading the RPC ids FishNet wove into AnyVR.LobbySystem.dll of both builds (with Mono.Cecil)
and comparing them.
Resolution. AnyVR's next update stops splitting the RPCs of a NetworkBehaviour across
partial files. A FishNet patch that numbers members by name instead was tested and works across
OSes (commit e0f52b74, Packages/com.firstgeargames.fishnet/PATCHES.md), but was not adopted.
Prevention. Every build job now records the network ids FishNet wove into its player
(Tools/Build/network-table.ps1 → Build/Artifacts/<target>/network-table.txt, and its SHA-256 as
networkTableSha256 in build-info.json). The publish stage compares them across platforms
(Tools/Build/compare-network-tables.ps1) and fails before anything is published if one networked
type has different ids on different platforms, naming the type and both id lists.
Warning
Declare all RPCs and SyncTypes (SyncVar, SyncList, …) of a NetworkBehaviour in one
file. Splitting them across partial files makes their network ids depend on the OS that builds
the player, and the CI builds the Windows client on Windows and everything else on Linux.