mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-13 09:20:53 +00:00
308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
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<ServerInfo[]> {
|
|
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<string[]> {
|
|
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<ServerInfo[]> {
|
|
const socket = dgram.createSocket("udp4");
|
|
const pingResults = new Map<string, PingInfo>();
|
|
const infoResults = new Map<string, GameInfo>();
|
|
const pingTimes = new Map<string, number>();
|
|
|
|
/** 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<void>((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<void>((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;
|
|
}
|
|
}
|