mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 22:44:47 +00:00
begin live server support
This commit is contained in:
parent
0c9ddb476a
commit
e4ae265184
368 changed files with 17756 additions and 7738 deletions
136
relay/BitStreamWriter.ts
Normal file
136
relay/BitStreamWriter.ts
Normal 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
17
relay/Dockerfile
Normal 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
223
relay/HuffmanWriter.ts
Normal 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
385
relay/auth.ts
Normal 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 BigInt→hex 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
175
relay/crc.ts
Normal 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
850
relay/gameConnection.ts
Normal 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 (0–511) 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
25
relay/logger.ts
Normal 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
308
relay/masterQuery.ts
Normal 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
546
relay/protocol.ts
Normal 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.
|
||||
* Client→Server 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
369
relay/server.ts
Normal 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
64
relay/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue