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(); /** 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(); }