#!/usr/bin/env python3 # Query Valve’s Master Server and then send A2S_INFO to each server to get name/map/player count. import socket import struct import os, sys import time import requests import tkinter as tk from tkinter import ttk import threading API_KEY = None try: API_KEY = os.environ["STEAM_API_KEY"] except KeyError: API_KEY = None APP_ID = 219640 # Chivalry: Medieval Warfare REQUEST_URL = f"https://api.steampowered.com/IGameServersService/GetServerList/v1?key={API_KEY}&filter=\\appid\\{APP_ID}&limit=100" MASTER_HOST = "hl2master.steampowered.com" MASTER_PORT = 27011 REGION = 0xFF # 0xFF = "rest of world"; see Valve docs turn3view0 §1.1.1 FILTER = f"\\appid\\{APP_ID}\\password\\0" # Only no‑password servers for this game MAX_HOSTS = 50 ### PROTOCOL CONSTANTS ### TIMOUT_SEC = 3.0 MASTER_PING = b"\x31" END_MARKER = b"\x00" A2S_HEADER = b"\xFF\xFF\xFF\xFF" A2S_INFO = b"TSource Engine Query\x00" A2S_PLAYER = b"U" A2S_RULES = b"V" status_var = None def stat(text): print(text) if status_var: status_var.set(text) def format_time(duration_sec: float) -> str: """ Convert a duration to [H:]M:S with no zero-padding and omit hours when it's zero. - Any fractional seconds get rounded to nearest second. - If hours == 0, result is "M:S" (e.g. "5:7" for 5m 7s). If hours > 0, result is "H:M:S" (e.g. "1:2:3"). - Seconds and minutes are not padded: "0:5", not "0:05". """ total = int(max(0, round(duration_sec))) hours, rem = divmod(total, 3600) minutes, seconds = divmod(rem, 60) if hours: return f"{hours}:{minutes}:{seconds}" return f"{minutes}:{seconds}" ### MASTER SERVER QUERY FUNCTION ### def query_master(appid: int, region: int, filter_str: str, max_hosts: int = 30, timeout: float = TIMOUT_SEC): """ Generator that yields (ip, port) for each server returned by Master Server. Implements Valve Master Server Query Protocol entirely with python sockets. See Valve docs: turn3view0 Master Server Protocol §1.1 and §1.4. """ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(timeout) master_addr = (MASTER_HOST, MASTER_PORT) last = "0.0.0.0:0" sent = 0 received = 0 hosts = 0 while hosts < max_hosts: payload = MASTER_PING payload += struct.pack("B", region & 0xFF) payload += last.encode("ascii") + END_MARKER payload += filter_str.encode("ascii") + END_MARKER try: s.sendto(payload, master_addr) except Exception as e: stat(f"[master] send failed: {e}") return sent += 1 try: data, _ = s.recvfrom(4096) except socket.timeout: stat("[master] Timeout or blocked by DS; stopping") return # Validate header: starts with 0xFF 0xFF 0xFF 0xFF 0x66 0x0A if len(data) < 6 or data[:2] != b"\xFF\xFF" or data[4:6] != b"\x66\x0A": stat("[master] Unexpected reply header:", data[:6].hex()) return received += 1 # Each 6‑byte block thereafter: 4 bytes IP, 2 bytes port (network order) off = 6 while off + 6 <= len(data): ip_packed = data[off:off+4] port_packed = data[off+4:off+6] off += 6 ip = socket.inet_ntoa(ip_packed) port = struct.unpack("!H", port_packed)[0] if ip == "0.0.0.0" and port == 0: return yield (ip, port) hosts += 1 if hosts >= max_hosts: return # Prepare next 'last' IP:Port for subsequent request last = f"{ip}:{port}" stat(f"[master] got {hosts} hosts (sent: {sent}, recv: {received})") ### A2S_INFO QUERY FUNCTION ### def query_info(addr, timeout=TIMOUT_SEC): """ Sends an A2S_INFO query and returns a dict with server metadata. Must add challenge support (post‑June 2020 rule). See turn4view0 §4.1, §4.2. """ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(timeout) s.connect(addr) # watermark for recvfrom origin check challenge = -1 # -1 = request challenge while True: buf = A2S_HEADER + A2S_INFO if challenge >= 0: #~ buf += struct.pack(" offset else "" offset = end + 1 edf = data[offset] offset += 1 info = { "name": name, "map": mapname, "game": gamename, "appid": appid_recv, "players": players, "max_players": maxp, "bots": bots, "server_type": server_type, "environment": env, "password_protected": (vis != 0), "vac": bool(vac), "version": version, "address": addr, } # Optional EDF fields if edf & 0x80: port_spectator = struct.unpack("= 0: buf += struct.pack("