Files
openttd-client/lib/openttd/protocol.py
T
kovagoadiandClaude 25953bea06
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
Fix ruff lint findings across client, protocol, and tests
Resolve 51 findings from the I/RUF/BLE/TRY002/S110/PLR0402 rule set:

- Sort imports and __all__ (I001, RUF022, PLR0402). The sys.path.insert
  calls in check_public_calls.py and tests/test_e2e.py still precede the
  openttd imports that depend on them.
- Replace unused unpacked values with _ (RUF059) and annotate the two
  timetable lookup tables as ClassVar (RUF012).
- Narrow the best-effort excepts in OpenTTDClient.quit and
  OpenTTDAdminClient.quit to (OSError, SocketClosed) and log at debug
  rather than swallowing silently (BLE001, S110). The test doubles now
  raise an OSError subclass so they still exercise that branch.
- Narrow the gamescript JSON fallback to json.JSONDecodeError. The broad
  catch in receive_packet keeps a noqa: it guards untrusted wire data and
  must degrade to a no-op packet instead of killing the connection.
- Use contextlib.suppress instead of try/except/pass in tests.

ruff check . is clean, 102 tests pass, coverage stays at 100%.

Co-Authored-By: Claude <[email protected]>
2026-08-26 20:57:56 +02:00

589 lines
19 KiB
Python

