cleanup input handling, fix server move packets

This commit is contained in:
Brian Beck 2026-03-13 11:08:11 -07:00
parent 409df9fcaa
commit e9125951e4
84 changed files with 2085 additions and 1808 deletions

View file

@ -65,17 +65,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** 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). */
@ -631,60 +624,12 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.flushEvents();
}
/** Flush pending events in a data packet. */
/** Flush pending events in a data packet immediately. */
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.pendingEvents.length === 0 && this.eventSendQueue.length === 0) {
return;
}
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);
this.emitDataPacket([], 0);
}
/** Handle packet delivery notification from the protocol layer. */
@ -733,62 +678,23 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
}
/**
* 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).
* Send moves immediately to the game server.
* The browser owns move indices and re-sends all unacked moves each tick,
* just like real Tribes 2's moveWritePacket.
*/
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;
}
sendMoves(moves: ClientMoveData[], moveStartIndex: number): void {
this.emitDataPacket(moves, moveStartIndex);
}
/** Send the current move state as a keepalive packet at the tick rate. */
private sendTickMove(): void {
const move: ClientMoveData = this.bufferedMove ?? {
x: 0,
y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
freeLook: false,
trigger: [false, false, false, false, false, false],
};
// Record send time keyed by the 9-bit sequence number (0511) that the
// server will echo back in highestAck. lastSendSeq is the full counter;
// the wire format uses only the low 9 bits.
/**
* Internal: build and send a data packet with moves and/or pending events.
* Used by both sendMoves (browser-initiated) and keepalive (idle).
*/
private emitDataPacket(
moves: ClientMoveData[],
moveStartIndex: number,
): void {
// Record send time for RTT measurement.
const nextSeq9 = (this.protocol.lastSendSeq + 1) & 0x1ff;
this.sendTimestamps.set(nextSeq9, Date.now());
@ -798,37 +704,37 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
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.
let events: { seq: number; event: ClientEvent }[] | undefined;
if (this.eventSendQueue.length > 0) {
this.sendDataPacketWithEvents(move);
} else {
const packet = buildClientGamePacket(this.protocol, {
moves: [move],
moveStartIndex: this.moveIndex++,
});
this.sendRaw(packet);
events = this.eventSendQueue.splice(0);
const packetSeq = this.protocol.lastSendSeq + 1;
this.sentEventsByPacket.set(packetSeq, events);
}
// 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],
};
}
}
const packet = buildClientGamePacket(this.protocol, {
moves,
moveStartIndex,
...(events
? {
events: events.map((e) => e.event),
nextSendEventSeq: events[0].seq,
}
: {}),
});
this.sendRaw(packet);
}
/** Start keepalive timer. */
/** Send an idle keepalive packet (no moves, but flushes pending events). */
private sendKeepalive(): void {
this.emitDataPacket([], 0);
}
/** Start keepalive timer. Sends idle packets when the browser isn't sending moves. */
private startKeepalive(): void {
let keepaliveCount = 0;
this.keepaliveTimer = setInterval(() => {
keepaliveCount++;
if (keepaliveCount % 300 === 0) {
// ~10s at 32ms tick rate
connLog.info(
{
dataPackets: this.dataPacketCount,
@ -840,7 +746,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
"Connection status",
);
}
this.sendTickMove();
this.sendKeepalive();
}, KEEPALIVE_INTERVAL_MS);
}
@ -863,7 +769,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// Send a Disconnect packet so the server knows we're leaving
if (this.socket && this.serverConnectSequence !== 0) {
try {
const packet = buildDisconnectPacket(this.connectSequence);
const packet = buildDisconnectPacket(
this.serverConnectSequence,
this.clientConnectSequence,
);
this.socket.send(packet, this.port, this.host);
connLog.info("Sent Disconnect packet to server");
} catch {

View file

@ -274,10 +274,13 @@ export interface ClientEvent {
*/
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;
// Matches Torque's Move::clamp(): pyaw = (yaw / M_2PI) * 0x10000.
// Server's Move::unclamp() reverses: yaw = (short)pyaw * M_2PI / 0x10000.
// Input values are in radians; divide by 2π to get fractional turns for packing.
const M_2PI = 2 * Math.PI;
const pyaw = Math.round((move.yaw / M_2PI) * 65536) | 0;
const ppitch = Math.round((move.pitch / M_2PI) * 65536) | 0;
const proll = Math.round((move.roll / M_2PI) * 65536) | 0;
if (pyaw !== 0) {
bs.writeFlag(true);
@ -480,10 +483,14 @@ export function buildConnectRequest(
}
/** Build a Disconnect (type 38) OOB packet. */
export function buildDisconnectPacket(connectSequence: number): Uint8Array {
export function buildDisconnectPacket(
serverConnectSequence: number,
clientConnectSequence: number,
): Uint8Array {
const bs = new BitStreamWriter(64);
bs.writeU8(38); // Disconnect type
bs.writeU32(connectSequence);
bs.writeU32(serverConnectSequence);
bs.writeU32(clientConnectSequence);
writeString(bs, ""); // reason
return bs.getBuffer();
}

View file

@ -355,18 +355,21 @@ wss.on("connection", (ws) => {
break;
}
case "sendMove": {
case "sendMoves": {
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,
});
gameConnection.sendMoves(
message.moves.map((m) => ({
x: m.x,
y: m.y,
z: m.z,
yaw: m.yaw,
pitch: m.pitch,
roll: m.roll,
freeLook: m.freeLook,
trigger: m.trigger,
})),
message.moveStartIndex,
);
}
break;
}

View file

@ -3,7 +3,7 @@ export type ClientMessage =
| { type: "listServers" }
| { type: "joinServer"; address: string; warriorName?: string }
| { type: "disconnect" }
| { type: "sendMove"; move: ClientMove }
| { type: "sendMoves"; moves: ClientMove[]; moveStartIndex: number }
| { type: "sendCommand"; command: string; args: string[] }
| {
type: "sendCRCResponse";