Vearie

Chivalry Server Browser

Aug 23rd, 2025
212
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 11.22 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. # Requires Python 3.9+ (due to use of memoryview.po
  3. #
  4. # Query Valve’s Master Server and then send A2S_INFO to each server to get name/map/player count.
  5. # Works on Chivalry: Medieval Warfare (AppID 219640) or any Source‑engine title.
  6.  
  7. import socket
  8. import struct
  9. import sys
  10. import time
  11. import tkinter as tk
  12. from tkinter import ttk
  13. import threading
  14.  
  15. ### CONFIGURATION ###
  16.  
  17. APP_ID = 219640  # Chivalry: Medieval Warfare ‍🛡️
  18. MASTER_HOST = "hl2master.steampowered.com"
  19. MASTER_PORT = 27011
  20. REGION = 0xFF  # 0xFF = "rest of world"; see Valve docs turn3view0 §1.1.1
  21. FILTER = f"\\appid\\{APP_ID}\\password\\0"  # Only no‑password servers for this game
  22.  
  23. MAX_HOSTS = 30  # Don't hammer for way too many servers
  24. ### PROTOCOL CONSTANTS ###
  25.  
  26. TIMOUT_SEC = 3.0
  27.  
  28. MASTER_PING = b"\x31"
  29. END_MARKER = b"\x00"
  30. A2S_HEADER = b"\xFF\xFF\xFF\xFF"
  31. A2S_INFO = b"TSource Engine Query\x00"
  32. A2S_PLAYER = b"U"
  33. A2S_RULES  = b"V"
  34.  
  35. def format_time(duration_sec: float) -> str:
  36.     """
  37.    Convert a duration to [H:]M:S with no zero-padding and omit hours when
  38.    it's zero.
  39.  
  40.    - Any fractional seconds get rounded to nearest second.
  41.    - If hours == 0, result is "M:S" (e.g. "5:7" for 5m 7s).
  42.      If hours > 0, result is "H:M:S" (e.g. "1:2:3").
  43.    - Seconds and minutes are not padded: "0:5", not "0:05".
  44.    """
  45.     total = int(max(0, round(duration_sec)))
  46.     hours, rem = divmod(total, 3600)
  47.     minutes, seconds = divmod(rem, 60)
  48.  
  49.     if hours:
  50.         return f"{hours}:{minutes}:{seconds}"
  51.     return f"{minutes}:{seconds}"
  52.  
  53. ### MASTER SERVER QUERY FUNCTION ###
  54.  
  55. def query_master(appid: int, region: int, filter_str: str, max_hosts: int = 30, timeout: float = TIMOUT_SEC):
  56.     """
  57.     Generator that yields (ip, port) for each server returned by Master Server.
  58.     Implements Valve Master Server Query Protocol entirely with python sockets.
  59.     See Valve docs: turn3view0 Master Server Protocol §1.1 and §1.4.
  60.     """
  61.     s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  62.     s.settimeout(timeout)
  63.     master_addr = (MASTER_HOST, MASTER_PORT)
  64.     last = "0.0.0.0:0"
  65.  
  66.     sent = 0
  67.     received = 0
  68.     hosts = 0
  69.  
  70.     while hosts < max_hosts:
  71.         payload = MASTER_PING
  72.         payload += struct.pack("B", region & 0xFF)
  73.         payload += last.encode("ascii") + END_MARKER
  74.         payload += filter_str.encode("ascii") + END_MARKER
  75.  
  76.         try:
  77.             s.sendto(payload, master_addr)
  78.         except Exception as e:
  79.             print(f"[master] send failed: {e}", file=sys.stderr)
  80.             break
  81.  
  82.         sent += 1
  83.  
  84.         try:
  85.             data, _ = s.recvfrom(4096)
  86.         except socket.timeout:
  87.             print("[master] Timeout or blocked by DS; stopping", file=sys.stderr)
  88.             break
  89.  
  90.         # Validate header: starts with 0xFF 0xFF 0xFF 0xFF 0x66 0x0A
  91.         if len(data) < 6 or data[:2] != b"\xFF\xFF" or data[4:6] != b"\x66\x0A":
  92.             print("[master] Unexpected reply header:", data[:6].hex(), file=sys.stderr)
  93.             break
  94.  
  95.         received += 1
  96.  
  97.         # Each 6‑byte block thereafter: 4 bytes IP, 2 bytes port (network order)
  98.         off = 6
  99.         while off + 6 <= len(data):
  100.             ip_packed = data[off:off+4]
  101.             port_packed = data[off+4:off+6]
  102.             off += 6
  103.             ip = socket.inet_ntoa(ip_packed)
  104.             port = struct.unpack("!H", port_packed)[0]
  105.             if ip == "0.0.0.0" and port == 0:
  106.                 return
  107.             yield (ip, port)
  108.             hosts += 1
  109.             if hosts >= max_hosts:
  110.                 return
  111.         # Prepare next 'last' IP:Port for subsequent request
  112.         last = f"{ip}:{port}"
  113.  
  114.     print(f"[master] got {hosts} hosts (sent: {sent}, recv: {received})", file=sys.stderr)
  115.  
  116. ### A2S_INFO QUERY FUNCTION ###
  117.  
  118. def query_info(addr, timeout=TIMOUT_SEC):
  119.     """
  120.     Sends an A2S_INFO query and returns a dict with server metadata.
  121.     Must add challenge support (post‑June 2020 rule). See turn4view0 §4.1, §4.2.
  122.     """
  123.     s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  124.     s.settimeout(timeout)
  125.     s.connect(addr)  # watermark for recvfrom origin check
  126.     challenge = -1  # -1 = request challenge
  127.  
  128.     while True:
  129.         buf = A2S_HEADER + A2S_INFO
  130.         if challenge >= 0:
  131.             #~ buf += struct.pack("<l", challenge & 0xFFFFFFFF)
  132.             buf += struct.pack("<L", challenge)
  133.         try:
  134.             s.send(buf)
  135.             data = s.recv(1400)
  136.         except socket.timeout:
  137.             #~ print(f"[info] {addr} timeout", file=sys.stderr)
  138.             return None
  139.  
  140.         if len(data) < 5:
  141.             print(f"[info] {addr} short reply", file=sys.stderr)
  142.             return None
  143.  
  144.         # Check base header
  145.         if data[:4] != A2S_HEADER:
  146.             print(f"[info] {addr} wrong reply header", file=sys.stderr)
  147.             return None
  148.  
  149.         frame = data[4]
  150.         # frame==0x41 ('A') → challenge; frame==0x49 ('I') → info
  151.         if frame == 0x41:  # 'A'
  152.             if len(data) < 9:
  153.                 print(f"[info] {addr} broken challenge", file=sys.stderr)
  154.                 return None
  155.             challenge = struct.unpack("<L", data[5:9])[0]
  156.             #~ print(f"[info] {addr} gave challenge {challenge}", file=sys.stderr)
  157.             continue
  158.         elif frame == 0x49:  # 'I'
  159.             # Parse the rest: name\0 map\0 folder\0 game\0 appid (short), players (byte), max (byte), bots(byte), type, env, visibility, vac, version\0, EDF
  160.             offset = 5
  161.             fields = []
  162.             for _ in range(4):  # name, map, folder, game
  163.                 end = data.find(b"\x00", offset)
  164.                 if end < 0:
  165.                     print(f"[info] {addr} unterm’d string field {_}", file=sys.stderr)
  166.                     return None
  167.                 fields.append(data[offset:end].decode("utf‑8", errors="replace"))
  168.                 offset = end + 1
  169.             name, mapname, folder, gamename = fields
  170.             if len(data) < offset + 2 + 1 + 1 + 1 + 1 + 1 + 1:
  171.                 print(f"[info] {addr} malformed fixed fields", file=sys.stderr)
  172.                 return None
  173.             appid_recv, players, maxp, bots = struct.unpack("<hBBB", data[offset:offset+5])
  174.             offset += 4
  175.             server_type = data[offset:offset+1].decode("ascii")
  176.             env = data[offset+1:offset+2].decode("ascii")
  177.             vis = data[offset+2]
  178.             vac = data[offset+3]
  179.             offset += 4
  180.             # Then version\0
  181.             end = data.find(b"\x00", offset)
  182.             version = data[offset:end].decode("utf‑8", errors="replace") if end > offset else ""
  183.             offset = end + 1
  184.             edf = data[offset]
  185.             offset += 1
  186.             info = {
  187.                 "name": name,
  188.                 "map": mapname,
  189.                 "game": gamename,
  190.                 "appid": appid_recv,
  191.                 "players": players,
  192.                 "max_players": maxp,
  193.                 "bots": bots,
  194.                 "server_type": server_type,
  195.                 "environment": env,
  196.                 "password_protected": (vis != 0),
  197.                 "vac": bool(vac),
  198.                 "version": version,
  199.                 "address": addr,
  200.             }
  201.             # Optional EDF fields
  202.             if edf & 0x80:
  203.                 port_spectator = struct.unpack("<H", data[offset:offset+2])[0]
  204.                 offset += 2
  205.                 info["spec_port"] = port_spectator
  206.             if edf & 0x10:
  207.                 steamid = struct.unpack("<Q", data[offset:offset+8])[0]
  208.                 offset += 8
  209.                 info["steamid"] = steamid
  210.             if edf & 0x40:
  211.                 name_spec_end = data.find(b"\x00", offset)
  212.                 spec_name = data[offset:name_spec_end].decode("utf‑8", errors="replace")
  213.                 info["spec_name"] = spec_name
  214.                 offset = name_spec_end + 1
  215.             if edf & 0x20:
  216.                 keywords_end = data.find(b"\x00", offset)
  217.                 keywords = data[offset:keywords_end].decode("utf‑8", errors="replace")
  218.                 info["keywords"] = keywords
  219.                 offset = keywords_end + 1
  220.             if edf & 0x01:
  221.                 gameid = struct.unpack("<Q", data[offset:offset+8])[0]
  222.                 info["gameid64"] = gameid
  223.             return info
  224.         else:
  225.             print(f"[info] {addr} got unknown frame 0x{frame:02x}", file=sys.stderr)
  226.             return None
  227.  
  228. import socket, struct, sys
  229.  
  230. A2S_HEADER = b"\xFF\xFF\xFF\xFF"
  231. A2S_PLAYER  = b"U"  # 0x55
  232.  
  233. def query_players(addr, timeout=TIMOUT_SEC):
  234.     """
  235.     Perform an A2S_PLAYER query to get name, score, duration for each player.
  236.     Handles Valve's optional challenge (frame 'A' == 0x41), loops until frame 'D'.
  237.     multi-packet/bzip2 not implemented—most Source servers fit in one packet.
  238.     """
  239.     s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  240.     s.settimeout(timeout)
  241.     s.connect(addr)
  242.  
  243.     challenge = -1
  244.     attempts = 0
  245.     while attempts < 3:
  246.         attempts += 1
  247.         buf = A2S_HEADER + A2S_PLAYER
  248.         if challenge >= 0:
  249.             buf += struct.pack("<l", challenge & 0xFFFFFFFF)
  250.         else:
  251.             buf += struct.pack("<l", -1)  # request challenge
  252.         try:
  253.             s.send(buf)
  254.             data = s.recv(2048)
  255.         except socket.timeout:
  256.             print(f"[player] timeout on attempt {attempts}", file=sys.stderr)
  257.             return None
  258.  
  259.         if len(data) < 5 or data[:4] != A2S_HEADER:
  260.             print(f"[player] malformed header", file=sys.stderr); return None
  261.  
  262.         frame = data[4]
  263.         if frame == 0x41:  # 'A' → server issued a challenge
  264.             challenge = struct.unpack("<l", data[5:9])[0]
  265.             #~ print(f"[player] got challenge {challenge}", file=sys.stderr)
  266.             continue
  267.         elif frame == 0x44:  # 'D' → player list
  268.             break
  269.         else:
  270.             print(f"[player] unexpected frame: 0x{frame:02x}", file=sys.stderr)
  271.             return None
  272.  
  273.     if frame != 0x44:
  274.         print("[player] no 'D' frame received", file=sys.stderr)
  275.         return None
  276.  
  277.     ptr = 5
  278.     player_count = data[ptr]
  279.     ptr += 1
  280.     players = []
  281.     for _ in range(player_count):
  282.         idx = data[ptr]; ptr += 1
  283.         end = data.find(b"\x00", ptr)
  284.         name = data[ptr:end].decode("utf-8", errors="replace")
  285.         ptr = end + 1
  286.         score = struct.unpack("<l", data[ptr:ptr+4])[0]
  287.         ptr += 4
  288.         duration = struct.unpack("<f", data[ptr:ptr+4])[0]
  289.         ptr += 4
  290.         players.append({
  291.             "index": idx,
  292.             "name": name,
  293.             "score": score,
  294.             "duration_sec": duration,
  295.         })
  296.     return players
  297.  
  298. def load_players(tree, tree_item_id, server):
  299.     info = query_info(server, timeout=TIMOUT_SEC)
  300.     if info and info['players']:
  301.         name = info['name'].encode("ascii", "ignore").decode("ascii", "ignore")
  302.         tree.item(tree_item_id, text=f"({info['players']}/{info['max_players']}) {name} - {info['map']}")
  303.        
  304.         #~ print(f"Name: {name}")
  305.         #~ print(f"Address: {ip}:{port}")
  306.         #~ print(f"Info: {info['players']}/{info['max_players']} on {info['map']}")
  307.         #~ print("Score\tTime\tPlayer Name")
  308.        
  309.         players = query_players(server, timeout=TIMOUT_SEC)
  310.         if not players:
  311.             return
  312.        
  313.         for p in sorted(players, key=lambda p: p["score"], reverse=True):
  314.             player_name = p['name'].encode("ascii", "ignore").decode("ascii", "ignore")
  315.            
  316.             tree.insert(tree_item_id, "end", text=player_name)
  317.             tree.item(tree_item_id, open=True)
  318.            
  319.             # print(f"{p['score']}\t{format_time(p['duration_sec'])}\t{player_name}")
  320.     else:
  321.         # No players
  322.         tree.delete(tree_item_id)
  323.  
  324. def load_servers(tree, callback):
  325.     threads = []
  326.    
  327.     for (ip, port) in query_master(APP_ID, REGION, FILTER, max_hosts=MAX_HOSTS, timeout=TIMOUT_SEC):
  328.         server = (ip, port)
  329.         tree_item_id = tree.insert("", "end", text=f"{ip}:{port}...")
  330.         thread = threading.Thread(target=load_players, args=(tree, tree_item_id, server))
  331.         thread.start()
  332.         threads += [thread]
  333.    
  334.     # Wait for any remaining threads to end
  335.     for thread in threads:
  336.         if thread.is_alive():
  337.             thread.join()
  338.    
  339.     callback()
  340.  
  341. def refresh(btn, tree, thread):
  342.     if not thread.is_alive():
  343.         for item in tree.get_children():
  344.             tree.delete(item)
  345.        
  346.         thread = threading.Thread(target=load_servers, args=(tree, lambda: enable_refresh(btn)))
  347.         btn.config(state="disabled")
  348.         thread.start()
  349.  
  350. def enable_refresh(btn):
  351.     btn.config(state="normal")
  352.  
  353. if __name__ == "__main__":
  354.     root = tk.Tk()
  355.     root.title("Chiv Servers")
  356.     root.geometry("400x500")
  357.     tree = ttk.Treeview(root, show="tree")
  358.     tree.pack(fill="both", expand=True)
  359.    
  360.     thread = threading.Thread(target=load_servers, args=(tree, lambda: enable_refresh(btn)))
  361.    
  362.     btn = tk.Button(root, text="Refresh", command=lambda: refresh(btn, tree, thread))
  363.     btn.config(state="disabled")
  364.     btn.pack()
  365.    
  366.     thread.start()
  367.    
  368.     root.mainloop()
  369.  
Advertisement
Add Comment
Please, Sign In to add comment