import struct
from enum import IntEnum
import monocypher
from openttd_protocol.wire.exceptions import SocketClosed
from openttd_protocol.wire.read import read_string, read_uint8, read_uint16, read_uint32
from openttd_protocol.wire.tcp import TCPProtocol
def write_varuint(buffer, value):
"""Encode a non-negative integer using OpenTTD's UTF-8-like varuint scheme."""
if value < 0:
raise ValueError("write_varuint requires a non-negative value")
thresholds = [1 << 7, 1 << 14, 1 << 21, 1 << 28, 1 << 35, 1 << 42, 1 << 49, 1 << 56]
for extra, limit in enumerate(thresholds):
if value < limit:
header_ones = (0xFF << (8 - extra)) & 0xFF
header = header_ones | (value >> (extra * 8))
buffer.append(header)
for i in range(extra - 1, -1, -1):
buffer.append((value >> (i * 8)) & 0xFF)
return
buffer.append(0xFF)
for i in range(7, -1, -1):
buffer.append((value >> (i * 8)) & 0xFF)
def read_varuint(data):
"""Decode a varuint written by write_varuint. Returns (value, rest)."""
header = data[0]
mask = 0x80
extra = 0
while header & mask:
extra += 1
mask >>= 1
value = header & (0x7F >> extra)
rest = data[1:]
for i in range(extra):
value = (value << 8) | rest[i]
return value, rest[extra:]
def write_varuint_signed(buffer, value):
"""Encode a signed integer using zigzag + write_varuint."""
zigzag = (value << 1) ^ (-1 if value < 0 else 0)
write_varuint(buffer, zigzag)
def read_varuint_signed(data):
"""Decode a signed varuint written by write_varuint_signed. Returns (value, rest)."""
zigzag, rest = read_varuint(data)
value = (zigzag >> 1) ^ -(zigzag & 1)
return value, rest
class GameCommand(IntEnum):
DeleteOrder = 51
InsertOrder = 52
ChangeTimetable = 174
SetVehicleOnTime = 176
AutofillTimetable = 177
SetTimetableStart = 180
# Scheduled dispatch (JGRPP)
SchDispatch = 205
SchDispatchAdd = 206
SchDispatchRemove = 207
SchDispatchSetDuration = 208
SchDispatchSetStartDate = 209
SchDispatchClear = 213
SchDispatchAddNewSchedule = 214
SchDispatchRemoveSchedule = 215
# Sentinel VehicleOrderID meaning "append to the end of the order list" for InsertOrder.
INVALID_VEH_ORDER_ID = 0xFFFF
class OrderType(IntEnum):
"""OrderType occupies bits 0-3 of an order's `type` byte (bits 6-7 hold OrderNonStopFlags)."""
GotoStation = 1
GotoDepot = 2
GotoWaypoint = 6
class OrderNonStopFlags(IntEnum):
"""Packed into bits 6-7 of an order's `type` byte."""
StopEverywhere = 0
NoStopAtIntermediate = 1
NoStopAtDestination = 2
NoStopAtAny = 3
class OrderStopLocation(IntEnum):
"""Packed into bits 4-5 of an order's `type` byte. Near-end/middle/through are train-only;
FarEnd is the only value the server accepts for every vehicle type, so it is the safe default."""
PlatformNearEnd = 0
PlatformMiddle = 1
PlatformFarEnd = 2
PlatformThrough = 3
class ModifyTimetableFlags(IntEnum):
WaitTime = 0
TravelTime = 1
TravelSpeed = 2
SetWaitFixed = 3
SetTravelFixed = 4
SetLeaveType = 5
AssignSchedule = 6
class ModifyTimetableCtrlFlag(IntEnum):
ClearField = 1 << 0
class PacketGameType(IntEnum):
ServerFull = 0
ServerBanned = 1
ClientJoin = 2
ServerError = 3
ClientUnused = 4
ServerUnused = 5
ServerGameInfo = 6
ClientGameInfo = 7
ServerNewGame = 8
ServerShutdown = 9
ServerGameInfoExtended = 10
ServerAuthenticationRequest = 11
ClientAuthenticationResponse = 12
ServerEnableEncryption = 13
ClientIdentify = 14
ServerCheckNewGRFs = 15
ClientNewGRFsChecked = 16
ServerNeedCompanyPassword = 17
ClientCompanyPassword = 18
ClientSettingsPassword = 19
ServerSettingsAccess = 20
ServerWelcome = 21
ServerClientInfo = 22
ClientGetMap = 23
ServerWaitForMap = 24
ServerMapBegin = 25
ServerMapSize = 26
ServerMapData = 27
ServerMapDone = 28
ClientMapOk = 29
ServerClientJoined = 30
ServerFrame = 31
ClientAck = 32
ServerSync = 33
ClientCommand = 34
ServerCommand = 35
ClientChat = 36
ServerChat = 37
ServerExternalChat = 38
ClientQuit = 47
ServerCompanyUpdate = 45
PACKET_END = 100
class PacketAdminType(IntEnum):
AdminJoin = 0
AdminQuit = 1
AdminUpdateFrequency = 2
AdminPoll = 3
AdminChat = 4
AdminRcon = 5
AdminGamescript = 6
AdminPing = 7
AdminExternalChat = 8
AdminJoinSecure = 9
AdminAuthResponse = 10
ServerFull = 100
ServerBanned = 101
ServerError = 102
ServerProtocol = 103
ServerWelcome = 104
ServerNewGame = 105
ServerShutdown = 106
ServerDate = 107
ServerClientJoin = 108
ServerClientInfo = 109
ServerClientUpdate = 110
ServerClientQuit = 111
ServerClientError = 112
ServerCompanyNew = 113
ServerCompanyInfo = 114
ServerCompanyUpdate = 115
ServerCompanyRemove = 116
ServerCompanyEconomy = 117
ServerCompanyStats = 118
ServerChat = 119
ServerRcon = 120
ServerConsole = 121
ServerCmdNames = 122
ServerCmdLoggingOld = 123
ServerGamescript = 124
ServerRconEnd = 125
ServerPong = 126
ServerCmdLogging = 127
ServerAuthRequest = 128
ServerEnableEncryption = 129
PACKET_END = 130
class AdminUpdateFrequency(IntEnum):
Poll = 1 << 0
Daily = 1 << 1
Weekly = 1 << 2
Monthly = 1 << 3
Quarterly = 1 << 4
Annually = 1 << 5
Automatic = 1 << 6
class AdminUpdateType(IntEnum):
Date = 0
ClientInfo = 1
CompanyInfo = 2
CompanyEconomy = 3
CompanyStats = 4
Chat = 5
Console = 6
CmdNames = 7
CmdLogging = 8
Gamescript = 9
End = 10
class NetworkAuthenticationMethod(IntEnum):
X25519_KeyExchangeOnly = 0
X25519_PAKE = 1
X25519_AuthorizedKey = 2
class OpenTTDProtocol(TCPProtocol):
"""Low-level OpenTTD TCP protocol handler with encryption support."""
PacketType = PacketGameType
PACKET_END = PacketGameType.PACKET_END
def __init__(self, handler):
super().__init__(handler)
self.handler = handler
def receive_packet(self, source, data):
try:
if self.handler.encryption_enabled:
if not self.handler._recv_aead:
self.handler._recv_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_recv, self.handler._encryption_nonce)
_, rest = read_uint16(data)
payload = self.handler._recv_aead.unlock(bytes(rest[:16]), bytes(rest[16:]))
if payload is None:
raise SocketClosed("Decryption failed")
data = memoryview(struct.pack("<H", len(payload) + 2) + payload)
return super().receive_packet(source, data)
except Exception: # noqa: BLE001 - untrusted wire data: any decode failure must degrade to a no-op packet rather than kill the connection
return PacketGameType.ServerUnused, {}
async def send_packet(self, data):
if self.handler.encryption_enabled:
if not self.handler._send_aead:
self.handler._send_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_send, self.handler._encryption_nonce)
_, payload = read_uint16(memoryview(data))
mac, ciphertext = self.handler._send_aead.lock(payload.tobytes())
data = struct.pack("<H", 18 + len(ciphertext)) + mac + ciphertext
await self._can_write.wait()
if self.transport.is_closing():
raise SocketClosed
self.transport.write(data)
return len(data)
# --- Static Parsers ---
@staticmethod
def receive_ServerGameInfo(source, data):
from openttd_protocol.protocol.game import GameProtocol
return GameProtocol.receive_PACKET_SERVER_GAME_INFO(source, data)
@staticmethod
def receive_ServerError(source, data):
ec, _ = read_uint8(data)
return {"error_code": ec}
@staticmethod
def receive_ServerAuthenticationRequest(source, data):
at, rest = read_uint8(data)
return {"auth_type": at, "data": rest}
@staticmethod
def receive_ServerEnableEncryption(source, data): return {"data": data}
@staticmethod
def receive_ServerCheckNewGRFs(source, data): return {}
@staticmethod
def receive_ServerUnused(source, data): return {}
@staticmethod
def receive_ServerWelcome(source, data):
cid, _ = read_uint32(data)
return {"client_id": cid}
@staticmethod
def receive_ServerNeedCompanyPassword(source, data):
seed, data = read_uint32(data)
sid, _ = read_string(data)
return {"seed": seed, "server_id": sid}
@staticmethod
def receive_ServerFrame(source, data):
f, data = read_uint32(data)
_, data = read_uint32(data)
token = 0
if len(data) > 0:
if len(data) >= 13:
data = data[12:]
token, _ = read_uint8(data)
return {"frame": f, "token": token}
@staticmethod
def receive_ServerChat(source, data):
_, data = read_uint8(data)
cid, data = read_uint32(data)
_, data = read_uint8(data)
msg, _ = read_string(data)
return {"client_id": cid, "message": msg}
@staticmethod
def receive_ServerCompanyUpdate(source, data):
mask, _ = read_uint16(data)
return {"passworded_mask": mask}
@staticmethod
def receive_ServerMapDone(source, data): return {}
@staticmethod
def receive_ServerClientInfo(source, data): return {}
@staticmethod
def receive_ServerSync(source, data): return {}
@staticmethod
def receive_ServerClientJoined(source, data): return {}
@staticmethod
def receive_ServerMapBegin(source, data): return {}
@staticmethod
def receive_ServerMapSize(source, data): return {"size": 0}
@staticmethod
def receive_ServerMapData(source, data): return {"data": data}
@staticmethod
def receive_ServerConfigurationUpdate(source, data): return {}
@staticmethod
def receive_ServerExternalChat(source, data): return {}
@staticmethod
def receive_ServerCommand(source, data):
company, data = read_uint8(data)
cmd, data = read_uint16(data)
error_msg, data = read_uint16(data)
tile, data = read_uint32(data)
payload_len, data = read_uint16(data)
payload = data[:payload_len]
data = data[payload_len:]
callback, data = read_uint8(data)
callback_param = 0
if callback != 0:
callback_param, data = read_uint32(data)
frame, data = read_uint32(data)
my_cmd, _ = read_uint8(data)
return {
"company": company, "cmd": cmd, "error_msg": error_msg, "tile": tile,
"payload": payload, "callback": callback, "callback_param": callback_param,
"frame": frame, "my_cmd": bool(my_cmd)
}
@staticmethod
def receive_ServerFull(source, data): return {}
@staticmethod
def receive_ServerBanned(source, data): return {}
@staticmethod
def receive_ClientAck(source, data):
f, data = read_uint32(data)
t, _ = read_uint8(data)
return {"frame": f, "token": t}
@staticmethod
def receive_ClientIdentify(source, data): return {}
class OpenTTDAdminProtocol(OpenTTDProtocol):
"""Low-level OpenTTD Admin TCP protocol handler."""
PacketType = PacketAdminType
PACKET_END = PacketAdminType.PACKET_END
@staticmethod
def receive_ServerProtocol(source, data):
v, data = read_uint8(data)
updates = {}
while True:
has_more, data = read_uint8(data)
if not has_more:
break
ut, data = read_uint16(data)
freqs, data = read_uint16(data)
updates[ut] = freqs
return {"version": v, "updates": updates}
@staticmethod
def receive_ServerWelcome(source, data):
name, data = read_string(data)
ver, data = read_string(data)
dedi, data = read_uint8(data)
map_name, data = read_string(data)
seed, data = read_uint32(data)
land, data = read_uint8(data)
date, data = read_uint32(data)
width, data = read_uint16(data)
height, _ = read_uint16(data)
return {
"server_name": name, "openttd_version": ver, "dedicated": bool(dedi),
"map_name": map_name, "generation_seed": seed, "landscape": land,
"start_date": date, "map_width": width, "map_height": height
}
@staticmethod
def receive_ServerDate(source, data):
d, _ = read_uint32(data)
return {"date": d}
@staticmethod
def receive_ServerChat(source, data):
action, data = read_uint8(data)
dest_type, data = read_uint8(data)
cid, data = read_uint32(data)
msg, data = read_string(data)
money = 0
if len(data) >= 8:
import struct
money = struct.unpack("<Q", data[:8])[0]
return {"action": action, "dest_type": dest_type, "client_id": cid, "message": msg, "money": money}
@staticmethod
def receive_ServerConsole(source, data):
origin, data = read_string(data)
text, _ = read_string(data)
return {"origin": origin, "text": text}
@staticmethod
def receive_ServerRcon(source, data):
color, data = read_uint16(data)
text, _ = read_string(data)
return {"color": color, "text": text}
@staticmethod
def receive_ServerRconEnd(source, data):
cmd, _ = read_string(data)
return {"command": cmd}
@staticmethod
def receive_ServerAuthRequest(source, data):
at, rest = read_uint8(data)
return {"auth_type": at, "data": rest}
@staticmethod
def receive_ServerEnableEncryption(source, data): return {"data": data}
@staticmethod
def receive_ServerError(source, data):
ec, _ = read_uint8(data)
return {"error_code": ec}
@staticmethod
def receive_ServerFull(source, data): return {}
@staticmethod
def receive_ServerBanned(source, data): return {}
@staticmethod
def receive_ServerShutdown(source, data): return {}
@staticmethod
def receive_ServerNewGame(source, data): return {}
@staticmethod
def receive_ServerClientJoin(source, data):
cid, _ = read_uint32(data)
return {"client_id": cid}
@staticmethod
def receive_ServerClientInfo(source, data):
cid, data = read_uint32(data)
addr, data = read_string(data)
name, data = read_string(data)
lang, data = read_uint8(data)
date, data = read_uint32(data)
playas, _ = read_uint8(data)
return {
"client_id": cid, "network_address": addr, "name": name,
"language": lang, "join_date": date, "play_as": playas
}
@staticmethod
def receive_ServerClientUpdate(source, data):
cid, data = read_uint32(data)
name, data = read_string(data)
playas, _ = read_uint8(data)
return {"client_id": cid, "name": name, "play_as": playas}
@staticmethod
def receive_ServerClientQuit(source, data):
cid, _ = read_uint32(data)
return {"client_id": cid}
@staticmethod
def receive_ServerClientError(source, data):
cid, data = read_uint32(data)
error, _ = read_uint8(data)
return {"client_id": cid, "error_code": error}
@staticmethod
def receive_ServerCompanyNew(source, data):
cid, _ = read_uint8(data)
return {"company_id": cid}
@staticmethod
def receive_ServerCompanyInfo(source, data):
cid, data = read_uint8(data)
name, data = read_string(data)
manager, data = read_string(data)
color, data = read_uint8(data)
protected, data = read_uint8(data)
year, data = read_uint32(data)
is_ai, _ = read_uint8(data)
return {
"company_id": cid, "name": name, "manager_name": manager,
"color": color, "password_protected": bool(protected),
"inaugurated_year": year, "is_ai": bool(is_ai)
}
@staticmethod
def receive_ServerCompanyUpdate(source, data):
cid, data = read_uint8(data)
name, data = read_string(data)
manager, data = read_string(data)
color, data = read_uint8(data)
protected, data = read_uint8(data)
bankrupt, data = read_uint8(data)
s1, data = read_uint8(data)
s2, data = read_uint8(data)
s3, data = read_uint8(data)
s4, _ = read_uint8(data)
return {
"company_id": cid, "name": name, "manager_name": manager,
"color": color, "password_protected": bool(protected),
"quarters_of_bankruptcy": bankrupt, "share_owners": [s1, s2, s3, s4]
}
@staticmethod
def receive_ServerCompanyRemove(source, data):
cid, data = read_uint8(data)
reason, _ = read_uint8(data)
return {"company_id": cid, "reason": reason}
@staticmethod
def receive_ServerCompanyEconomy(source, data):
import struct
cid, data = read_uint8(data)
money = struct.unpack("<Q", data[:8])[0]
data = data[8:]
loan = struct.unpack("<Q", data[:8])[0]
data = data[8:]
income = struct.unpack("<q", data[:8])[0]
data = data[8:]
delivered, data = read_uint16(data)
val_lq = struct.unpack("<Q", data[:8])[0]
data = data[8:]
perf_lq, data = read_uint16(data)
del_lq, data = read_uint16(data)
val_pq = struct.unpack("<Q", data[:8])[0]
data = data[8:]
perf_pq, data = read_uint16(data)
del_pq, _ = read_uint16(data)
return {
"company_id": cid, "money": money, "loan": loan, "income": income,
"delivered_cargo": delivered, "value_last_quarter": val_lq,
"performance_last_quarter": perf_lq, "delivered_cargo_last_quarter": del_lq,
"value_previous_quarter": val_pq, "performance_previous_quarter": perf_pq,
"delivered_cargo_previous_quarter": del_pq
}
@staticmethod
def receive_ServerCompanyStats(source, data):
cid, data = read_uint8(data)
trains, data = read_uint16(data)
lorries, data = read_uint16(data)
buses, data = read_uint16(data)
planes, data = read_uint16(data)
ships, data = read_uint16(data)
t_stations, data = read_uint16(data)
l_stations, data = read_uint16(data)
b_stops, data = read_uint16(data)
airports, data = read_uint16(data)
harbours, _ = read_uint16(data)
return {
"company_id": cid,
"vehicles": {"trains": trains, "lorries": lorries, "buses": buses, "planes": planes, "ships": ships},
"stations": {"train": t_stations, "lorry": l_stations, "bus": b_stops, "airport": airports, "harbour": harbours}
}
@staticmethod
def receive_ServerGamescript(source, data):
json_str, _ = read_string(data)
import json
try:
return {"data": json.loads(json_str)}
except json.JSONDecodeError:
return {"raw_data": json_str}
@staticmethod
def receive_ServerPong(source, data):
payload, _ = read_uint32(data)
return {"payload": payload}