begin live server support

This commit is contained in:
Brian Beck 2026-03-09 12:38:40 -07:00
parent 0c9ddb476a
commit e4ae265184
368 changed files with 17756 additions and 7738 deletions

136
relay/BitStreamWriter.ts Normal file
View file

@ -0,0 +1,136 @@
/**
* Bit-level stream writer, mirroring the V12 engine's BitStream write methods.
* Bits are written LSB-first within each byte, matching the read convention.
*/
export class BitStreamWriter {
private data: Uint8Array;
private bitNum: number;
private maxBitNum: number;
constructor(maxBytes = 1500) {
this.data = new Uint8Array(maxBytes);
this.bitNum = 0;
this.maxBitNum = maxBytes << 3;
}
getCurPos(): number {
return this.bitNum;
}
setCurPos(pos: number): void {
this.bitNum = pos;
}
getBytePosition(): number {
return (this.bitNum + 7) >> 3;
}
getByteCount(): number {
return this.getBytePosition();
}
/** Get a copy of the written bytes. */
getBuffer(): Uint8Array {
return this.data.slice(0, this.getByteCount());
}
writeFlag(value: boolean): void {
if (this.bitNum >= this.maxBitNum) return;
if (value) {
this.data[this.bitNum >> 3] |= 1 << (this.bitNum & 0x7);
} else {
this.data[this.bitNum >> 3] &= ~(1 << (this.bitNum & 0x7));
}
this.bitNum++;
}
/** Write N bits from an unsigned integer, LSB-first. */
writeInt(value: number, bitCount: number): void {
if (bitCount === 0) return;
value = value >>> 0;
for (let i = 0; i < bitCount; i++) {
if (value & (1 << i)) {
this.data[this.bitNum >> 3] |= 1 << (this.bitNum & 0x7);
} else {
this.data[this.bitNum >> 3] &= ~(1 << (this.bitNum & 0x7));
}
this.bitNum++;
}
}
/** Write a signed integer: 1-bit sign flag + (bitCount-1) magnitude bits. */
writeSignedInt(value: number, bitCount: number): void {
if (value < 0) {
this.writeFlag(true);
this.writeInt(-value, bitCount - 1);
} else {
this.writeFlag(false);
this.writeInt(value, bitCount - 1);
}
}
writeU8(value: number): void {
this.writeInt(value & 0xff, 8);
}
writeU16(value: number): void {
this.writeInt(value & 0xffff, 16);
}
writeU32(value: number): void {
this.writeInt(value >>> 0, 32);
}
writeS32(value: number): void {
this.writeU32(value | 0);
}
/** Shared buffer for F32 writes. */
private static readonly f32Buf = new ArrayBuffer(4);
private static readonly f32View = new DataView(BitStreamWriter.f32Buf);
private static readonly f32U8 = new Uint8Array(BitStreamWriter.f32Buf);
writeF32(value: number): void {
BitStreamWriter.f32View.setFloat32(0, value, true);
for (let i = 0; i < 4; i++) {
this.writeU8(BitStreamWriter.f32U8[i]);
}
}
/** Write a float normalized to [0, 1]. */
writeFloat(value: number, bitCount: number): void {
const maxVal = (1 << bitCount) - 1;
this.writeInt(Math.round(value * maxVal), bitCount);
}
/** Write a float normalized to [-1, 1]. */
writeSignedFloat(value: number, bitCount: number): void {
const maxVal = (1 << bitCount) - 1;
this.writeInt(Math.round((value + 1.0) * 0.5 * maxVal), bitCount);
}
writeRangedU32(value: number, rangeStart: number, rangeEnd: number): void {
const rangeSize = rangeEnd - rangeStart + 1;
const rangeBits = Math.ceil(Math.log2(rangeSize)) || 1;
this.writeInt(value - rangeStart, rangeBits);
}
/** Write raw bits from a Uint8Array. */
writeBitsBuffer(data: Uint8Array, bitCount: number): void {
for (let i = 0; i < bitCount; i++) {
const byteIndex = i >> 3;
const bitIndex = i & 0x7;
const bit = (data[byteIndex] >> bitIndex) & 1;
if (bit) {
this.data[this.bitNum >> 3] |= 1 << (this.bitNum & 0x7);
} else {
this.data[this.bitNum >> 3] &= ~(1 << (this.bitNum & 0x7));
}
this.bitNum++;
}
}
writeBytes(bytes: Uint8Array): void {
this.writeBitsBuffer(bytes, bytes.length * 8);
}
}

17
relay/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
COPY .yalc/ .yalc/
RUN npm ci --omit=dev
COPY relay/ relay/
COPY public/manifest.json public/manifest.json
ENV RELAY_PORT=8765
ENV GAME_BASE_PATH=/data/base
ENV MANIFEST_PATH=/app/public/manifest.json
EXPOSE 8765
CMD ["node", "--import=tsx/esm", "relay/server.ts"]

223
relay/HuffmanWriter.ts Normal file
View file

@ -0,0 +1,223 @@
import { BitStreamWriter } from "./BitStreamWriter.js";
/** Hardcoded character frequency table from the V12 engine (bitStream.cc). */
const CSM_CHAR_FREQS: number[] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554, 3,
394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39,
0, 144, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36,
185, 1, 149, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211,
0, 2090, 344, 736, 993, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713,
562, 3, 1775, 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
const PROB_BOOST = 1;
function isAlphaNumeric(c: number): boolean {
return (
(c >= 48 && c <= 57) ||
(c >= 65 && c <= 90) ||
(c >= 97 && c <= 122)
);
}
interface HuffLeaf {
pop: number;
symbol: number;
numBits: number;
code: number;
}
interface HuffNode {
pop: number;
index0: number;
index1: number;
}
interface HuffWrap {
node: HuffNode | null;
leaf: HuffLeaf | null;
}
function wrapGetPop(w: HuffWrap): number {
return w.node ? w.node.pop : w.leaf!.pop;
}
let leaves: HuffLeaf[] = [];
let tablesBuilt = false;
function buildTables(): void {
if (tablesBuilt) return;
tablesBuilt = true;
leaves = [];
for (let i = 0; i < 256; i++) {
leaves.push({
pop: CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
symbol: i,
numBits: 0,
code: 0,
});
}
const nodes: HuffNode[] = [{ pop: 0, index0: 0, index1: 0 }];
let currWraps = 256;
const wraps: HuffWrap[] = [];
for (let i = 0; i < 256; i++) {
wraps.push({ node: null, leaf: leaves[i] });
}
while (currWraps !== 1) {
let min1 = 0xfffffffe;
let min2 = 0xffffffff;
let index1 = -1;
let index2 = -1;
for (let i = 0; i < currWraps; i++) {
const pop = wrapGetPop(wraps[i]);
if (pop < min1) {
min2 = min1;
index2 = index1;
min1 = pop;
index1 = i;
} else if (pop < min2) {
min2 = pop;
index2 = i;
}
}
const determineIndex = (wrap: HuffWrap): number => {
if (wrap.leaf !== null) {
return -(leaves.indexOf(wrap.leaf) + 1);
}
return nodes.indexOf(wrap.node!);
};
const newNode: HuffNode = {
pop: wrapGetPop(wraps[index1]) + wrapGetPop(wraps[index2]),
index0: determineIndex(wraps[index1]),
index1: determineIndex(wraps[index2]),
};
nodes.push(newNode);
const mergeIndex = index1 < index2 ? index1 : index2;
const nukeIndex = index1 > index2 ? index1 : index2;
wraps[mergeIndex] = { node: newNode, leaf: null };
if (nukeIndex !== currWraps - 1) {
wraps[nukeIndex] = wraps[currWraps - 1];
}
currWraps--;
}
nodes[0] = wraps[0].node!;
function generateCodes(code: number, nodeIndex: number, depth: number): void {
if (nodeIndex < 0) {
const leaf = leaves[-(nodeIndex + 1)];
leaf.code = code;
leaf.numBits = depth;
} else {
const node = nodes[nodeIndex];
generateCodes(code, node.index0, depth + 1);
generateCodes(code | (1 << depth), node.index1, depth + 1);
}
}
generateCodes(0, 0, 0);
}
/** Write a Huffman-encoded string to a BitStreamWriter. */
export function writeHuffBuffer(bs: BitStreamWriter, str: string): void {
buildTables();
// Always use Huffman compression (flag=true)
bs.writeFlag(true);
bs.writeInt(str.length, 8);
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i) & 0xff;
const leaf = leaves[charCode];
// Write Huffman code bits LSB-first
for (let b = 0; b < leaf.numBits; b++) {
bs.writeFlag((leaf.code & (1 << b)) !== 0);
}
}
}
/**
* Write a Huffman-encoded string (readString inverse).
* When the server's BitStream has a stringBuffer set (compression point),
* readString reads an extra flag before the Huffman data. We must write
* that flag. Since we don't track buffer state, we always write false
* (no prefix match), then the full Huffman string.
*/
export function writeString(
bs: BitStreamWriter,
str: string,
compressed: boolean = false,
): void {
if (compressed) {
// Compression buffer is active on the reader side.
// Write false = no prefix match with buffer, send full string.
bs.writeFlag(false);
}
writeHuffBuffer(bs, str);
}
/**
* Pack a net string (inverse of BitStream.unpackNetString).
* Code 0 = empty, code 1 = Huffman string, code 3 = integer.
*/
export function packNetString(
bs: BitStreamWriter,
str: string,
compressed: boolean = false,
): void {
if (str === "" || str == null) {
bs.writeInt(0, 2);
return;
}
// Check if it's a tagged string (\x01<id>)
if (str.charCodeAt(0) === 1) {
bs.writeInt(2, 2);
const tag = parseInt(str.slice(1), 10);
bs.writeInt(tag, 10);
return;
}
// Check if it's a simple integer
const num = parseInt(str, 10);
if (!isNaN(num) && String(num) === str) {
bs.writeInt(3, 2);
const neg = num < 0;
const absNum = Math.abs(num);
bs.writeFlag(neg);
if (absNum < 128) {
bs.writeFlag(true);
bs.writeInt(absNum, 7);
} else if (absNum < 32768) {
bs.writeFlag(false);
bs.writeFlag(true);
bs.writeInt(absNum, 15);
} else {
bs.writeFlag(false);
bs.writeFlag(false);
bs.writeInt(absNum, 31);
}
return;
}
// Normal string
bs.writeInt(1, 2);
writeString(bs, str, compressed);
}

