Talking to the game
Zandronum’s RCON listens on UDP. The protocol has one wrinkle and one gift:
- The wrinkle: every datagram is Huffman-coded with a fixed, protocol-defined tree.
- The gift: a datagram whose first byte is
0xFFis passed through unencoded. So your adapter can always send0xFF + raw bytesand only needs the Huffman decoder for server replies, no encoder.
The handshake
Section titled “The handshake”- Send
CLRC_BEGINCONNECTION(52) with protocol version 4. - Server replies
SVRC_SALT(34) with a 32-character salt. - Send
CLRC_PASSWORD(53) with the lowercase hexmd5(salt + password). - Server replies
SVRC_LOGGEDIN(35).
After that, each command is one datagram: CLRC_COMMAND (54) + the console
command + a NUL byte. Send CLRC_PONG (55) every few seconds: the server
drops clients silent for 40.
The client
Section titled “The client”Here it is, minus the Huffman table (grab HUFFMAN_TREE and _parse_tree
from the reference Pack’s adapter.py, a 511-byte constant, not
something to retype):
import hashlib, socket, threading, time
CLRC_BEGINCONNECTION, CLRC_PASSWORD, CLRC_COMMAND, CLRC_PONG = 52, 53, 54, 55SVRC_OLDPROTOCOL, SVRC_BANNED, SVRC_SALT = 32, 33, 34SVRC_LOGGEDIN, SVRC_INVALIDPASSWORD = 35, 36RCON_PROTOCOL_VERSION = 4
def huffman_decode(packet): if not packet: return b"" if packet[0] == 0xFF: # unencoded passthrough return packet[1:] bits_available = (len(packet) - 1) * 8 - packet[0] out, node = bytearray(), _HUFF_ROOT for byte in packet[1:]: for bitpos in range(8): # wire bytes carry bits in reversed order if bits_available <= 0: return bytes(out) node = node[(byte >> bitpos) & 1] if isinstance(node, int): out.append(node) node = _HUFF_ROOT bits_available -= 1 return bytes(out)
def raw(payload): # the 0xFF escape: never encode outbound return b"\xff" + payload
class RconError(Exception): pass
class RconClient: def __init__(self, host, port, password): self.addr, self.password = (host, port), password self.sock, self.lock = None, threading.Lock() threading.Thread(target=self._pong_loop, daemon=True).start()
def _read_packet(self, sock, want, timeout=3.0): deadline = time.monotonic() + timeout while True: remaining = deadline - time.monotonic() if remaining <= 0: raise RconError(f"timed out waiting for message {want}") sock.settimeout(remaining) data = huffman_decode(sock.recv(4096)) if data and data[0] == want: return data[1:] if data and data[0] in (SVRC_OLDPROTOCOL, SVRC_BANNED, SVRC_INVALIDPASSWORD): raise RconError(f"server rejected connection: {data[0]}")
def _connect(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect(self.addr) try: sock.send(raw(bytes([CLRC_BEGINCONNECTION, RCON_PROTOCOL_VERSION]))) salt = self._read_packet(sock, SVRC_SALT).split(b"\0", 1)[0] digest = hashlib.md5(salt + self.password.encode()).hexdigest() sock.send(raw(bytes([CLRC_PASSWORD]) + digest.encode() + b"\0")) self._read_packet(sock, SVRC_LOGGEDIN) except Exception: sock.close() raise self.sock = sock
def command(self, cmd): with self.lock: try: if self.sock is None: self._connect() self.sock.send(raw(bytes([CLRC_COMMAND]) + cmd.encode() + b"\0")) except (OSError, RconError) as e: if self.sock is not None: self.sock.close() self.sock = None raise RconError(f"rcon command failed: {e}") from e
def _pong_loop(self): while True: time.sleep(5) with self.lock: if self.sock is None: continue try: self.sock.send(raw(bytes([CLRC_PONG]))) except OSError: self.sock.close() self.sock = NoneNote the shape of command(): it connects lazily and tears down on any
failure. If the game isn’t running, the adapter doesn’t crash. The command
raises RconError, and the next command tries a fresh connection. This
matters for the Pack contract: the game dying mid-stream must degrade into
failed Invocations, not a dead adapter.
Letting the Dashboard launch the game
Section titled “Letting the Dashboard launch the game”Starting a server by hand (as in
setup) works, but the pack can also
boot the game itself. Two additions to pack.toml wire it up:
[adapter]command = "python3"args = ["-u", "adapter.py"]restart = "never"[adapter.env]DOOM_RCON_HOST = "127.0.0.1"DOOM_RCON_PORT = "10666"DOOM_ZANDRONUM = "${paths.zandronum}"DOOM_IWAD = "${paths.iwad}"
[[adapter.path]]key = "zandronum"label = "Zandronum executable"extensions = ["exe"]
[[adapter.path]]key = "iwad"label = "Doom IWAD (freedoom2.wad or a DOOM2.WAD you own)"extensions = ["wad"]Each [[adapter.path]] entry becomes a Browse field in the Dashboard’s
Launcher panel. The picked paths live in <pack>/.local/launcher.toml
on the broadcaster’s machine, never in the hashed manifest, and are
substituted into [adapter.env] via ${paths.*}.
When DOOM_ZANDRONUM is set, the adapter hosts the server itself before
connecting (ZANDRONUM_BIN and IWAD_PATH are plain os.environ.get
reads of the two variables above):
def launch_game(): global _game_proc if not ZANDRONUM_BIN: return exe = Path(ZANDRONUM_BIN) argv = [str(exe), "-host"] if IWAD_PATH: argv += ["-iwad", IWAD_PATH] argv += ["+sv_rconpassword", RCON_PASSWORD, "+sv_cheats", "1", "-port", str(RCON_PORT)] _game_proc = subprocess.Popen(argv, cwd=str(exe.parent)) atexit.register(_terminate_game)Two details worth noticing:
- No password to configure. When the adapter hosts the server, the RCON
password is a loopback secret between two halves of one process, so the
adapter generates an ephemeral one (
secrets.token_hex(16)).DOOM_RCON_PASSWORDis only required for a manual run against a server you host yourself. restart = "never". The adapter supervises the Zandronum process, so an auto-restart after a crash would try to bind a second server on the same port. Restarting is the broadcaster’s call, from the Dashboard.
Next: wire an Invocation to this client.