t2-mapper/relay/protocol.ts

536 lines
16 KiB
TypeScript
Raw Normal View History

2026-03-09 12:38:40 -07:00
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 {
2026-03-09 12:38:40 -07:00
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;
2026-03-09 12:38:40 -07:00
// 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";
2026-03-09 12:38:40 -07:00
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"})`,
2026-03-09 12:38:40 -07:00
);
}
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;
2026-03-09 12:38:40 -07:00
}
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;
2026-03-09 12:38:40 -07:00
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 {
2026-03-09 12:38:40 -07:00
const bs = this.buildSendPacketHeader(DataPacket);
writePayload(bs);
return bs.getBuffer();
}
}
/**
* Build a GameConnection client data packet.
* ClientServer format (from checkPacketSend + GameConnection::writePacket):
* 1. Rate info (2 flag bits from checkPacketSend, before writePacket)
* 2. GameConnection fields:
* a. Flag (firstPerson: cameraPos == 0)
* b. U32 (controlObjectChecksum)
* c. moveWritePacket (move count + packed moves)
* d. Flag (updateFirstPerson) false for observer
* e. Flag (updateCameraFov) false for observer
* 3. NetConnection::writePacket:
* a. eventWritePacket (events)
* b. ghostWritePacket (ghosts client doesn't write any)
*/
export function buildClientGamePacket(
protocol: ConnectionProtocol,
options: {
moves?: ClientMoveData[];
moveStartIndex?: number;
events?: ClientEvent[];
nextSendEventSeq?: number;
} = {},
): Uint8Array {
return protocol.buildDataPacket((bs) => {
// NetConnection::checkPacketSend writes rate info BEFORE writePacket.
// handlePacket on the server reads these before calling readPacket.
// Both sides send rate flags — we send false (no changes).
bs.writeFlag(false); // mCurRate.changed
bs.writeFlag(false); // mMaxRate.changed
// GameConnection::writePacket (client→server path)
// 1. First person flag (cameraPos == 0 → firstPerson)
bs.writeFlag(false); // not first person
// 2. 32-bit control object value
bs.writeU32(0);
// 3. moveWritePacket: writeInt(start, 32) + writeInt(count, 5) + moves
const moves = options.moves ?? [];
bs.writeU32(options.moveStartIndex ?? 0);
bs.writeInt(moves.length, 5); // MoveCountBits = 5
for (const move of moves) {
writeMove(bs, move);
}
// 4. FOV change flag (Tribes 2 binary reads one flag here, not two
// like TorqueSDK-1.2 — verified against decompiled Tribes2.exe)
bs.writeFlag(false);
// NetConnection::writePacket — events + ghosts
// eventWritePacket:
// Unguaranteed events: none from observer
bs.writeFlag(false); // end unguaranteed
// Guaranteed events
if (options.events && options.events.length > 0) {
let seq = options.nextSendEventSeq ?? 0;
for (const event of options.events) {
bs.writeFlag(true); // more guaranteed events
bs.writeFlag(false); // not sequential shortcut
bs.writeInt(seq & 0x7f, 7);
seq++;
bs.writeInt(event.classId - NetEventClassFirst, 6);
event.write(bs);
}
}
bs.writeFlag(false); // end guaranteed events
// ghostWritePacket: client doesn't ghost, so nothing written
// (doesGhostFrom() returns false for client)
});
}
export interface ClientMoveData {
/** Movement axes: float [-1, 1]. Encoded as 6-bit unsigned (0-32, center=16). */
x: number;
y: number;
z: number;
/** Rotation deltas: float (radians per tick). Encoded as 16-bit signed (×65536).
* Server adds these directly to camera rotation each tick. */
yaw: number;
pitch: number;
roll: number;
freeLook: boolean;
trigger: boolean[];
}
export interface ClientEvent {
classId: number;
write: (bs: BitStreamWriter) => void;
}
/**
* Write a Move struct to the stream.
*
* Wire format (from Tribes2.exe FUN_00601800):
* flag(yaw?) + optional 16-bit yaw (rotation, signed)
* flag(pitch?) + optional 16-bit pitch
* flag(roll?) + optional 16-bit roll
* 6-bit x + 6-bit y + 6-bit z (movement, unsigned 0-32, center=16)
* flag(freeLook) + 6×flag(trigger)
*/
function writeMove(bs: BitStreamWriter, move: ClientMoveData): void {
// Rotation (flag + optional 16-bit signed).
// Pack: int16 = (int)(radians * 65536). Server unpacks: float = (short)int16 / 65536.
const pyaw = Math.round(move.yaw * 65536) | 0;
const ppitch = Math.round(move.pitch * 65536) | 0;
const proll = Math.round(move.roll * 65536) | 0;
if (pyaw !== 0) {
bs.writeFlag(true);
bs.writeInt(pyaw & 0xffff, 16);
} else {
bs.writeFlag(false);
}
if (ppitch !== 0) {
bs.writeFlag(true);
bs.writeInt(ppitch & 0xffff, 16);
} else {
bs.writeFlag(false);
}
if (proll !== 0) {
bs.writeFlag(true);
bs.writeInt(proll & 0xffff, 16);
} else {
bs.writeFlag(false);
}
// Movement (6-bit unsigned, 0-32, center=16).
// Pack: uint6 = clamp(float * 16 + 16, 0, 32). Server unpacks: float = (val - 16) / 16.
const px = Math.max(0, Math.min(32, Math.round(move.x * 16 + 16)));
const py = Math.max(0, Math.min(32, Math.round(move.y * 16 + 16)));
const pz = Math.max(0, Math.min(32, Math.round(move.z * 16 + 16)));
bs.writeInt(px, 6);
bs.writeInt(py, 6);
bs.writeInt(pz, 6);
// FreeLook flag
bs.writeFlag(move.freeLook);
// Trigger keys (6 triggers)
for (let i = 0; i < 6; i++) {
bs.writeFlag(move.trigger[i] ?? false);
}
}
/**
* Client-side net string table for tagged string synchronization.
* Assigns 10-bit IDs to strings and generates NetStringEvents to
* register them with the server before use in RemoteCommandEvents.
*/
export class ClientNetStringTable {
private nextId = 1;
private strings = new Map<string, number>();
/** Get or assign a 10-bit string ID. Returns the ID and whether it's new. */
getOrAdd(str: string): { id: number; isNew: boolean } {
const existing = this.strings.get(str);
if (existing !== undefined) return { id: existing, isNew: false };
const id = this.nextId++;
if (id > 1023) throw new Error("Net string table overflow (10-bit IDs)");
this.strings.set(str, id);
return { id, isNew: true };
}
}
/** Build a NetStringEvent to register a string with the server. */
export function buildNetStringEvent(id: number, value: string): ClientEvent {
2026-03-09 12:38:40 -07:00
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 {
2026-03-09 12:38:40 -07:00
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();
}