385
relay/auth.ts Normal file
View file

@ -0,0 +1,385 @@
import crypto from "node:crypto";
import { authLog } from "./logger.js";
/**
* T2csri authentication reimplements the TribesNext challenge-response
* flow in TypeScript. The relay acts as the client side.
*
* Flow (after Torque-level ConnectAccept):
* 1. Server sends: t2csri_pokeClient(version)
* 2. Client sends: certificate in 200-byte chunks via t2csri_sendCertChunk
* 3. Client sends: t2csri_sendChallenge(clientChallenge)
* 4. Server sends: encrypted challenge chunks via t2csri_getChallengeChunk
* 5. Server sends: t2csri_decryptChallenge
* 6. Client decrypts, verifies, sends: t2csri_challengeResponse(serverChallenge)
*/
export interface AccountCredentials {
/** Tab-separated: username\tguid\te\tn\tsig */
certificate: string;
/** Hex-encoded RSA private exponent (after RC4 decryption). */
privateKey: string;
}
/**
* Decrypt the RC4-encrypted private key using the account password.
*
* The stored format is `nonce:encryptedHex` where:
* - nonce = SHA1 of the plaintext private key (used for verification)
* - encryptedHex = hex-encoded RC4-encrypted private key bytes
* - RC4 key = SHA1(password + nonce), with 2048 bytes discarded from stream
*
* Based on t2csri_decryptAccountKey in clientSide.cs.
*/
export function decryptAccountKey(
encryptedKeyBase64: string,
_username: string,
password: string,
): string {
// Decode the base64 to get the "nonce:encryptedHex" string
const stored = Buffer.from(encryptedKeyBase64, "base64").toString("ascii");
const colonIdx = stored.indexOf(":");
if (colonIdx === -1) {
throw new Error("Invalid encrypted key format: missing colon separator");
}
const nonce = stored.slice(0, colonIdx);
const encryptedHex = stored.slice(colonIdx + 1);
// RC4 key = SHA1(password + nonce) as ASCII hex string (not hex-decoded)
// T2csri uses strCmp(char, "") to get ASCII values, so the key is the
// raw 40-char hex string, not the 20-byte decoded value.
const rc4Key = sha1(password + nonce);
// Hex-decode the encrypted data
const encryptedBytes = Buffer.from(encryptedHex, "hex");
// RC4 decrypt with 2048-byte stream discard
const decrypted = rc4WithDiscard(
Buffer.from(rc4Key, "ascii"),
encryptedBytes,
2048,
);
// Result is the hex-encoded private key
const privateKeyHex = decrypted.toString("hex");
// Verify against nonce (nonce = SHA1 of plaintext hex)
const hash = sha1(privateKeyHex);
if (hash === nonce) {
return privateKeyHex;
}
// T2csri tries fixing the last nibble/byte if hash doesn't match
const truncated = privateKeyHex.slice(0, -2);
for (let i = 0; i < 16; i++) {
const candidate = truncated + i.toString(16);
if (sha1(candidate) === nonce) return candidate;
}
for (let i = 0; i < 256; i++) {
const candidate = truncated + i.toString(16).padStart(2, "0");
if (sha1(candidate) === nonce) return candidate;
}
authLog.warn("Private key hash verification failed (password may be wrong)");
return privateKeyHex;
}
/** Load credentials from environment variables. */
export function loadCredentials(): AccountCredentials | null {
const certificate = process.env.T2_ACCOUNT_CERTIFICATE;
const encryptedKey = process.env.T2_ACCOUNT_ENCRYPTED_KEY;
const username = process.env.T2_ACCOUNT_NAME;
const password = process.env.T2_ACCOUNT_PASSWORD;
if (!certificate) {
authLog.warn("T2_ACCOUNT_CERTIFICATE not set");
return null;
}
let privateKey: string;
if (encryptedKey && username && password) {
privateKey = decryptAccountKey(encryptedKey, username, password);
} else {
authLog.warn(
"T2_ACCOUNT_ENCRYPTED_KEY / T2_ACCOUNT_NAME / T2_ACCOUNT_PASSWORD not fully set",
);
return null;
}
const cert = Buffer.from(certificate, "base64").toString("ascii");
return { certificate: cert, privateKey };
}
/**
* Generate a random hex challenge string.
* Mirrors T2csri's `rand(18446744073709551615).to_s(16)` a random
* 64-bit integer converted to hex WITHOUT leading zeros. This is critical:
* the challenge round-trips through BigInthex conversions (`.to_i(16)` /
* `.to_s(16)`) during RSA encryption/decryption, which strip leading zeros.
* If we generated "06ab..." it would decrypt as "6ab..." and fail to match.
*/
export function generateChallenge(): string {
const bytes = crypto.randomBytes(8);
const num = BigInt("0x" + bytes.toString("hex"));
return num.toString(16); // no leading zeros, matches Ruby .to_s(16)
}
/** Get the hex representation of an IPv4 address (e.g. "192.168.1.1" -> "c0a80101"). */
export function ipToHex(ip: string): string {
return ip
.split(".")
.map((octet) => parseInt(octet, 10).toString(16).padStart(2, "0"))
.join("");
}
/**
* Build the client challenge string: random_challenge + server_ip_hex.
*/
export function buildClientChallenge(serverIp: string): {
fullChallenge: string;
randomPart: string;
} {
const randomPart = generateChallenge();
const ipHex = ipToHex(serverIp);
return { fullChallenge: randomPart + ipHex, randomPart };
}
/**
* RSA modular exponentiation: base^exp mod modulus.
* All values are hex strings.
*/
export function rsaModExp(
baseHex: string,
expHex: string,
modHex: string,
): string {
const base = BigInt("0x" + baseHex);
const exp = BigInt("0x" + expHex);
const mod = BigInt("0x" + modHex);
const result = modPow(base, exp, mod);
// No padding — matches T2csri's Ruby `.to_s(16)` which strips leading zeros.
// Both server and client use unpadded hex throughout the challenge flow.
return result.toString(16);
}
/** Efficient modular exponentiation using square-and-multiply. */
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
if (mod === 1n) return 0n;
let result = 1n;
base = ((base % mod) + mod) % mod;
while (exp > 0n) {
if (exp & 1n) {
result = (result * base) % mod;
}
exp >>= 1n;
base = (base * base) % mod;
}
return result;
}
/**
* Decrypt the server's encrypted challenge using our private key.
* encrypted = challenge^e mod n (server encrypted with our public key)
* decrypted = encrypted^d mod n (we decrypt with private key)
*/
export function decryptChallenge(
encryptedHex: string,
privateKeyHex: string,
modulusHex: string,
): string {
return rsaModExp(encryptedHex, privateKeyHex, modulusHex);
}
/**
* Process the decrypted challenge from the server.
* Returns the server challenge portion if valid.
*/
export function processDecryptedChallenge(
decryptedHex: string,
originalClientChallenge: string,
): { valid: boolean; serverChallenge: string } {
// The decrypted value should be: clientChallenge + serverChallenge
const clientPart = decryptedHex.slice(0, originalClientChallenge.length);
if (clientPart.toLowerCase() !== originalClientChallenge.toLowerCase()) {
return { valid: false, serverChallenge: "" };
}
const serverChallenge = decryptedHex.slice(originalClientChallenge.length);
return { valid: true, serverChallenge };
}
/** SHA1 hash of a string, returned as hex. */
function sha1(data: string): string {
return crypto.createHash("sha1").update(data).digest("hex");
}
/** RC4 encrypt/decrypt with optional stream discard (drop-N). */
function rc4WithDiscard(
key: Buffer,
data: Buffer,
discardBytes: number = 0,
): Buffer {
// Initialize S-box
const S = new Uint8Array(256);
for (let i = 0; i < 256; i++) S[i] = i;
let j = 0;
for (let i = 0; i < 256; i++) {
j = (j + S[i] + key[i % key.length]) & 0xff;
[S[i], S[j]] = [S[j], S[i]];
}
let si = 0;
j = 0;
// Discard initial bytes from the keystream
for (let k = 0; k < discardBytes; k++) {
si = (si + 1) & 0xff;
j = (j + S[si]) & 0xff;
[S[si], S[j]] = [S[j], S[si]];
}
// Generate keystream and XOR
const result = Buffer.alloc(data.length);
for (let k = 0; k < data.length; k++) {
si = (si + 1) & 0xff;
j = (j + S[si]) & 0xff;
[S[si], S[j]] = [S[j], S[si]];
result[k] = data[k] ^ S[(S[si] + S[j]) & 0xff];
}
return result;
}
/**
* T2csri authentication state machine.
* Manages the challenge-response flow over an established connection.
*/
export class T2csriAuth {
private credentials: AccountCredentials;
private clientChallenge = "";
private encryptedChallenge = "";
private _authenticated = false;
constructor(credentials: AccountCredentials) {
this.credentials = credentials;
}
get authenticated(): boolean {
return this._authenticated;
}
/**
* Handle t2csri_pokeClient from server.
* Returns commands to send back (cert chunks + challenge).
*/
onPokeClient(
_version: string,
serverIp: string,
): { commands: Array<{ name: string; args: string[] }> } {
const commands: Array<{ name: string; args: string[] }> = [];
// Send certificate in 200-byte chunks
const cert = this.credentials.certificate;
for (let i = 0; i < cert.length; i += 200) {
commands.push({
name: "t2csri_sendCertChunk",
args: [cert.substring(i, i + 200)],
});
}
// Generate and send client challenge
const { fullChallenge } = buildClientChallenge(serverIp);
this.clientChallenge = fullChallenge;
commands.push({
name: "t2csri_sendChallenge",
args: [fullChallenge],
});
return { commands };
}
/** Handle t2csri_getChallengeChunk from server. */
onChallengeChunk(chunk: string): void {
this.encryptedChallenge += chunk;
}
/**
* Handle t2csri_decryptChallenge from server.
* Returns the challenge response command to send, or null on failure.
*/
onDecryptChallenge(): {
command: { name: string; args: string[] };
} | null {
// Sanitize: must be hex only
const challenge = this.encryptedChallenge.toLowerCase();
authLog.info(
{ challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length },
"Auth: starting challenge decryption",
);
for (let i = 0; i < challenge.length; i++) {
const c = challenge.charCodeAt(i);
const isHex =
(c >= 48 && c <= 57) || // 0-9
(c >= 97 && c <= 102); // a-f
if (!isHex) {
authLog.error(
{ charCode: c, pos: i, char: challenge[i] },
"Invalid characters in server challenge",
);
return null;
}
}
// Parse certificate to get modulus (n)
const fields = this.credentials.certificate.split("\t");
const modulusHex = fields[3];
authLog.debug(
{ encryptedLen: challenge.length, modulusLen: modulusHex?.length, privateKeyLen: this.credentials.privateKey.length },
"Auth: RSA parameters",
);
// Decrypt using private key
const decrypted = decryptChallenge(
challenge,
this.credentials.privateKey,
modulusHex,
);
authLog.debug(
{
decryptedLen: decrypted.length,
decryptedPrefix: decrypted.slice(0, 40),
clientChallenge: this.clientChallenge,
},
"Auth: decryption result",
);
// Verify client challenge is intact
const result = processDecryptedChallenge(decrypted, this.clientChallenge);
if (!result.valid) {
authLog.error(
{
decryptedPrefix: decrypted.slice(0, 40),
expectedPrefix: this.clientChallenge,
},
"Server sent back wrong client challenge",
);
return null;
}
void result.serverChallenge; // verified
this._authenticated = true;
return {
command: {
name: "t2csri_challengeResponse",
args: [result.serverChallenge],
},
};
}
}

