The last commit noted in passing that the server-side half of the admin
GameScript channel "is not in this repo -- docker/config is gitignored --
so it has to be updated separately for any of this to work." That was true
of all nine features the README documents: list_vehicles, list_stations,
list_cargo, get_timetable, get_station, get_station_cargo, get_dispatch and
the event stream all answer from 705 lines of Squirrel that no clone could
reproduce, no reviewer could see, and CI never touched.
The bridge now lives in gamescript/AdminBridge/ with its own README, the
same arrangement docker/patches/ uses for the local JGRPP patches, and
docker-compose.yml bind-mounts it read-only over the container's
game/AdminBridge. docker/config stays ignored -- it also holds savegames,
downloaded content and generated config -- so the copy under it is now
shadowed and can be deleted. main.nut is byte-identical to what was running,
apart from the version work below.
Adds a version handshake, because the channel gives no way to tell a stale
bridge from a hung one: a bridge that does not recognise a command drops it
silently, so a client ahead of the server sees nothing but timeouts. The
bridge now answers get_version with its protocol version plus its command
and event catalogues, and get_bridge_version() raises when that is below
GS_BRIDGE_VERSION. It is opt-in rather than checked on connect: GameScripts
do not tick while the game is paused, so an automatic check would refuse to
connect to a paused server. A bridge older than 4 predates get_version
itself and can only fail by timing out, so the E2E test catches that and
reports it by name instead.
tests/test_gamescript.py gives CI a foothold on the GameScript without a
Squirrel toolchain: it parses the .nut files and pins the protocol version
across info.nut, main.nut and protocol.py, the event catalogue against
GameEventType, and the command table against the commands client.py sends.
The version has to be declared three times because a GameScript cannot read
its own info.nut at runtime -- GSController.GetVersion() returns the OpenTTD
version, not the script's.
info.nut also gains MinVersionToLoad() { return 1; }. The engine defaults it
to GetVersion(), so without it this bump would orphan every savegame pinned
to version 3: the scanner finds no compatible script and falls back with a
warning. The bridge keeps no savegame state, so any version can take over.
HandleCommand now dispatches through the same table get_version reports,
rather than an if/else chain, so the catalogue a client feature-detects
against cannot drift from what is implemented.
Co-Authored-By: Claude <[email protected]>
3.3 KiB
3.3 KiB
Architecture and Design
This project follows a strict Object-Oriented approach to manage the complexity of the OpenTTD network protocol.
Component Overview
1. OpenTTDProtocol (Low-Level)
Inherits from openttd_protocol.wire.tcp.TCPProtocol.
- Responsibility: Binary serialization/deserialization and stream encryption.
- Encryption Layer: It overrides
send_packetandreceive_packetto wrap/unwrap AEAD (XChaCha20-Poly1305) payloads. - Manual Dispatch: Uses a manual dispatch mechanism to ensure that even encrypted packets are correctly mapped to their high-level handlers.
2. OpenTTDClient (High-Level)
The primary API for developers.
- Responsibility: Handshake orchestration, state management, and keep-alive.
- Event-Driven: Uses
asyncio.Event(likeself.joined) to signal state changes to the calling code. - Callback System: Provides hooks like
on_chatto allow users to react to game events without modifying the core library.
3. OpenTTDAdminClient (Admin Network)
Talks to the Admin port (TCP 3977) and, through the AdminBridge GameScript's JSON channel, to the running game itself.
- Correlated Queries:
get_timetable(),get_station(),get_dispatch()and friends all funnel through_gs_query(), which tags each request with a monotonicrequest_id, parks a future, and letsreceive_ServerGamescriptresolve it when the matching reply arrives. - Two Halves: the queries above only work because a matching GameScript is running on the server. That script is part of this project, in
gamescript/AdminBridge/, and the client'sget_bridge_version()checks the running copy is new enough for what it is about to send. - Push Events:
subscribe_events()opens the one stream that flows the other way. Event batches carry norequest_id, soreceive_ServerGamescriptroutes them to the event consumers instead: each event goes to the longest-waiting matchingwait_for_event()caller, or into a bounded buffer if nobody is waiting, and to theon_eventobserver either way. See EVENTS.md.
Handshake Flow
- Connection: TCP connection established to Port 3979.
- Information:
ClientGameInfosent to verify server version. - Join:
ClientJoinsent with the specific JGRPP revision string. - Authentication: Server sends
ServerAuthenticationRequest(Type 1: PAKE). - PAKE Exchange:
- Shared Secret derived via X25519.
- Session Keys derived via Blake2b hashing of (SharedSecret + ServerPub + OurPub + Password).
- Encrypted challenge sent back via
ClientAuthenticationResponse.
- Encryption: Server sends
ServerEnableEncryption. The Protocol layer activates the AEAD stream. - Identification:
ClientIdentifysent (now encrypted). - Map Synchronization:
ServerWelcomereceived ->ClientGetMapsent -> Map segments received ->ClientMapOksent. - Active State:
client.joinedis set. The client responds toServerFramewithClientAckto prevent timeouts.
Error Handling
- Shutdown Event: Every fatal error or manual quit triggers a
shutdown_event. - Graceful Exit: The
quit()method sends aClientQuitpacket before closing the transport. - Fallback: Unknown packets are caught and mapped to
receive_ServerUnusedto prevent the simulation loop from crashing.