Files
openttd-client/docs/PROTOCOL.md
kovagoadi 3b54a722d6
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 33s
Continuous Integration / tests-and-coverage (pull_request) Successful in 26s
Added real timetable support
2026-07-23 20:59:28 +02:00

8.5 KiB

Protocol Internals

This client supports the modern OpenTTD Game Port protocol (TCP 3979), specifically as implemented in JGRPP.

X25519 PAKE Authentication

OpenTTD 14+ and JGRPP use a Password-Authenticated Key Exchange to prevent plaintext password leakage.

Key Derivation (KDF)

We use Blake2b (64-byte digest) to derive two 32-byte session keys.

  • Input: SharedSecret (32) + ServerPublicKey (32) + ClientPublicKey (32) + Password (string)
  • Output:
    • 0..31: Client-to-Server Key
    • 32..63: Server-to-Client Key

Handshake Nonces

The server provides a 24-byte nonce in the ServerAuthenticationRequest. This nonce is used for the AEAD challenge during the auth response and for the initial stream encryption setup.

Admin Network (TCP 3977)

The Admin Network allows external applications to monitor and control the server. It supports both unsecured and secure (X25519 PAKE) authentication.

Secure Authentication

Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authentication.

  • Packet: AdminJoinSecure starts the handshake.
  • Encryption: Once enabled via ServerEnableEncryption, all subsequent traffic is encrypted using XChaCha20-Poly1305.

Update Frequencies

Admins can subscribe to various updates (Date, Client Info, Company Info, etc.) at different frequencies (Poll, Daily, Weekly, Monthly, Quarterly, Annually, Automatic).

Vehicle Listing

The Admin Network has no native packet or AdminUpdateType for listing individual vehicles — ServerCompanyStats only reports aggregate per-company vehicle counts (trains/lorries/buses/planes/ships). To retrieve an actual vehicle list, this client sends a list_vehicles command over the GameScript JSON channel (AdminGamescript/ServerGamescript) via list_vehicles(). This requires a companion GameScript running server-side that understands the list_vehicles command and replies with vehicle data through ServerGamescript.

Important: the server only forwards ServerGamescript packets to admins that have subscribed with update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic) (enforced server-side in NetworkAdminGameScript, which checks update_frequency[ADMIN_UPDATE_GAMESCRIPT]). Call update_frequency() for Gamescript before list_vehicles(), or the response is silently dropped.

When a list_vehicles request carries a request_id field, the AdminBridge GameScript echoes it back in the reply (backward compatible: absent otherwise).

Timetable Query

The stock GameScript API has no timetable getters, so this project patches the server (see docker/patches/) to add read-only getters to GSOrder (GetTimetableWaitTime, GetTimetableTravelTime, IsWaitTimetabled, IsTravelTimetabled, IsWaitFixed, IsTravelFixed, GetLeaveType, GetTimetableMaxSpeed, GetTimetableLateness, GetTimetableStartTick, GetCurrentOrderTime, GetTimetableTotalDuration). On top of that, get_timetable() sends a request over the same GameScript JSON channel as vehicle listing and awaits the correlated reply — an authoritative snapshot of the live game state, unlike the passive observer on the game port (see below).

  • Request: {"command": "get_timetable", "vehicle_id": N, "request_id": X}request_id is a client-side monotonic counter used to match the reply to the awaiting caller.
  • Reply (success): {"command": "get_timetable", "vehicle_id": N, "request_id": X, "lateness": ..., "start_tick": ..., "current_order_time": ..., "total_duration": ..., "orders": [{"position", "wait_time", "travel_time", "wait_timetabled", "travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"}, ...]} (booleans encoded as 0/1).
  • Reply (error): same envelope with an "error" field instead of the data: "invalid_vehicle" (no such vehicle) or "response_too_large" (the reply exceeded the admin packet size limit, possible with very many orders). get_timetable() raises ValueError for these.