175
relay/crc.ts Normal file
View file

@ -0,0 +1,175 @@
import fs from "node:fs/promises";
import path from "node:path";
// CRC-32 lookup table (reflected polynomial 0xEDB88320)
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let crc = i;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
crcTable[i] = crc;
}
/**
* Raw CRC-32 over a buffer, continuing from an initial value.
* Tribes 2 uses raw CRC (no XOR-in/XOR-out) verified against
* decompiled FUN_004411b0 in Tribes2.exe.
*/
export function crc32(data: Uint8Array, initial = 0): number {
let crc = initial;
for (let i = 0; i < data.length; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
}
return crc >>> 0;
}
/**
* Classes that derive from ShapeBaseData in Tribes 2.
* Determined by which DataBlockParser unpack functions call shapeBaseDataUnpack.
*/
const SHAPE_BASE_DATA_CLASSES = new Set([
"ShapeBaseData",
"PlayerData",
"VehicleData",
"FlyingVehicleData",
"HoverVehicleData",
"WheeledVehicleData",
"StaticShapeData",
"TurretData",
"ItemData",
"CameraData",
"MissionMarkerData",
]);
export interface CRCDataBlock {
objectId: number;
className: string;
shapeName: string;
}
// Manifest types (mirrored from src/manifest.ts)
type SourceTuple = [sourcePath: string] | [sourcePath: string, actualPath: string];
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
interface Manifest {
resources: Record<string, ResourceEntry>;
}
let cachedManifest: Manifest | null = null;
/**
* Path to manifest.json. Defaults to `public/manifest.json` relative to the
* project root, but can be overridden via `MANIFEST_PATH` env var for
* deployment outside the monorepo layout.
*/
async function loadManifest(basePath: string): Promise<Manifest> {
if (cachedManifest) return cachedManifest;
const manifestPath =
process.env.MANIFEST_PATH ||
path.join(basePath, "..", "..", "public", "manifest.json");
const raw = await fs.readFile(manifestPath, "utf-8");
cachedManifest = JSON.parse(raw) as Manifest;
return cachedManifest;
}
/** Resolve a game resource path to a local file path using the manifest. */
async function resolveGameFile(
resourcePath: string,
basePath: string,
): Promise<string | null> {
const manifest = await loadManifest(basePath);
const key = resourcePath.toLowerCase().replace(/\\/g, "/");
const entry = manifest.resources[key];
if (!entry) return null;
const [firstSeenPath, ...sources] = entry;
const [sourcePath, actualPath] = sources[sources.length - 1];
if (sourcePath) {
return path.join(basePath, "@vl2", sourcePath, actualPath ?? firstSeenPath);
}
return path.join(basePath, actualPath ?? firstSeenPath);
}
/**
* Compute the game CRC matching Tribes 2's FUN_00440580.
*
* Algorithm:
* 1. Start with seed as initial CRC value
* 2. For each ShapeBaseData datablock (sorted by objectId):
* - CRC-32 the shape file ("shapes/<shapeName>"), using running CRC
* - Accumulate file size
* - If includeTextures and not PlayerData: also CRC texture files
* 3. Final: crc += totalSize
*/
export async function computeGameCRC(
seed: number,
datablocks: CRCDataBlock[],
basePath: string,
includeTextures = false,
): Promise<{ crc: number; totalSize: number }> {
// Sort by objectId to match the binary's iteration order (0-2047)
const sorted = [...datablocks]
.filter((db) => SHAPE_BASE_DATA_CLASSES.has(db.className) && db.shapeName)
.sort((a, b) => a.objectId - b.objectId);
let crc = seed;
let totalSize = 0;
let filesFound = 0;
let filesMissing = 0;
console.log(
`[crc] starting computation: seed=0x${(seed >>> 0).toString(16)}, ` +
`${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` +
`includeTextures=${includeTextures}`,
);
for (const db of sorted) {
const shapePath = `shapes/${db.shapeName}`;
const localPath = await resolveGameFile(shapePath, basePath);
if (!localPath) {
console.log(
`[crc] SKIP id=${db.objectId} ${db.className} "${db.shapeName}" — not found in manifest`,
);
filesMissing++;
continue;
}
let data: Uint8Array;
try {
data = new Uint8Array(await fs.readFile(localPath));
} catch {
console.log(
`[crc] SKIP id=${db.objectId} ${db.className} "${db.shapeName}" — file read failed`,
);
filesMissing++;
continue;
}
const prevCrc = crc;
crc = crc32(data, crc);
totalSize += data.length;
filesFound++;
console.log(
`[crc] #${filesFound} id=${db.objectId} ${db.className} "${db.shapeName}" ` +
`size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`,
);
// TODO: If includeTextures && db.className !== "PlayerData",
// parse the DTS to enumerate textures and CRC each
// textures/<name>.png and textures/<name>.bm8 file.
// Most servers don't enable $Host::CRCTextures, so this is deferred.
if (includeTextures && db.className !== "PlayerData") {
// Texture CRC not yet implemented — would need DTS parsing
// to enumerate material textures for each shape.
}
}
crc = (crc + totalSize) >>> 0;
console.log(
`[crc] RESULT: ${filesFound} files CRC'd, ${filesMissing} missing, ` +
`crc=0x${crc.toString(16)}, totalSize=${totalSize}`,
);
return { crc, totalSize };
}

850
relay/gameConnection.ts Normal file
View file

