import dgram from "node:dgram"; import { BitStream } from "t2-demo-parser"; import type { ServerInfo } from "./types.js"; import { buildGamePingRequest, buildGameInfoRequest } from "./protocol.js"; import { masterLog } from "./logger.js"; const QUERY_TIMEOUT_MS = 5000; const PHASE_TIMEOUT_MS = 3000; /** Required build version for client compatibility. */ const REQUIRED_BUILD_VERSION = 25034; /** Parse a "host:port" string into components. */ function parseAddress(addr: string): { host: string; port: number } { const [host, portStr] = addr.split(":"); return { host, port: parseInt(portStr, 10) }; } /** * Query the TribesNext master server for a list of active game servers, * then ping each one for details via UDP. * * Two-phase query matching the real Tribes 2 client: * 1. GamePingRequest (type 14) -> server name, version, protocol * 2. GameInfoRequest (type 18) -> mod, map, game type, players */ export async function queryServerList( masterAddress: string, ): Promise { masterLog.info({ master: masterAddress }, "Querying master server"); const addresses = await queryMasterHTTP(masterAddress); masterLog.info( { count: addresses.length }, "Master returned server addresses", ); if (addresses.length === 0) return []; const servers = await queryServers(addresses); masterLog.info( { compatible: servers.length, total: addresses.length }, "Server query complete", ); return servers; } /** Query the TribesNext master server via HTTP GET /list, with retries. */ async function queryMasterHTTP(masterAddress: string): Promise { const maxAttempts = 3; const url = `http://${masterAddress}/list`; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { masterLog.debug({ url, attempt }, "Fetching server list"); const res = await fetch(url, { signal: AbortSignal.timeout(QUERY_TIMEOUT_MS), }); const body = await res.text(); return body .trim() .split("\n") .map((line) => line.trim()) .filter((addr) => addr.includes(":") && addr.includes(".")); } catch (err) { masterLog.warn( { err: err instanceof Error ? err.message : err, attempt, maxAttempts }, "Master HTTP query failed", ); if (attempt < maxAttempts) { const delay = attempt * 1000; masterLog.info({ delayMs: delay }, "Retrying master query"); await new Promise((r) => setTimeout(r, delay)); } } } masterLog.error("Master HTTP query failed after all retries"); return []; } /** Ping info from GamePingResponse (type 16). */ interface PingInfo { name: string; buildVersion: number; protocolVersion: number; ping: number; } /** Info from GameInfoResponse (type 20). */ interface GameInfo { mod: string; gameType: string; mapName: string; status: number; playerCount: number; maxPlayers: number; botCount: number; } /** Two-phase UDP query: ping first, then info request. */ async function queryServers(addresses: string[]): Promise { const socket = dgram.createSocket("udp4"); const pingResults = new Map(); const infoResults = new Map(); const pingTimes = new Map(); /** Resolve an rinfo address back to a queried address. */ function resolveAddr(rinfo: dgram.RemoteInfo): string { let addr = `${rinfo.address}:${rinfo.port}`; if (!pingTimes.has(addr)) { for (const [key] of pingTimes) { const { port } = parseAddress(key); if (port === rinfo.port) { addr = key; break; } } } return addr; } // Phase 1: Send pings, collect responses masterLog.debug( { count: addresses.length }, "Phase 1: sending GamePingRequests", ); await new Promise((resolve) => { const timeout = setTimeout(() => resolve(), PHASE_TIMEOUT_MS); socket.on("message", (msg, rinfo) => { const addr = resolveAddr(rinfo); const type = msg[0]; if (type === 16) { const info = parsePingResponse(msg, pingTimes.get(addr)); if (info) { pingResults.set(addr, info); masterLog.debug( { addr, name: info.name, build: info.buildVersion, ping: info.ping, }, "Ping response", ); } if (pingResults.size >= addresses.length) { clearTimeout(timeout); resolve(); } } else if (type === 20) { const info = parseInfoResponse(msg); if (info) infoResults.set(addr, info); } }); socket.on("error", () => { clearTimeout(timeout); resolve(); }); for (const addr of addresses) { const { host, port } = parseAddress(addr); pingTimes.set(addr, Date.now()); socket.send(buildGamePingRequest(), port, host); } }); masterLog.debug( { responded: pingResults.size, total: addresses.length }, "Phase 1 complete", ); // Phase 2: Send info requests to servers that responded to ping // and are running the correct version. const compatibleAddrs = [...pingResults.entries()] .filter(([, info]) => info.buildVersion === REQUIRED_BUILD_VERSION) .map(([addr]) => addr); if (compatibleAddrs.length > 0) { socket.removeAllListeners("message"); masterLog.debug( { count: compatibleAddrs.length }, "Phase 2: sending GameInfoRequests", ); await new Promise((resolve) => { const timeout = setTimeout(() => resolve(), PHASE_TIMEOUT_MS); socket.on("message", (msg, rinfo) => { const addr = resolveAddr(rinfo); if (msg[0] === 20) { const info = parseInfoResponse(msg); if (info) infoResults.set(addr, info); if (infoResults.size >= compatibleAddrs.length) { clearTimeout(timeout); resolve(); } } }); for (const addr of compatibleAddrs) { if (infoResults.has(addr)) continue; const { host, port } = parseAddress(addr); socket.send(buildGameInfoRequest(), port, host); } const remaining = compatibleAddrs.filter((a) => !infoResults.has(a)); if (remaining.length === 0) { clearTimeout(timeout); resolve(); } }); } socket.removeAllListeners(); socket.close(); // Combine results const servers: ServerInfo[] = []; for (const [addr, ping] of pingResults) { if (ping.buildVersion !== REQUIRED_BUILD_VERSION) continue; const info = infoResults.get(addr); servers.push({ address: addr, name: ping.name, mod: info?.mod ?? "", gameType: info?.gameType ?? "", mapName: info?.mapName ?? "", playerCount: info?.playerCount ?? 0, maxPlayers: info?.maxPlayers ?? 0, botCount: info?.botCount ?? 0, ping: ping.ping, buildVersion: ping.buildVersion, passwordRequired: info ? (info.status & 0x02) !== 0 : false, }); } return servers; } /** * Parse a GamePingResponse (type 16). * * Format (from decompiled Tribes2.exe): * U8 type (16) * U8 flags * U32 key * HuffString versionString (e.g. "VER5") * U32 protocolVersion * U32 minProtocolVersion * U32 buildVersion (e.g. 25034) * HuffString serverName (24 chars max) */ function parsePingResponse(data: Buffer, sendTime?: number): PingInfo | null { if (data.length < 7 || data[0] !== 16) return null; try { const bs = new BitStream( new Uint8Array(data.buffer, data.byteOffset + 6, data.length - 6), ); bs.readString(); // versionString const protocolVersion = bs.readU32(); bs.readU32(); // minProtocolVersion const buildVersion = bs.readU32(); const name = bs.readString(); return { name, buildVersion, protocolVersion, ping: sendTime ? Date.now() - sendTime : 0, }; } catch { return null; } } /** * Parse a GameInfoResponse (type 20). * * Format (from decompiled Tribes2.exe): * U8 type (20) * U8 flags * U32 key * HuffString mod (mod paths, e.g. "Classic") * HuffString missionTypeDisplayName (e.g. "Capture the Flag") * HuffString missionDisplayName (map name) * U8 status flags * U8 playerCount * U8 maxPlayers * U8 botCount * U16 cpuMhz * HuffString serverInfo ($Host::Info — description, NOT the name) */ function parseInfoResponse(data: Buffer): GameInfo | null { if (data.length < 7 || data[0] !== 20) return null; try { const bs = new BitStream( new Uint8Array(data.buffer, data.byteOffset + 6, data.length - 6), ); const mod = bs.readString(); const gameType = bs.readString(); const mapName = bs.readString(); const status = bs.readU8(); const playerCount = bs.readU8(); const maxPlayers = bs.readU8(); const botCount = bs.readU8(); return { mod, gameType, mapName, status, playerCount, maxPlayers, botCount, }; } catch { return null; } }