Replies carrying a request_id that matches a pending request resolve that request and are not delivered to the on_gamescript callback; all other ServerGamescript traffic reaches the callback unchanged. The same update_frequency subscription requirement applies (get_timetable() subscribes automatically on first use). Since GameScripts do not tick while the game is paused, a query against a paused server times out (asyncio.TimeoutError).

Vehicle Timetables (Game Port DoCommands)

Unlike vehicle listing, timetables have no stock GameScript API surface (this project adds read-only getters via a server patch — see "Timetable Query" above; writing still has none). Reading and modifying them requires real engine commands (DoCommands) sent over the game port (TCP 3979) via ClientCommand/ServerCommand packets, not the Admin Network. This section covers the wire format; for how to call the methods and what each parameter means, see the Vehicle Timetables Usage Guide.

Command envelope

Both ClientCommand and ServerCommand share this body: company (uint8), cmd (uint16 LE, index into the Commands enum), error_msg (uint16 LE, StringID, use 0), tile (uint32 LE, always 0 for these commands), payload_len (uint16 LE), payload (payload_len bytes), callback (uint8, use 0), callback_param (uint32 LE, only present if callback != 0). ServerCommand additionally appends frame (uint32 LE) and my_cmd (uint8 bool), and is a broadcast echo of the request (no success/failure code) sent to every joined client, not just the sender.

Timetable command IDs and payload tuples

Method cmd payload
change_timetable() 174 (ChangeTimetable) VehicleID (varuint), VehicleOrderID (uint16), ModifyTimetableFlags (uint8), value (varuint), ModifyTimetableCtrlFlags (uint8)
set_vehicle_on_time() 176 (SetVehicleOnTime) VehicleID (varuint), apply_to_group (uint8 bool)
autofill_timetable() 177 (AutofillTimetable) VehicleID (varuint), bool (uint8), bool (uint8)
set_timetable_start() 180 (SetTimetableStart) VehicleID (varuint), bool (uint8), StateTicks (signed varuint)

VehicleID and other 4/8-byte fields use OpenTTD's custom varuint scheme (write_varuint/read_varuint in protocol.py) — a UTF-8-like prefix encoding, not LEB128; signed fields (StateTicks) use zigzag on top of it (write_varuint_signed/read_varuint_signed).

Ownership requirement

A command is rejected (and the client kicked) unless it's issued by the company that owns the target vehicle — join that company via join_company() with a real company id (not 255/spectator) before calling any timetable method.

Reading timetables — no query command exists on the game port

There is no getter DoCommand for orders/timetables anywhere in the protocol. get_vehicle_timetable() works by passively decoding ServerCommand broadcasts (including the sender's own) as they arrive — it only reflects changes made after the client joined. A vehicle's pre-existing timetable (set before this client connected) is invisible until something changes it again; seeing it upfront would require parsing the ORDR/VEHS chunks of the initial savegame transfer (ServerMapData), which this client does not implement. For an authoritative read, use the Admin Network's get_timetable() instead (see "Timetable Query" above).

Stream Encryption (AEAD)

Once ServerEnableEncryption is received, all subsequent packets use XChaCha20-Poly1305 (Authenticated Encryption with Associated Data).

Encrypted Packet Format

On the wire, encrypted packets have the following structure:

  1. Length (2 bytes): Big-endian uint16 of the entire remaining packet.
  2. MAC (16 bytes): The Poly1305 authentication tag.
  3. Ciphertext (variable): The encrypted payload.

Decryption Logic

The OpenTTDProtocol layer uses an IncrementalAuthenticatedEncryption state from the Monocypher library. It maintains the nonce state internally. If a MAC check fails (indicating corruption or a wrong key), the client immediately closes the connection (SocketClosed).

Keep-Alive (Simulation Synchronization)

OpenTTD is a lockstep simulation. The server sends ServerFrame packets periodically.

  • Client Requirement: You must respond with a ClientAck containing the frame number and a one-time token provided in the frame packet.
  • Timeout: If the server does not receive an ACK for several in-game days, it will disconnect the client with error code 17 (TimeoutComputer).