@ -0,0 +1,850 @@
import dgram from "node:dgram";
import { EventEmitter } from "node:events";
import {
ConnectionProtocol,
ClientNetStringTable,
buildConnectChallengeRequest,
buildConnectRequest,
buildClientGamePacket,
buildRemoteCommandEvent,
buildCRCChallengeResponseEvent,
buildGhostingMessageEvent,
buildDisconnectPacket,
type ClientEvent,
type ClientMoveData,
} from "./protocol.js";
import { BitStream } from "t2-demo-parser";
import { T2csriAuth, loadCredentials } from "./auth.js";
import { connLog } from "./logger.js";
import type { ConnectionStatus } from "./types.js";
import { computeGameCRC, type CRCDataBlock } from "./crc.js";
// Tribes 2 protocol version and class CRC from the binary.
// These must match what the server expects.
const PROTOCOL_VERSION = 0x33; // 51 — from Tribes2.exe binary
// Real T2 client sends at ~32ms tick rate. Using 32ms ensures the server
// receives steady acks for guaranteed event delivery (datablock phase).
const KEEPALIVE_INTERVAL_MS = 32;
const CONNECT_TIMEOUT_MS = 30000;
interface GameConnectionEvents {
status: [status: ConnectionStatus, message?: string];
packet: [data: Uint8Array];
ping: [ms: number];
error: [error: Error];
close: [];
}
/**
* Manages a UDP connection to a Tribes 2 game server.
* Handles the connection handshake, keepalive, and packet forwarding.
*/
export class GameConnection extends EventEmitter<GameConnectionEvents> {
private socket: dgram.Socket | null = null;
private host: string;
private port: number;
private protocol = new ConnectionProtocol();
private auth: T2csriAuth | null = null;
private clientConnectSequence = Math.floor(Math.random() * 0xffffffff);
private serverConnectSequence = 0;
private _status: ConnectionStatus = "disconnected";
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private handshakeTimer: ReturnType<typeof setTimeout> | null = null;
private challengeRetryTimer: ReturnType<typeof setTimeout> | null = null;
private authDelayTimer: ReturnType<typeof setTimeout> | null = null;
private nextSendEventSeq = 0;
private pendingEvents: ClientEvent[] = [];
/** Events sent but not yet acked, keyed by packet sequence number. */
private sentEventsByPacket = new Map<number, { seq: number; event: ClientEvent }[]>();
/** Events waiting to be sent (new or retransmitted from lost packets). */
private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
private stringTable = new ClientNetStringTable();
/** Incrementing move index so the server doesn't deduplicate our moves. */
private moveIndex = 0;
private dataPacketCount = 0;
private rawMessageCount = 0;
private sendMoveCount = 0;
private _mapName?: string;
private observerEnforced = false;
/** Buffered move state — merged into the next keepalive tick. */
private bufferedMove: ClientMoveData | null = null;
/** Ticks remaining to hold the current trigger state before clearing. */
private triggerHoldTicks = 0;
/** Send timestamps by sequence number for RTT measurement. */
private sendTimestamps = new Map<number, number>();
/** Smoothed RTT in ms (exponential moving average). */
private smoothedPing = 0;
private lastPingEmit = 0;
constructor(address: string) {
super();
const [host, portStr] = address.split(":");
this.host = host;
this.port = parseInt(portStr, 10);
// Wire up packet delivery notifications for event retransmission.
this.protocol.onNotify = (packetSeq, acked) => {
this.handlePacketNotify(packetSeq, acked);
};
}
get status(): ConnectionStatus {
return this._status;
}
get connectSequence(): number {
return (this.clientConnectSequence ^ this.serverConnectSequence) >>> 0;
}
get mapName(): string | undefined {
return this._mapName;
}
private setStatus(status: ConnectionStatus, message?: string): void {
this._status = status;
this.emit("status", status, message);
}
/** Initiate connection to the game server. */
async connect(): Promise<void> {
connLog.info(
{ host: this.host, port: this.port },
"Connecting to game server",
);
const credentials = loadCredentials();
if (credentials) {
connLog.info("T2csri credentials loaded");
this.auth = new T2csriAuth(credentials);
} else {
connLog.warn("No T2csri credentials — connecting without auth");
}
this.socket = dgram.createSocket("udp4");
this.socket.on("message", (msg) => this.handleMessage(msg));
this.socket.on("error", (err) => {
this.emit("error", err);
this.disconnect();
});
this.setStatus("connecting");
// Start the handshake
this.sendChallengeRequest();
// Set overall connection timeout
this.handshakeTimer = setTimeout(() => {
if (this._status !== "connected" && this._status !== "authenticating") {
connLog.warn("Connection timed out");
this.setStatus("disconnected", "Connection timed out");
this.disconnect();
}
}, CONNECT_TIMEOUT_MS);
}
/** Send the initial ConnectChallengeRequest. */
private sendChallengeRequest(): void {
this.setStatus("challenging");
const packet = buildConnectChallengeRequest(
PROTOCOL_VERSION,
this.clientConnectSequence,
);
connLog.info(
{ bytes: packet.length, clientSeq: this.clientConnectSequence },
"Sending ConnectChallengeRequest",
);
this.sendRaw(packet);
// Retry challenge if no response
this.challengeRetryTimer = setTimeout(() => {
this.challengeRetryTimer = null;
if (this._status === "challenging") {
connLog.info("No challenge response, retrying");
this.sendRaw(packet);
}
}, 2000);
}
/** Handle an incoming UDP message. */
private handleMessage(msg: Buffer): void {
if (msg.length === 0) return;
this.rawMessageCount++;
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
connLog.debug(
{ bytes: msg.length, firstByte: msg[0], rawTotal: this.rawMessageCount },
"Raw UDP message received",
);
}
const firstByte = msg[0];
if (this.isOOBPacket(firstByte)) {
connLog.debug(
{ type: firstByte, bytes: msg.length },
"Received OOB packet",
);
this.handleOOBPacket(msg);
} else {
this.handleDataPacket(msg);
}
}
/** Check if a packet is OOB (out-of-band) vs data protocol. */
private isOOBPacket(firstByte: number): boolean {
// Disconnect (38) can arrive at any time
if (firstByte === 38) return true;
const oobTypes = [26, 28, 30, 32, 34, 36, 38, 40];
return (
this._status !== "connected" &&
this._status !== "authenticating" &&
oobTypes.includes(firstByte)
);
}
/** Handle out-of-band handshake packets. */
private handleOOBPacket(msg: Buffer): void {
const type = msg[0];
switch (type) {
case 28: // ChallengeReject
this.handleChallengeReject(msg);
break;
case 30: // ConnectChallengeResponse
this.handleChallengeResponse(msg);
break;
case 34: // ConnectReject
this.handleConnectReject(msg);
break;
case 36: // ConnectAccept
this.handleConnectAccept(msg);
break;
case 38: { // Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
let reason = "Server disconnected";
if (msg.length > 9) {
try {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
// Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2).
// Reason is Huffman-encoded via BitStream::writeString (no stringBuffer).
const bs = new BitStream(data.subarray(9));
const parsed = bs.readString();
if (parsed) reason = parsed;
} catch {
// Fall back to default reason
}
}
connLog.warn({ reason, bytes: msg.length }, "Server sent Disconnect packet");
this.setStatus("disconnected", reason);
this.disconnect();
break;
}
default:
connLog.warn({ type, bytes: msg.length }, "Unknown OOB packet type");
}
}
/** Handle ChallengeReject (type 28): U8(28) + U32(connectSeq) + ASCII reason. */
private handleChallengeReject(msg: Buffer): void {
let reason = "Challenge rejected";
if (msg.length > 5) {
const chars: number[] = [];
for (let i = 5; i < msg.length && msg[i] !== 0; i++) {
chars.push(msg[i]);
}
if (chars.length > 0) {
reason = String.fromCharCode(...chars);
}
}
connLog.warn({ reason }, "ChallengeReject received");
this.setStatus("disconnected", reason);
this.disconnect();
}
/** Handle ConnectChallengeResponse. */
private handleChallengeResponse(msg: Buffer): void {
if (msg.length < 14) {
connLog.error(
{ bytes: msg.length },
"ChallengeResponse too short",
);
return;
}
const dv = new DataView(
msg.buffer,
msg.byteOffset,
msg.byteLength,
);
const serverProtocolVersion = dv.getUint32(1, true);
this.serverConnectSequence = dv.getUint32(5, true);
const echoedClientSeq = dv.getUint32(9, true);
connLog.info(
{
serverProto: serverProtocolVersion,
serverSeq: this.serverConnectSequence,
echoedClientSeq,
},
"Received ChallengeResponse",
);
if (echoedClientSeq !== this.clientConnectSequence) {
connLog.error(
{ expected: this.clientConnectSequence, got: echoedClientSeq },
"Client connect sequence mismatch",
);
return;
}
// Send ConnectRequest
const connectArgv = this.buildConnectArgv();
const packet = buildConnectRequest(
this.serverConnectSequence,
this.clientConnectSequence,
PROTOCOL_VERSION,
false, // not pre-authenticated
connectArgv,
);
connLog.info(
{ bytes: packet.length, argv: connectArgv },
"Sending ConnectRequest",
);
this.sendRaw(packet);
}
/** Build the connection argv (name, race/gender, skin, voice, voicePitch). */
private buildConnectArgv(): string[] {
const name = process.env.T2_ACCOUNT_NAME || "Observer";
return [
name, // player name
"Male Human", // race/gender
"beagle", // skin
"male1", // voice
"1.0", // voice pitch
];
}
/** Handle ConnectAccept. */
private handleConnectAccept(_msg: Buffer): void {
connLog.info(
{
clientSeq: this.clientConnectSequence,
serverSeq: this.serverConnectSequence,
xorSeq: this.connectSequence,
connectSeqBit: this.connectSequence & 1,
},
"ConnectAccept received — connection established",
);
this.protocol.connectSequence = this.connectSequence;
this.startKeepalive();
if (this.auth) {
connLog.info("Starting T2csri authentication");
this.setStatus("authenticating");
} else {
this.enforceObserver();
this.setStatus("connected");
}
}
/** Handle ConnectReject. */
private handleConnectReject(msg: Buffer): void {
let reason = "Connection rejected";
if (msg.length > 1) {
const chars: number[] = [];
for (let i = 1; i < msg.length && msg[i] !== 0; i++) {
chars.push(msg[i]);
}
reason = String.fromCharCode(...chars);
}
connLog.warn({ reason }, "ConnectReject received");
this.setStatus("disconnected", reason);
this.disconnect();
}
/** Handle a data protocol packet (established connection). */
private handleDataPacket(msg: Buffer): void {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
this.dataPacketCount++;
if (this.dataPacketCount <= 20 || this.dataPacketCount % 50 === 0) {
connLog.debug(
{ bytes: data.length, total: this.dataPacketCount },
"Data packet received",
);
}
// Forward the raw packet to the browser for parsing
this.emit("packet", data);
// We still need to process the dnet header locally to track ack state
this.processPacketForAcks(data);
}
/** Process a packet's dnet header to maintain ack state. */
private processPacketForAcks(data: Uint8Array): void {
if (data.length < 4) return;
const bs = new BitStream(data);
bs.readFlag(); // gameFlag
const connectSeqBit = bs.readInt(1);
const seqNumber = bs.readInt(9);
const highestAck = bs.readInt(9);
const packetType = bs.readInt(2);
const ackByteCount = bs.readInt(3);
const ackMask = ackByteCount > 0 ? bs.readInt(8 * ackByteCount) : 0;
const result = this.protocol.processReceivedHeader({
seqNumber,
highestAck,
packetType,
connectSeqBit,
ackByteCount,
ackMask,
});
// Respond to PingPackets (type=1) with our own PingPacket.
// The server's processRawPacket calls sendPingResponse on receiving a
// PingPacket. Without this response, the server may time us out.
if (packetType === 1) {
connLog.debug({ seq: seqNumber }, "Received PingPacket, sending ping response");
const pingResponse = this.protocol.buildPingPacket();
this.sendRaw(pingResponse);
}
if (this.dataPacketCount <= 20 || this.dataPacketCount % 50 === 0) {
connLog.debug(
{
seq: seqNumber,
ack: highestAck,
type: packetType,
csb: connectSeqBit,
ackBytes: ackByteCount,
accepted: result.accepted,
dispatch: result.dispatchData,
ourSeq: this.protocol.lastSendSeq,
ourAck: this.protocol.lastSeqRecvd,
},
"Packet header parsed",
);
}
// Measure RTT from the acked sequence's send timestamp.
const sendTime = this.sendTimestamps.get(highestAck);
if (sendTime) {
const rtt = Date.now() - sendTime;
this.sendTimestamps.delete(highestAck);
// Exponential moving average (alpha=0.5 for responsive updates).
this.smoothedPing =
this.smoothedPing === 0 ? rtt : this.smoothedPing * 0.5 + rtt * 0.5;
// Emit ping updates at most every 2 seconds.
const now = Date.now();
if (now - this.lastPingEmit >= 2000) {
this.lastPingEmit = now;
this.emit("ping", Math.round(this.smoothedPing));
}
}
if (!result.accepted) {
connLog.warn(
{
seq: seqNumber,
ack: highestAck,
type: packetType,
csb: connectSeqBit,
expectedCsb: this.protocol.connectSequence & 1,
lastSeqRecvd: this.protocol.lastSeqRecvd,
lastSendSeq: this.protocol.lastSendSeq,
highestAckedSeq: this.protocol.highestAckedSeq,
total: this.dataPacketCount,
},
"Data packet REJECTED by protocol",
);
}
}
/** Handle a parsed T2csri event from the browser. */
handleAuthEvent(
eventName: string,
args: string[],
): void {
if (!this.auth) return;
switch (eventName) {
case "t2csri_pokeClient": {
connLog.info("Auth: received pokeClient, sending certificate + challenge");
const result = this.auth.onPokeClient(
args[0] || "",
this.host,
);
for (const cmd of result.commands) {
this.sendCommand(cmd.name, ...cmd.args);
}
break;
}
case "t2csri_getChallengeChunk": {
connLog.debug(
{ chunkLen: args[0]?.length ?? 0 },
"Auth: received challenge chunk",
);
this.auth.onChallengeChunk(args[0] || "");
break;
}
case "t2csri_decryptChallenge": {
connLog.info("Auth: decrypting challenge");
const result = this.auth.onDecryptChallenge();
if (result) {
const delay = 64 + Math.floor(Math.random() * 448);
connLog.info(
{ delayMs: delay },
"Auth: challenge verified, sending response",
);
this.authDelayTimer = setTimeout(() => {
this.authDelayTimer = null;
if (this._status !== "authenticating") return;
this.sendCommand(
result.command.name,
...result.command.args,
);
this.enforceObserver();
this.setStatus("connected");
}, delay);
} else {
connLog.error("Auth: challenge verification failed");
this.setStatus("disconnected", "Authentication failed");
this.disconnect();
}
break;
}
}
}
/** Respond to a CRCChallengeEvent by echoing values (legacy fallback). */
handleCRCChallenge(crcValue: number, field1: number, field2: number): void {
connLog.info(
{ crcValue, field1, field2 },
"CRC challenge received, sending echo response (legacy)",
);
const event = buildCRCChallengeResponseEvent(crcValue, field1, field2);
this.pendingEvents.push(event);
this.flushEvents();
}
/**
* Compute correct CRC over game shape files and send the response.
* The browser sends us the datablock list (from SimDataBlockEvents)
* along with the challenge seed and field2 to echo.
*/
async computeAndSendCRC(
seed: number,
field2: number,
datablocks: CRCDataBlock[],
includeTextures: boolean,
basePath: string,
): Promise<void> {
connLog.info(
{ seed: `0x${(seed >>> 0).toString(16)}`, datablocks: datablocks.length, includeTextures },
"Computing CRC over game files",
);
try {
const { crc, totalSize } = await computeGameCRC(seed, datablocks, basePath, includeTextures);
connLog.info(
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
"CRC computed, sending response",
);
const event = buildCRCChallengeResponseEvent(crc, totalSize, field2);
this.pendingEvents.push(event);
this.flushEvents();
} catch (e) {
connLog.error({ err: e }, "CRC computation failed");
}
}
/**
* Respond to a GhostingMessageEvent type 0 (GhostAlwaysDone) from the server.
* Sends back type 1 to enable ghosting (sets mGhosting=true on server).
*/
handleGhostAlwaysDone(sequence: number, ghostCount: number): void {
connLog.info(
{ sequence, ghostCount },
"GhostAlwaysDone received, sending acknowledgment (type 1)",
);
const event = buildGhostingMessageEvent(sequence, 1, ghostCount);
this.pendingEvents.push(event);
this.flushEvents();
}
/** Send a commandToServer as a RemoteCommandEvent. */
sendCommand(command: string, ...args: string[]): void {
connLog.debug({ command, args, eventSeq: this.nextSendEventSeq }, "Sending commandToServer");
const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
this.pendingEvents.push(...events);
this.flushEvents();
}
/** Flush pending events in a data packet. */
private flushEvents(): void {
// Assign sequence numbers to new pending events and add to send queue.
for (const event of this.pendingEvents.splice(0)) {
const seq = this.nextSendEventSeq++;
this.eventSendQueue.push({ seq, event });
}
if (this.eventSendQueue.length === 0) return;
this.sendDataPacketWithEvents();
}
/**
* Build and send a data packet that includes events from the send queue.
* Events stay tracked per-packet so they can be re-queued on loss.
*/
private sendDataPacketWithEvents(
move?: ClientMoveData,
): void {
const events = this.eventSendQueue.splice(0);
if (events.length === 0) return;
const startSeq = events[0].seq;
connLog.debug(
{
eventCount: events.length,
seqRange: `${startSeq}-${events[events.length - 1].seq}`,
sendSeq: this.protocol.lastSendSeq + 1,
},
"Sending data packet with guaranteed events",
);
// Track which events are in this packet for ack/loss handling.
// lastSendSeq+1 because buildSendPacketHeader increments it.
const packetSeq = this.protocol.lastSendSeq + 1;
this.sentEventsByPacket.set(packetSeq, events);
const moveData = move ?? {
x: 0, y: 0, z: 0,
yaw: 0, pitch: 0, roll: 0,
freeLook: false,
trigger: [false, false, false, false, false, false],
};
const packet = buildClientGamePacket(this.protocol, {
moves: [moveData],
moveStartIndex: this.moveIndex++,
events: events.map((e) => e.event),
nextSendEventSeq: startSeq,
});
this.sendRaw(packet);
}
/** Handle packet delivery notification from the protocol layer. */
private handlePacketNotify(packetSeq: number, acked: boolean): void {
const events = this.sentEventsByPacket.get(packetSeq);
if (!events || events.length === 0) {
this.sentEventsByPacket.delete(packetSeq);
return;
}
this.sentEventsByPacket.delete(packetSeq);
if (acked) {
connLog.debug(
{
packetSeq,
ackedEvents: events.map((e) => e.seq),
},
"Guaranteed events acked",
);
} else {
// Packet was lost — re-queue events at the HEAD of the send queue
// so they are retransmitted in the next outgoing data packet.
connLog.warn(
{
packetSeq,
lostEvents: events.map((e) => e.seq),
},
"Packet lost, re-queuing guaranteed events for retransmission",
);
this.eventSendQueue.unshift(...events);
}
}
/** Enforce observer team so we spectate instead of spawning. */
private enforceObserver(): void {
if (this.observerEnforced) return;
this.observerEnforced = true;
connLog.info("Enforcing observer mode (setPlayerTeam 0)");
this.sendCommand("setPlayerTeam", "0");
}
/** Set the map name (from GameInfoResponse during server query). */
setMapName(mapName: string): void {
this._mapName = mapName;
}
/**
* Buffer a move to be sent in the next keepalive tick.
* Moves are merged into the 32ms keepalive cadence rather than sent as
* separate packets, because the server's Camera control object processes
* moves from the regular tick stream (separate extra packets can be
* ignored or cause trigger edge detection issues).
*/
sendMove(move: ClientMoveData): void {
this.sendMoveCount++;
if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) {
connLog.debug(
{ yaw: move.yaw, pitch: move.pitch, x: move.x, y: move.y, z: move.z, total: this.sendMoveCount },
"Sending move",
);
}
// During trigger hold, merge trigger flags so rapid move updates
// (e.g. from useFrame at 60fps) can't overwrite a pending trigger
// before the server sees it.
if (this.triggerHoldTicks > 0 && this.bufferedMove) {
move = {
...move,
trigger: this.bufferedMove.trigger.map(
(held, i) => held || (move.trigger[i] ?? false),
),
};
}
this.bufferedMove = move;
// If any trigger is set, hold it for 2 ticks to ensure the server
// sees the edge (true then false on the next tick).
if (move.trigger.some(Boolean)) {
this.triggerHoldTicks = 2;
}
}
/** Send the current move state as a keepalive packet at the tick rate. */
private sendTickMove(): void {
const move: ClientMoveData = this.bufferedMove ?? {
x: 0,
y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
freeLook: false,
trigger: [false, false, false, false, false, false],
};
// Record send time keyed by the 9-bit sequence number (0511) that the
// server will echo back in highestAck. lastSendSeq is the full counter;
// the wire format uses only the low 9 bits.
const nextSeq9 = (this.protocol.lastSendSeq + 1) & 0x1ff;
this.sendTimestamps.set(nextSeq9, Date.now());
// Absorb any new pending events into the send queue.
for (const event of this.pendingEvents.splice(0)) {
const seq = this.nextSendEventSeq++;
this.eventSendQueue.push({ seq, event });
}
// If we have events waiting to be sent (new or re-queued from lost
// packets), include them in this tick's data packet.
if (this.eventSendQueue.length > 0) {
this.sendDataPacketWithEvents(move);
} else {
const packet = buildClientGamePacket(this.protocol, {
moves: [move],
moveStartIndex: this.moveIndex++,
});
this.sendRaw(packet);
}
// Count down trigger hold, then clear triggers.
if (this.triggerHoldTicks > 0) {
this.triggerHoldTicks--;
if (this.triggerHoldTicks === 0 && this.bufferedMove) {
this.bufferedMove = {
...this.bufferedMove,
trigger: [false, false, false, false, false, false],
};
}
}
}
/** Start keepalive timer. */
private startKeepalive(): void {
let keepaliveCount = 0;
this.keepaliveTimer = setInterval(() => {
keepaliveCount++;
if (keepaliveCount % 300 === 0) { // ~10s at 32ms tick rate
connLog.info(
{
dataPackets: this.dataPacketCount,
rawMessages: this.rawMessageCount,
ourSeq: this.protocol.lastSendSeq,
ourAck: this.protocol.lastSeqRecvd,
theirAck: this.protocol.highestAckedSeq,
},
"Connection status",
);
}
this.sendTickMove();
}, KEEPALIVE_INTERVAL_MS);
}
/** Send raw bytes to the server. */
private sendRaw(data: Uint8Array): void {
if (!this.socket) return;
this.socket.send(data, this.port, this.host, (err) => {
if (err) {
connLog.error({ err, bytes: data.length }, "UDP send failed");
}
});
}
/** Disconnect from the server, sending a Disconnect OOB packet first. */
disconnect(): void {
if (this._status === "disconnected" && !this.socket) return;
connLog.info("Disconnecting");
// Send a Disconnect packet so the server knows we're leaving
if (this.socket && this.serverConnectSequence !== 0) {
try {
const packet = buildDisconnectPacket(this.connectSequence);
this.socket.send(packet, this.port, this.host);
connLog.info("Sent Disconnect packet to server");
} catch {
// Best effort
}
}
if (this.keepaliveTimer) {
clearInterval(this.keepaliveTimer);
this.keepaliveTimer = null;
}
if (this.challengeRetryTimer) {
clearTimeout(this.challengeRetryTimer);
this.challengeRetryTimer = null;
}
if (this.authDelayTimer) {
clearTimeout(this.authDelayTimer);
this.authDelayTimer = null;
}
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
}
if (this.socket) {
try {
this.socket.close();
} catch {
// Already closed
}
this.socket = null;
}
if (this._status !== "disconnected") {
this.setStatus("disconnected");
}
this.emit("close");
}
}

25
relay/logger.ts Normal file
View file

@ -0,0 +1,25 @@
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
export const logger = pino({
level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
...(isDev && {
transport: {
target: "pino-pretty",
options: { colorize: true },
},
}),
});
/** Relay server (WebSocket + dispatch). */
export const relayLog = logger.child({ module: "relay" });
/** UDP game connection handshake and protocol. */
export const connLog = logger.child({ module: "conn" });
/** Master server / server list queries. */
export const masterLog = logger.child({ module: "master" });
/** T2csri authentication. */
export const authLog = logger.child({ module: "auth" });

308
relay/masterQuery.ts Normal file
View file

@ -0,0 +1,308 @@
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;
}
}

546
relay/protocol.ts Normal file
View file

@ -0,0 +1,546 @@
import { BitStreamWriter } from "./BitStreamWriter.js";
import { packNetString, writeString } from "./HuffmanWriter.js";
const DataPacket = 0;
const PingPacket = 1;
const AckPacket = 2;
const NetEventClassFirst = 255;
const CRCChallengeResponseEventClassId = NetEventClassFirst + 1; // index 1
const GhostingMessageEventClassId = NetEventClassFirst + 4; // index 4
const NetStringEventClassId = NetEventClassFirst + 7; // index 7
const RemoteCommandEventClassId = NetEventClassFirst + 9; // index 9
/**
* Manages the connection protocol state for the client side.
* Mirrors ConnectionProtocol from dnet.cc, but for building outgoing packets.
*/
export class ConnectionProtocol {
lastSeqRecvdAtSend: number[] = new Array(32).fill(0);
lastSeqRecvd = 0;
highestAckedSeq = 0;
lastSendSeq = 0;
ackMask = 0;
connectSequence = 0;
lastRecvAckAck = 0;
/** Called for each outgoing packet when its delivery status is determined. */
onNotify: ((packetSeq: number, acked: boolean) => void) | null = null;
private _sendCount = 0;
buildSendPacketHeader(
packetType: number = DataPacket,
): BitStreamWriter {
const bs = new BitStreamWriter(1500);
// gameFlag — always true for data connection packets
bs.writeFlag(true);
// connectSeqBit — LSB of connectSequence
bs.writeInt(this.connectSequence & 1, 1);
// Increment send sequence
this.lastSendSeq = (this.lastSendSeq + 1) >>> 0;
this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] =
this.lastSeqRecvd >>> 0;
// seqNumber (9 bits)
bs.writeInt(this.lastSendSeq & 0x1ff, 9);
// highestAck (9 bits) — the highest seq we've received from server
bs.writeInt(this.lastSeqRecvd & 0x1ff, 9);
// packetType (2 bits)
bs.writeInt(packetType, 2);
// ackByteCount (3 bits) + ackMask
// We need to send back our ack mask for packets we've received
const mask = this.ackMask >>> 0;
let ackByteCount = 0;
if (mask !== 0) {
if (mask & 0xff000000) ackByteCount = 4;
else if (mask & 0x00ff0000) ackByteCount = 3;
else if (mask & 0x0000ff00) ackByteCount = 2;
else ackByteCount = 1;
}
bs.writeInt(ackByteCount, 3);
if (ackByteCount > 0) {
bs.writeInt(mask, ackByteCount * 8);
}
this._sendCount++;
if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
const typeName = packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
console.log(
`[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` +
`highestAck=${this.lastSeqRecvd} type=${typeName} ` +
`ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` +
`(${mask.toString(2).replace(/^0+/, "") || "0"})`,
);
}
return bs;
}
/** Process a received packet header, updating our state. */
processReceivedHeader(header: {
seqNumber: number;
highestAck: number;
packetType: number;
connectSeqBit: number;
ackByteCount: number;
ackMask: number;
}): { accepted: boolean; dispatchData: boolean } {
if (header.connectSeqBit !== (this.connectSequence & 1)) {
return { accepted: false, dispatchData: false };
}
if (header.ackByteCount > 4 || header.packetType > 2) {
return { accepted: false, dispatchData: false };
}
let seqNumber =
(header.seqNumber | (this.lastSeqRecvd & 0xffff_fe00)) >>> 0;
if (seqNumber < this.lastSeqRecvd) {
seqNumber = (seqNumber + 0x200) >>> 0;
}
if (this.lastSeqRecvd + 0x1f < seqNumber) {
return { accepted: false, dispatchData: false };
}
let highestAck =
(header.highestAck | (this.highestAckedSeq & 0xffff_fe00)) >>> 0;
if (highestAck < this.highestAckedSeq) {
highestAck = (highestAck + 0x200) >>> 0;
}
if (this.lastSendSeq < highestAck) {
return { accepted: false, dispatchData: false };
}
const seqShift = (seqNumber - this.lastSeqRecvd) & 0x1f;
this.ackMask = (this.ackMask << seqShift) >>> 0;
if (header.packetType === DataPacket) {
this.ackMask = (this.ackMask | 1) >>> 0;
}
for (
let ackSeq = this.highestAckedSeq + 1;
ackSeq <= highestAck;
ackSeq++
) {
const isAcked =
(header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0;
if (isAcked) {
this.lastRecvAckAck =
this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
}
if (this.onNotify) {
this.onNotify(ackSeq, isAcked);
}
}
if (seqNumber - this.lastRecvAckAck > 0x20) {
this.lastRecvAckAck = seqNumber - 0x20;
}
this.highestAckedSeq = highestAck;
const dispatchData =
this.lastSeqRecvd !== seqNumber &&
header.packetType === DataPacket;
this.lastSeqRecvd = seqNumber;
return { accepted: true, dispatchData };
}
/** Build a ping response packet. */
buildPingPacket(): Uint8Array {
const bs = this.buildSendPacketHeader(PingPacket);
return bs.getBuffer();
}
/** Build an ack-only packet (no game data). */
buildAckPacket(): Uint8Array {
const bs = this.buildSendPacketHeader(AckPacket);
return bs.getBuffer();
}
/**
* Build a data packet with game payload.
* The caller provides a callback that writes game data to the stream
* after the dnet header.
*/
buildDataPacket(
writePayload: (bs: BitStreamWriter) => void,
): Uint8Array {
const bs = this.buildSendPacketHeader(DataPacket);
writePayload(bs);
return bs.getBuffer();
}
}
/**
* Build a GameConnection client data packet.
* ClientServer format (from checkPacketSend + GameConnection::writePacket):
* 1. Rate info (2 flag bits from checkPacketSend, before writePacket)
* 2. GameConnection fields:
* a. Flag (firstPerson: cameraPos == 0)
* b. U32 (controlObjectChecksum)
* c. moveWritePacket (move count + packed moves)
* d. Flag (updateFirstPerson) false for observer
* e. Flag (updateCameraFov) false for observer
* 3. NetConnection::writePacket:
* a. eventWritePacket (events)
* b. ghostWritePacket (ghosts client doesn't write any)
*/
export function buildClientGamePacket(
protocol: ConnectionProtocol,
options: {
moves?: ClientMoveData[];
moveStartIndex?: number;
events?: ClientEvent[];
nextSendEventSeq?: number;
} = {},
): Uint8Array {
return protocol.buildDataPacket((bs) => {
// NetConnection::checkPacketSend writes rate info BEFORE writePacket.
// handlePacket on the server reads these before calling readPacket.
// Both sides send rate flags — we send false (no changes).
bs.writeFlag(false); // mCurRate.changed
bs.writeFlag(false); // mMaxRate.changed
// GameConnection::writePacket (client→server path)
// 1. First person flag (cameraPos == 0 → firstPerson)
bs.writeFlag(false); // not first person
// 2. 32-bit control object value
bs.writeU32(0);
// 3. moveWritePacket: writeInt(start, 32) + writeInt(count, 5) + moves
const moves = options.moves ?? [];
bs.writeU32(options.moveStartIndex ?? 0);
bs.writeInt(moves.length, 5); // MoveCountBits = 5
for (const move of moves) {
writeMove(bs, move);
}
// 4. FOV change flag (Tribes 2 binary reads one flag here, not two
// like TorqueSDK-1.2 — verified against decompiled Tribes2.exe)
bs.writeFlag(false);
// NetConnection::writePacket — events + ghosts
// eventWritePacket:
// Unguaranteed events: none from observer
bs.writeFlag(false); // end unguaranteed
// Guaranteed events
if (options.events && options.events.length > 0) {
let seq = options.nextSendEventSeq ?? 0;
for (const event of options.events) {
bs.writeFlag(true); // more guaranteed events
bs.writeFlag(false); // not sequential shortcut
bs.writeInt(seq & 0x7f, 7);
seq++;
bs.writeInt(event.classId - NetEventClassFirst, 6);
event.write(bs);
}
}
bs.writeFlag(false); // end guaranteed events
// ghostWritePacket: client doesn't ghost, so nothing written
// (doesGhostFrom() returns false for client)
});
}
export interface ClientMoveData {
/** Movement axes: float [-1, 1]. Encoded as 6-bit unsigned (0-32, center=16). */
x: number;
y: number;
z: number;
/** Rotation deltas: float (radians per tick). Encoded as 16-bit signed (×65536).
* Server adds these directly to camera rotation each tick. */
yaw: number;
pitch: number;
roll: number;
freeLook: boolean;
trigger: boolean[];
}
export interface ClientEvent {
classId: number;
write: (bs: BitStreamWriter) => void;
}
/**
* Write a Move struct to the stream.
*
* Wire format (from Tribes2.exe FUN_00601800):
* flag(yaw?) + optional 16-bit yaw (rotation, signed)
* flag(pitch?) + optional 16-bit pitch
* flag(roll?) + optional 16-bit roll
* 6-bit x + 6-bit y + 6-bit z (movement, unsigned 0-32, center=16)
* flag(freeLook) + 6×flag(trigger)
*/
function writeMove(bs: BitStreamWriter, move: ClientMoveData): void {
// Rotation (flag + optional 16-bit signed).
// Pack: int16 = (int)(radians * 65536). Server unpacks: float = (short)int16 / 65536.
const pyaw = Math.round(move.yaw * 65536) | 0;
const ppitch = Math.round(move.pitch * 65536) | 0;
const proll = Math.round(move.roll * 65536) | 0;
if (pyaw !== 0) {
bs.writeFlag(true);
bs.writeInt(pyaw & 0xffff, 16);
} else {
bs.writeFlag(false);
}
if (ppitch !== 0) {
bs.writeFlag(true);
bs.writeInt(ppitch & 0xffff, 16);
} else {
bs.writeFlag(false);
}
if (proll !== 0) {
bs.writeFlag(true);
bs.writeInt(proll & 0xffff, 16);
} else {
bs.writeFlag(false);
}
// Movement (6-bit unsigned, 0-32, center=16).
// Pack: uint6 = clamp(float * 16 + 16, 0, 32). Server unpacks: float = (val - 16) / 16.
const px = Math.max(0, Math.min(32, Math.round(move.x * 16 + 16)));
const py = Math.max(0, Math.min(32, Math.round(move.y * 16 + 16)));
const pz = Math.max(0, Math.min(32, Math.round(move.z * 16 + 16)));
bs.writeInt(px, 6);
bs.writeInt(py, 6);
bs.writeInt(pz, 6);
// FreeLook flag
bs.writeFlag(move.freeLook);
// Trigger keys (6 triggers)
for (let i = 0; i < 6; i++) {
bs.writeFlag(move.trigger[i] ?? false);
}
}
/**
* Client-side net string table for tagged string synchronization.
* Assigns 10-bit IDs to strings and generates NetStringEvents to
* register them with the server before use in RemoteCommandEvents.
*/
export class ClientNetStringTable {
private nextId = 1;
private strings = new Map<string, number>();
/** Get or assign a 10-bit string ID. Returns the ID and whether it's new. */
getOrAdd(str: string): { id: number; isNew: boolean } {
const existing = this.strings.get(str);
if (existing !== undefined) return { id: existing, isNew: false };
const id = this.nextId++;
if (id > 1023) throw new Error("Net string table overflow (10-bit IDs)");
this.strings.set(str, id);
return { id, isNew: true };
}
}
/** Build a NetStringEvent to register a string with the server. */
export function buildNetStringEvent(
id: number,
value: string,
): ClientEvent {
return {
classId: NetStringEventClassId,
write(bs: BitStreamWriter) {
// NetStringEvent::pack (FUN_00589b60 inverse):
// writeInt(id, 10) + writeFlag(hasValue) + writeString(value)
bs.writeInt(id, 10);
bs.writeFlag(true);
writeString(bs, value, true);
},
};
}
/**
* Build a RemoteCommandEvent for commandToServer.
* The function name must be sent as a TagString (type=2, 10-bit ID)
* with a corresponding NetStringEvent sent beforehand.
* Returns the RemoteCommandEvent and any required NetStringEvents.
*/
export function buildRemoteCommandEvent(
stringTable: ClientNetStringTable,
command: string,
...args: string[]
): ClientEvent[] {
const events: ClientEvent[] = [];
// Register the function name in the string table
const { id: cmdId, isNew } = stringTable.getOrAdd(command);
if (isNew) {
events.push(buildNetStringEvent(cmdId, command));
}
// Build the RemoteCommandEvent
events.push({
classId: RemoteCommandEventClassId,
write(bs: BitStreamWriter) {
// RemoteCommandEvent::pack (FUN_005bfd40):
// writeInt(argc, 5) then argc × conn->packString
// argv[0] = function name (must be TagString for process() to work)
const argc = Math.min(1 + args.length, 20);
bs.writeInt(argc, 5);
// Pack function name as TagString (type=2, 10-bit ID)
bs.writeInt(2, 2); // TagString type
bs.writeInt(cmdId, 10);
// Pack remaining args as regular strings
for (let i = 0; i < argc - 1; i++) {
packNetString(bs, args[i], true);
}
},
});
return events;
}
/**
* Build a CRCChallengeResponseEvent to reply to the server's CRC challenge.
* Format: 3×U32 (crcValue, field1, field2).
* The real client computes CRC over game files; we echo back dummy values.
* The server always proceeds to the script callback regardless of CRC match
* (but schedules a delayed kick if values are wrong).
*/
export function buildCRCChallengeResponseEvent(
crcValue: number,
field1: number,
field2: number,
): ClientEvent {
return {
classId: CRCChallengeResponseEventClassId,
write(bs: BitStreamWriter) {
bs.writeU32(crcValue);
bs.writeU32(field1);
bs.writeU32(field2);
},
};
}
/**
* Build a GhostingMessageEvent to acknowledge GhostAlwaysDone from the server.
* When the server sends type 0 (GhostAlwaysDone), the client must respond
* with type 1 to enable ghost writes (mGhosting=true on the server).
*/
export function buildGhostingMessageEvent(
sequence: number,
message: number,
ghostCount: number,
): ClientEvent {
return {
classId: GhostingMessageEventClassId,
write(bs: BitStreamWriter) {
bs.writeU32(sequence);
bs.writeInt(message, 3);
bs.writeInt(ghostCount, 11);
},
};
}
// ── Out-of-band (OOB) packet types ──
/**
* Build a ConnectChallengeRequest (type 26) OOB packet.
* Format from Tribes2.exe: U8(26) + U32(proto) + U32(seq) + HuffString(password) + Flag(auth)
*/
export function buildConnectChallengeRequest(
protocolVersion: number,
clientConnectSequence: number,
joinPassword: string = "",
): Uint8Array {
const bs = new BitStreamWriter(512);
bs.writeU8(26); // ConnectChallengeRequest type
bs.writeU32(protocolVersion);
bs.writeU32(clientConnectSequence);
writeString(bs, joinPassword);
// No auth data
bs.writeFlag(false);
return bs.getBuffer();
}
/** Build a ConnectRequest (type 32) OOB packet. */
export function buildConnectRequest(
serverConnectSequence: number,
clientConnectSequence: number,
protocolVersion: number,
authenticated: boolean,
argv: string[] = [],
): Uint8Array {
const bs = new BitStreamWriter(1024);
bs.writeU8(32); // ConnectRequest type
bs.writeU32(serverConnectSequence);
bs.writeU32(clientConnectSequence);
bs.writeU32(protocolVersion);
bs.writeFlag(authenticated);
// argc + argv (connection parameters: name, race, skin, voice, etc.)
// Argv uses Huffman-encoded strings per Tribes2.exe binary.
bs.writeU32(argv.length);
for (const arg of argv) {
writeString(bs, arg);
}
return bs.getBuffer();
}
/** Build a Disconnect (type 38) OOB packet. */
export function buildDisconnectPacket(
connectSequence: number,
): Uint8Array {
const bs = new BitStreamWriter(64);
bs.writeU8(38); // Disconnect type
bs.writeU32(connectSequence);
writeString(bs, ""); // reason
return bs.getBuffer();
}
// ── Master server query packets ──
/** Build a MasterServerListRequest (type 6). */
export function buildMasterServerListRequest(
queryFlags: number = 0,
key: number = 0,
): Uint8Array {
const bs = new BitStreamWriter(256);
bs.writeU8(6); // MasterServerListRequest
bs.writeU8(queryFlags);
bs.writeU32(key);
// Game type / mission type filters (empty = all)
bs.writeU8(0xff); // maxPlayers filter (0xff = any)
bs.writeU32(0); // regionMask (0 = any)
bs.writeU32(0); // version (0 = any)
bs.writeU8(0); // filter flags
bs.writeU8(0); // maxBots
bs.writeU16(0); // minCPU
bs.writeU8(0); // buddyCount
return bs.getBuffer();
}
/** Build a GamePingRequest (type 14). */
export function buildGamePingRequest(
flags: number = 0,
key: number = 0,
): Uint8Array {
const bs = new BitStreamWriter(64);
bs.writeU8(14); // GamePingRequest
bs.writeU8(flags);
bs.writeU32(key);
return bs.getBuffer();
}
/** Build a GameInfoRequest (type 18). */
export function buildGameInfoRequest(
flags: number = 0,
key: number = 0,
): Uint8Array {
const bs = new BitStreamWriter(64);
bs.writeU8(18); // GameInfoRequest
bs.writeU8(flags);
bs.writeU32(key);
return bs.getBuffer();
}

369
relay/server.ts Normal file
View file

@ -0,0 +1,369 @@
import http from "node:http";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocketServer, WebSocket } from "ws";
import { queryServerList } from "./masterQuery.js";
import { GameConnection } from "./gameConnection.js";
import { loadCredentials } from "./auth.js";
import { relayLog } from "./logger.js";
import type { ClientMessage, ServerMessage, ServerInfo } from "./types.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Base path for game files (extracted VL2 contents). */
const GAME_BASE_PATH =
process.env.GAME_BASE_PATH || path.resolve(__dirname, "..", "docs", "base");
const MANIFEST_PATH =
process.env.MANIFEST_PATH ||
path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json");
const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10);
const MASTER_SERVER =
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
/** HTTP server for health checks; WebSocket upgrades are handled separately. */
const httpServer = http.createServer(async (req, res) => {
if (req.url === "/health") {
const checks: Record<string, { ok: boolean; detail?: string }> = {};
// Check game assets directory.
try {
const stat = await fs.stat(GAME_BASE_PATH);
const entries = await fs.readdir(GAME_BASE_PATH);
checks.gameAssets = {
ok: stat.isDirectory() && entries.length > 0,
detail: `${entries.length} entries in ${GAME_BASE_PATH}`,
};
} catch {
checks.gameAssets = { ok: false, detail: `${GAME_BASE_PATH} not found` };
}
// Check manifest.
try {
const raw = await fs.readFile(MANIFEST_PATH, "utf-8");
const manifest = JSON.parse(raw);
const count = Object.keys(manifest.resources ?? {}).length;
checks.manifest = { ok: count > 0, detail: `${count} resources` };
} catch {
checks.manifest = { ok: false, detail: `${MANIFEST_PATH} not found` };
}
// Check credentials.
const creds = loadCredentials();
checks.credentials = {
ok: creds !== null,
detail: creds ? "loaded" : "missing or incomplete",
};
const allOk = Object.values(checks).every((c) => c.ok);
res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2));
return;
}
res.writeHead(404);
res.end();
});
const wss = new WebSocketServer({ server: httpServer });
httpServer.listen(RELAY_PORT, "0.0.0.0", () => {
relayLog.info({ port: RELAY_PORT }, "Relay server listening");
});
/** Cached server list from the most recent master query. */
let cachedServers: ServerInfo[] = [];
wss.on("connection", (ws) => {
relayLog.info("Browser client connected");
let gameConnection: GameConnection | null = null;
let lastJoinAddress: string | null = null;
let retryCount = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 6000;
const RETRYABLE_REASONS = ["Server is cycling mission"];
async function connectToServer(ws: WebSocket, address: string): Promise<void> {
if (gameConnection) {
gameConnection.disconnect();
}
gameConnection = new GameConnection(address);
// Set mapName from the cached server list if available.
const cachedServer = cachedServers.find(
(s) => s.address === address,
);
if (cachedServer?.mapName) {
gameConnection.setMapName(cachedServer.mapName);
}
gameConnection.on("status", (status, statusMessage) => {
relayLog.info(
{
status,
statusMessage,
connectSequence: gameConnection?.connectSequence,
mapName: gameConnection?.mapName,
},
"Game connection status changed",
);
// Auto-retry on retryable disconnect reasons.
if (
status === "disconnected" &&
statusMessage &&
RETRYABLE_REASONS.some((r) => statusMessage.includes(r)) &&
retryCount < MAX_RETRIES &&
lastJoinAddress === address
) {
retryCount++;
relayLog.info(
{ attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS },
"Retryable disconnect — will reconnect",
);
sendToClient(ws, {
type: "status",
status: "connecting",
message: `${statusMessage} — retrying (${retryCount}/${MAX_RETRIES})...`,
connectSequence: gameConnection?.connectSequence,
mapName: gameConnection?.mapName,
});
retryTimer = setTimeout(() => {
retryTimer = null;
if (lastJoinAddress === address && ws.readyState === WebSocket.OPEN) {
connectToServer(ws, address);
}
}, RETRY_DELAY_MS);
return;
}
sendToClient(ws, {
type: "status",
status,
message: statusMessage,
connectSequence: gameConnection?.connectSequence,
mapName: gameConnection?.mapName,
});
});
gameConnection.on("ping", (ms) => {
sendToClient(ws, { type: "ping", ms });
});
let forwardedPackets = 0;
gameConnection.on("packet", (packetData) => {
forwardedPackets++;
if (ws.readyState === WebSocket.OPEN) {
ws.send(packetData, { binary: true });
} else {
relayLog.warn(
{ wsState: ws.readyState, total: forwardedPackets },
"Dropped game packet — WebSocket not open",
);
}
if (forwardedPackets <= 5 || forwardedPackets % 500 === 0) {
relayLog.debug(
{ bytes: packetData.length, total: forwardedPackets },
"Forwarded game packet to browser",
);
}
});
gameConnection.on("error", (err) => {
relayLog.error({ err }, "Game connection error");
sendToClient(ws, {
type: "error",
message: err.message,
});
});
gameConnection.on("close", () => {
relayLog.info("Game connection closed");
gameConnection = null;
});
await gameConnection.connect();
}
ws.on("message", async (data, isBinary) => {
try {
if (isBinary) {
return;
}
const message: ClientMessage = JSON.parse(data.toString());
await handleClientMessage(ws, message);
} catch (e) {
const err = e instanceof Error ? e.message : String(e);
relayLog.error({ err: e }, "Error handling client message");
sendToClient(ws, { type: "error", message: err });
}
});
ws.on("close", () => {
relayLog.info("Browser client disconnected");
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (gameConnection) {
gameConnection.disconnect();
gameConnection = null;
}
});
async function handleClientMessage(
ws: WebSocket,
message: ClientMessage,
): Promise<void> {
switch (message.type) {
case "listServers": {
relayLog.info("Querying master server for server list");
try {
const servers = await queryServerList(MASTER_SERVER);
cachedServers = servers;
relayLog.info(
{ count: servers.length },
"Returning server list to browser",
);
sendToClient(ws, { type: "serverList", servers });
} catch (e) {
relayLog.error({ err: e }, "Master query failed");
sendToClient(ws, {
type: "error",
message: `Master query failed: ${e}`,
});
}
break;
}
case "joinServer": {
relayLog.info({ address: message.address }, "Join server requested");
if (gameConnection) {
relayLog.info("Disconnecting existing game connection");
gameConnection.disconnect();
}
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
retryCount = 0;
lastJoinAddress = message.address;
await connectToServer(ws, message.address);
break;
}
case "disconnect": {
relayLog.info("Disconnect requested");
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (gameConnection) {
gameConnection.disconnect();
gameConnection = null;
}
break;
}
case "sendCommand": {
if (gameConnection) {
const authEvents = [
"t2csri_pokeClient",
"t2csri_getChallengeChunk",
"t2csri_decryptChallenge",
];
if (authEvents.includes(message.command)) {
relayLog.debug(
{ event: message.command },
"Forwarding auth event from browser",
);
gameConnection.handleAuthEvent(
message.command,
message.args,
);
} else {
relayLog.debug(
{ command: message.command },
"Forwarding command to server",
);
gameConnection.sendCommand(message.command, ...message.args);
}
}
break;
}
case "sendCRCResponse": {
if (gameConnection) {
relayLog.debug("Forwarding CRC response from browser (legacy echo)");
gameConnection.handleCRCChallenge(
message.crcValue,
message.field1,
message.field2,
);
}
break;
}
case "sendCRCCompute": {
if (gameConnection) {
relayLog.info(
{ datablocks: message.datablocks.length, includeTextures: message.includeTextures },
"Computing CRC from game files",
);
gameConnection.computeAndSendCRC(
message.seed,
message.field2,
message.datablocks,
message.includeTextures,
GAME_BASE_PATH,
);
}
break;
}
case "sendGhostAck": {
if (gameConnection) {
relayLog.debug("Forwarding ghost ack from browser");
gameConnection.handleGhostAlwaysDone(
message.sequence,
message.ghostCount,
);
}
break;
}
case "wsPing": {
sendToClient(ws, { type: "wsPong", ts: message.ts });
break;
}
case "sendMove": {
if (gameConnection) {
gameConnection.sendMove({
x: message.move.x,
y: message.move.y,
z: message.move.z,
yaw: message.move.yaw,
pitch: message.move.pitch,
roll: message.move.roll,
freeLook: message.move.freeLook,
trigger: message.move.trigger,
});
}
break;
}
}
}
});
function sendToClient(ws: WebSocket, message: ServerMessage): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}

64
relay/types.ts Normal file
View file

@ -0,0 +1,64 @@
/** Messages from browser client to relay server. */
export type ClientMessage =
| { type: "listServers" }
| { type: "joinServer"; address: string }
| { type: "disconnect" }
| { type: "sendMove"; move: ClientMove }
| { type: "sendCommand"; command: string; args: string[] }
| { type: "sendCRCResponse"; crcValue: number; field1: number; field2: number }
| { type: "sendCRCCompute"; seed: number; field2: number; includeTextures: boolean; datablocks: { objectId: number; className: string; shapeName: string }[] }
| { type: "sendGhostAck"; sequence: number; ghostCount: number }
| { type: "wsPing"; ts: number };
/** Messages from relay server to browser client. */
export type ServerMessage =
| { type: "serverList"; servers: ServerInfo[] }
| { type: "status"; status: ConnectionStatus; message?: string; connectSequence?: number; mapName?: string }
| { type: "gamePacket"; data: Uint8Array }
| { type: "ping"; ms: number }
| { type: "wsPong"; ts: number }
| { type: "error"; message: string };
export interface ServerInfo {
address: string;
name: string;
mod: string;
gameType: string;
mapName: string;
playerCount: number;
maxPlayers: number;
botCount: number;
ping: number;
buildVersion: number;
passwordRequired: boolean;
}
export type ConnectionStatus =
| "connecting"
| "challenging"
| "authenticating"
| "connected"
| "disconnected";
export interface ClientMove {
/** Movement axes: float [-1, 1]. 0 = no movement. */
x: number;
y: number;
z: number;
/** Rotation deltas: float (radians per tick). 0 = no rotation change. */
yaw: number;
pitch: number;
roll: number;
trigger: boolean[];
freeLook: boolean;
}
export interface RelayConfig {
port: number;
accountName: string;
accountPassword: string;
accountCertificate: string;
accountEncryptedKey: string;
authServerAddress: string;
masterServerAddress: string;
}