From bbe347823a44e484445415d666c149bf62f8e980 Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:18:55 +0200 Subject: [PATCH] Support Teeworlds 0.7 game and master servers 0.7 uses a token handshake before answering connless requests, varint packed ints in server info, and masters on port 8283. Every server and master is polled with both protocols and responses merge into the unified game server info, version staying a plain attribute. Co-Authored-By: Claude Fable 5 --- apps/scheduler/src/addDefaultMasterServers.ts | 19 +-- apps/worker/src/packet.ts | 104 ++++++++++++-- apps/worker/src/packets/gameServerInfo.ts | 71 ++++++++-- apps/worker/src/packets/masterServerInfo.ts | 2 + apps/worker/src/packets/packets.test.ts | 132 ++++++++++++++++++ apps/worker/src/socket.ts | 29 +++- apps/worker/src/workers/pollGameServer.ts | 53 +++---- apps/worker/src/workers/pollMasterServer.ts | 9 +- 8 files changed, 351 insertions(+), 68 deletions(-) create mode 100644 apps/worker/src/packets/packets.test.ts diff --git a/apps/scheduler/src/addDefaultMasterServers.ts b/apps/scheduler/src/addDefaultMasterServers.ts index 4605e1cc..40ad5062 100644 --- a/apps/scheduler/src/addDefaultMasterServers.ts +++ b/apps/scheduler/src/addDefaultMasterServers.ts @@ -1,25 +1,18 @@ import { prisma } from "./prisma"; +// 0.6 masters listen on port 8300, 0.7 masters on port 8283 export async function addDefaultMasterServers() { await prisma.masterServer.createMany({ - data: [ + data: [1, 2, 3, 4].flatMap((index) => [ { - address: 'master1.teeworlds.com', + address: `master${index}.teeworlds.com`, port: 8300, }, { - address: 'master2.teeworlds.com', - port: 8300, - }, - { - address: 'master3.teeworlds.com', - port: 8300, - }, - { - address: 'master4.teeworlds.com', - port: 8300, + address: `master${index}.teeworlds.com`, + port: 8283, }, - ], + ]), skipDuplicates: true, }) } diff --git a/apps/worker/src/packet.ts b/apps/worker/src/packet.ts index ddb29b0a..111395eb 100644 --- a/apps/worker/src/packet.ts +++ b/apps/worker/src/packet.ts @@ -42,11 +42,30 @@ export function unpackBool(packet: Packet): boolean { return parseInt(unpackString(packet), 10) !== 0; } +// 0.7 packed int format: ESDDDDDD EDDDDDDD EDD... (E: extend, S: sign, D: data) +export function unpackInt7(packet: Packet): number { + const masks = [0x7f, 0x7f, 0x7f, 0x0f]; + const shifts = [6, 13, 20, 27]; + + const first = packet.data[packet.offset]; + const sign = (first >> 6) & 1; + let value = first & 0x3f; + + for (let i = 0; packet.data[packet.offset] & 0x80 && i < masks.length; i += 1) { + packet.offset += 1; + value |= (packet.data[packet.offset] & masks[i]) << shifts[i]; + } + + packet.offset += 1; + return sign ? ~value : value; +} + export enum ServerHeader { Vanilla, Legacy64, Extended, ExtendedMore, + Vanilla7, } export function headerBuffer(header: string): Buffer { @@ -59,17 +78,39 @@ const SERVER_HEADER_LEGACY64 = headerBuffer('dtsf'); const SERVER_HEADER_EXTENDED = headerBuffer('iext'); const SERVER_HEADER_EXTENDED_MORE = headerBuffer('iex+'); -export function unpackServerHeader(packet: Packet): ServerHeader { - unpackBytes(packet, 6); - const header = unpackBytes(packet, 8); +// 0.6 connless packets start with 6 padding bytes, 0.7 ones with a 9 bytes +// header: flags/version byte then two 4 bytes tokens. Returns undefined for +// anything else, like 0.7 control packets. +function unpackConnlessHeader(packet: Packet): { magic: Buffer, version7: boolean } | undefined { + if (packet.data[0] === 0xff) { + unpackBytes(packet, 6); + return { magic: unpackBytes(packet, 8), version7: false }; + } + + if ((packet.data[0] & 0xfc) >> 2 === 0x08) { + unpackBytes(packet, 9); + return { magic: unpackBytes(packet, 8), version7: true }; + } + + return undefined; +} + +export function unpackServerHeader(packet: Packet): ServerHeader | undefined { + const connlessHeader = unpackConnlessHeader(packet); - if (header.equals(SERVER_HEADER_VANILLA)) { - return ServerHeader.Vanilla; - } else if (header.equals(SERVER_HEADER_LEGACY64)) { + if (connlessHeader === undefined) { + return undefined; + } + + const { magic, version7 } = connlessHeader; + + if (magic.equals(SERVER_HEADER_VANILLA)) { + return version7 ? ServerHeader.Vanilla7 : ServerHeader.Vanilla; + } else if (magic.equals(SERVER_HEADER_LEGACY64)) { return ServerHeader.Legacy64; - } else if (header.equals(SERVER_HEADER_EXTENDED)) { + } else if (magic.equals(SERVER_HEADER_EXTENDED)) { return ServerHeader.Extended; - } else if (header.equals(SERVER_HEADER_EXTENDED_MORE)) { + } else if (magic.equals(SERVER_HEADER_EXTENDED_MORE)) { return ServerHeader.ExtendedMore; } @@ -82,13 +123,52 @@ export enum MasterHeader { const MASTER_HEADER_VANILLA = headerBuffer('lis2'); -export function unpackMasterHeader(packet: Packet): MasterHeader { - unpackBytes(packet, 6); - const header = unpackBytes(packet, 8); +export function unpackMasterHeader(packet: Packet): MasterHeader | undefined { + const connlessHeader = unpackConnlessHeader(packet); - if (header.equals(MASTER_HEADER_VANILLA)) { + if (connlessHeader === undefined) { + return undefined; + } + + if (connlessHeader.magic.equals(MASTER_HEADER_VANILLA)) { return MasterHeader.Vanilla; } throw new Error('Invalid master header'); } + +const CTRLMSG_TOKEN = 0x05; + +export function randomToken(): number { + return Math.floor(Math.random() * 0xfffffffe); +} + +// 0.7 requires a token handshake before answering connless requests: send a +// control message with our token, padded to 512 bytes, and the server replies +// with the token to use in packConnless7(). +export function packCtrlTokenRequest(myToken: number): Buffer { + const buffer = Buffer.alloc(7 + 1 + 512); + buffer[0] = 0x04; + buffer.writeUInt32BE(0xffffffff, 3); + buffer[7] = CTRLMSG_TOKEN; + buffer.writeUInt32BE(myToken, 8); + return buffer; +} + +export function peekCtrlToken(packet: Packet): number | undefined { + const { data } = packet; + + if ((data[0] & 0xfc) >> 2 !== 0x01 || data.length < 12 || data[7] !== CTRLMSG_TOKEN) { + return undefined; + } + + return data.readUInt32BE(8); +} + +export function packConnless7(serverToken: number, myToken: number, payload: Buffer): Buffer { + const header = Buffer.alloc(9); + header[0] = 0x21; + header.writeUInt32BE(serverToken, 1); + header.writeUInt32BE(myToken, 5); + return Buffer.concat([header, payload]); +} diff --git a/apps/worker/src/packets/gameServerInfo.ts b/apps/worker/src/packets/gameServerInfo.ts index d2c98588..d2ae4c77 100644 --- a/apps/worker/src/packets/gameServerInfo.ts +++ b/apps/worker/src/packets/gameServerInfo.ts @@ -1,4 +1,4 @@ -import { Packet, ServerHeader, packetIsConsumed, unpackBool, unpackInt, unpackServerHeader, unpackString } from "../packet"; +import { Packet, ServerHeader, packetIsConsumed, unpackBool, unpackInt, unpackInt7, unpackServerHeader, unpackString } from "../packet"; type Client = { name: string; @@ -58,19 +58,30 @@ function initGameServerInfoPacket(): GameServerInfoPacket { }; } -function unpackGameServerInfoPacket(packet: Packet, gameServerInfoPacket: GameServerInfoPacket) { +function unpackGameServerInfoPacket(packet: Packet, gameServerInfoPacket: GameServerInfoPacket): boolean { const header = unpackServerHeader(packet); switch (header) { + case undefined: + return false; case ServerHeader.Vanilla: - return unpackGameServerInfoVanilla(packet, gameServerInfoPacket); + unpackGameServerInfoVanilla(packet, gameServerInfoPacket); + break; case ServerHeader.Legacy64: - return unpackGameServerInfoLegacy64(packet, gameServerInfoPacket); + unpackGameServerInfoLegacy64(packet, gameServerInfoPacket); + break; case ServerHeader.Extended: - return unpackGameServerInfoExtended(packet, gameServerInfoPacket); + unpackGameServerInfoExtended(packet, gameServerInfoPacket); + break; case ServerHeader.ExtendedMore: - return unpackGameServerInfoExtendedMore(packet, gameServerInfoPacket); + unpackGameServerInfoExtendedMore(packet, gameServerInfoPacket); + break; + case ServerHeader.Vanilla7: + unpackGameServerInfoVanilla7(packet, gameServerInfoPacket); + break; } + + return true; } function unpackGameServerInfoVanilla(packet: Packet, gameServerInfoPacket: GameServerInfoPacket) { @@ -211,12 +222,54 @@ function unpackGameServerInfoExtendedMore(packet: Packet, gameServerInfoPacket: } } -export function unpackGameServerInfoPackets(packets: Packet[]): GameServerInfoPacket { +function unpackGameServerInfoVanilla7(packet: Packet, gameServerInfoPacket: GameServerInfoPacket) { + unpackInt7(packet); // token + + gameServerInfoPacket.version = unpackString(packet); + gameServerInfoPacket.name = unpackString(packet); + + unpackString(packet); // hostname + + gameServerInfoPacket.map = unpackString(packet); + gameServerInfoPacket.gameType = unpackString(packet); + + unpackInt7(packet); // flags + unpackInt7(packet); // skill level + + gameServerInfoPacket.numPlayers = unpackInt7(packet); + gameServerInfoPacket.maxPlayers = unpackInt7(packet); + + gameServerInfoPacket.numClients = unpackInt7(packet); + gameServerInfoPacket.maxClients = unpackInt7(packet); + + while (!packetIsConsumed(packet)) { + const name = unpackString(packet); + const clan = unpackString(packet); + const country = unpackInt7(packet); + const score = unpackInt7(packet); + const playerType = unpackInt7(packet); // 1: spectator, 2: bot + + addClient(gameServerInfoPacket, { + name, + clan, + country, + score, + inGame: (playerType & 1) === 0, + + _origin: ServerHeader.Vanilla7, + }); + } +} + +export function unpackGameServerInfoPackets(packets: Packet[]): GameServerInfoPacket | undefined { const gameServerInfoPacket = initGameServerInfoPacket(); + let unpackedCount = 0; for (const packet of packets) { - unpackGameServerInfoPacket(packet, gameServerInfoPacket); + if (unpackGameServerInfoPacket(packet, gameServerInfoPacket)) { + unpackedCount += 1; + } } - return gameServerInfoPacket; + return unpackedCount > 0 ? gameServerInfoPacket : undefined; } diff --git a/apps/worker/src/packets/masterServerInfo.ts b/apps/worker/src/packets/masterServerInfo.ts index 5e56a7ab..250b7840 100644 --- a/apps/worker/src/packets/masterServerInfo.ts +++ b/apps/worker/src/packets/masterServerInfo.ts @@ -17,6 +17,8 @@ function unpackMasterPacket(packet: Packet): MasterServerPacketInfo { const header = unpackMasterHeader(packet); switch (header) { + case undefined: + return { gameServers: [] }; case MasterHeader.Vanilla: return unpackMasterVanillaContent(packet); } diff --git a/apps/worker/src/packets/packets.test.ts b/apps/worker/src/packets/packets.test.ts new file mode 100644 index 00000000..6e2ec975 --- /dev/null +++ b/apps/worker/src/packets/packets.test.ts @@ -0,0 +1,132 @@ +import { headerBuffer, packetFromBuffer, peekCtrlToken, unpackInt7 } from "../packet"; +import { unpackGameServerInfoPackets } from "./gameServerInfo"; +import { unpackMasterPackets } from "./masterServerInfo"; + +function packInt7(value: number): number[] { + let sign = 0; + if (value < 0) { + sign = 0x40; + value = ~value; + } + + const bytes = [sign | (value & 0x3f)]; + value >>>= 6; + + while (value) { + bytes[bytes.length - 1] |= 0x80; + bytes.push(value & 0x7f); + value >>>= 7; + } + + return bytes; +} + +function packString(value: string): number[] { + return [...Buffer.from(value), 0]; +} + +function connless7Packet(magic: string, payload: number[]) { + return packetFromBuffer(Buffer.from([ + 0x21, + 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, + ...headerBuffer(magic), + ...payload, + ])); +} + +function ctrlTokenPacket(serverToken: number) { + const buffer = Buffer.alloc(12); + buffer[0] = 0x04; + buffer[7] = 0x05; + buffer.writeUInt32BE(serverToken, 8); + return packetFromBuffer(buffer); +} + +test('unpackInt7', () => { + for (const value of [0, 1, 63, 64, -1, -64, 1234567, -1234567]) { + const packet = packetFromBuffer(Buffer.from(packInt7(value))); + expect(unpackInt7(packet)).toBe(value); + expect(packet.offset).toBe(packet.data.length); + } +}); + +test('peekCtrlToken', () => { + expect(peekCtrlToken(ctrlTokenPacket(0xaabbccdd))).toBe(0xaabbccdd); + expect(peekCtrlToken(connless7Packet('inf3', []))).toBeUndefined(); +}); + +test('unpackGameServerInfoPackets version 0.7', () => { + const packet = connless7Packet('inf3', [ + ...packInt7(0), // token + ...packString('0.7.5'), + ...packString('server name'), + ...packString('hostname'), + ...packString('ctf5'), + ...packString('CTF'), + ...packInt7(1), // flags + ...packInt7(1), // skill level + ...packInt7(1), + ...packInt7(8), + ...packInt7(2), + ...packInt7(16), + + ...packString('player1'), + ...packString('clan1'), + ...packInt7(-1), + ...packInt7(10), + ...packInt7(0), + + ...packString('spectator1'), + ...packString(''), + ...packInt7(64), + ...packInt7(-3), + ...packInt7(1), + ]); + + expect(unpackGameServerInfoPackets([packet])).toEqual({ + version: '0.7.5', + name: 'server name', + map: 'ctf5', + gameType: 'CTF', + numPlayers: 1, + maxPlayers: 8, + numClients: 2, + maxClients: 16, + clients: [ + expect.objectContaining({ + name: 'player1', + clan: 'clan1', + country: -1, + score: 10, + inGame: true, + }), + expect.objectContaining({ + name: 'spectator1', + clan: '', + country: 64, + score: -3, + inGame: false, + }), + ], + }); +}); + +test('unpackGameServerInfoPackets skips control packets', () => { + expect(unpackGameServerInfoPackets([ctrlTokenPacket(0x12345678)])).toBeUndefined(); +}); + +test('unpackMasterPackets version 0.7', () => { + const listPacket = connless7Packet('lis2', [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 1, 2, 3, 4, 0x20, 0x6f, + ]); + + expect(unpackMasterPackets([ctrlTokenPacket(0x12345678), listPacket])).toEqual({ + gameServers: [ + { + ip: '1.2.3.4', + port: 8303, + }, + ], + }); +}); diff --git a/apps/worker/src/socket.ts b/apps/worker/src/socket.ts index 0cf14011..042817d1 100644 --- a/apps/worker/src/socket.ts +++ b/apps/worker/src/socket.ts @@ -1,5 +1,5 @@ import { RemoteInfo, Socket, createSocket } from "dgram"; -import { Packet, packetFromBuffer } from "./packet"; +import { Packet, packCtrlTokenRequest, packConnless7, packetFromBuffer, peekCtrlToken, randomToken } from "./packet"; import { isIP } from "net"; type Sockets = { @@ -7,7 +7,7 @@ type Sockets = { socket6: Socket; packetsByIpAndPort: Record< string, - { packets: Packet[]; } + { packets: Packet[]; onPacket?: (packet: Packet) => void; } >; }; @@ -32,6 +32,7 @@ async function createSockets() { if (receivedPacket !== undefined) { receivedPacket.packets.push(packet); + receivedPacket.onPacket?.(packet); } }; @@ -70,9 +71,29 @@ export function sendData(sockets: Sockets, data: Buffer, ip: string, port: numbe }); } -export function listenForPackets(sockets: Sockets, ip: string, port: number) { +export function listenForPackets(sockets: Sockets, ip: string, port: number, onPacket?: (packet: Packet) => void) { const ipAndPort = ipAndPortToString(ip, port); - sockets.packetsByIpAndPort[ipAndPort] = { packets: [] }; + sockets.packetsByIpAndPort[ipAndPort] = { packets: [], onPacket }; +} + +// Sends 0.6 requests right away. 0.7 requires a token handshake first, so the +// 0.7 request is sent when the server answers the token request. +export function sendRequests(sockets: Sockets, ip: string, port: number, requests06: Buffer[], request7: Buffer) { + const myToken = randomToken(); + + listenForPackets(sockets, ip, port, (packet) => { + const serverToken = peekCtrlToken(packet); + + if (serverToken !== undefined) { + sendData(sockets, packConnless7(serverToken, myToken, request7), ip, port); + } + }); + + for (const request of requests06) { + sendData(sockets, request, ip, port); + } + + sendData(sockets, packCtrlTokenRequest(myToken), ip, port); } export function getReceivedPackets(sockets: Sockets, ip: string, port: number) { diff --git a/apps/worker/src/workers/pollGameServer.ts b/apps/worker/src/workers/pollGameServer.ts index 3f2be5ad..aa5c610f 100644 --- a/apps/worker/src/workers/pollGameServer.ts +++ b/apps/worker/src/workers/pollGameServer.ts @@ -1,6 +1,7 @@ import { prisma } from "../prisma"; -import { resetPackets, getReceivedPackets, sendData, setupSockets, listenForPackets } from "../socket"; +import { resetPackets, getReceivedPackets, sendRequests, setupSockets } from "../socket"; import { GameServerInfoPacket, unpackGameServerInfoPackets } from "../packets/gameServerInfo"; +import { headerBuffer } from "../packet"; import { scheduleUpdatePlayTime, scheduleRankPlayer, PollGameServerJobData, processPollGameServerJobs, wait } from "@teerank/teerank"; import { GameServer, GameServerState, Prisma } from "@prisma/client"; import { upsertPlayers } from "@prisma/client/sql"; @@ -39,6 +40,8 @@ const PACKET_GETINFO64 = Buffer.from([ 0 ]); +const REQUEST_GETINFO7 = Buffer.from([...headerBuffer('gie3'), 0]); + const MAX_FAILURE_COUNT = getEnvInt('MAX_FAILURE_COUNT', 30); function skipPolling(gameServer: GameServer & { gameServerState: GameServerState | null }) { @@ -241,44 +244,42 @@ async function processor(jobData: PollGameServerJobData) { const sockets = await setupSockets(); - listenForPackets(sockets, gameServer.ip, gameServer.port); - - sendData(sockets, PACKET_GETINFO, gameServer.ip, gameServer.port); - sendData(sockets, PACKET_GETINFO64, gameServer.ip, gameServer.port); + sendRequests(sockets, gameServer.ip, gameServer.port, [PACKET_GETINFO, PACKET_GETINFO64], REQUEST_GETINFO7); await wait(2000); const receivedPackets = getReceivedPackets(sockets, gameServer.ip, gameServer.port); - if (receivedPackets.packets.length > 0) { - try { - const gameServerInfo = unpackGameServerInfoPackets(receivedPackets.packets) + try { + const gameServerInfo = unpackGameServerInfoPackets(receivedPackets.packets); + + if (gameServerInfo !== undefined) { const snapshotId = await processGameServerInfo(gameServer, gameServerInfo); await Promise.all([ scheduleUpdatePlayTime({ snapshotId }), scheduleRankPlayer({ snapshotId }) ]); - } catch (e) { - console.warn(`${gameServer.ip}:${gameServer.port}: ${e}`) - } - } else { - await prisma.gameServerState.deleteMany({ - where: { - gameServerId: gameServer.id, - }, - }); + } else { + await prisma.gameServerState.deleteMany({ + where: { + gameServerId: gameServer.id, + }, + }); - await prisma.gameServer.update({ - where: { - id: gameServer.id, - }, - data: { - failureCount: { - increment: 1, + await prisma.gameServer.update({ + where: { + id: gameServer.id, }, - }, - }); + data: { + failureCount: { + increment: 1, + }, + }, + }); + } + } catch (e) { + console.warn(`${gameServer.ip}:${gameServer.port}: ${e}`) } resetPackets(sockets, gameServer.ip, gameServer.port); diff --git a/apps/worker/src/workers/pollMasterServer.ts b/apps/worker/src/workers/pollMasterServer.ts index ab1e9b82..3e42b2b9 100644 --- a/apps/worker/src/workers/pollMasterServer.ts +++ b/apps/worker/src/workers/pollMasterServer.ts @@ -1,8 +1,9 @@ import { prisma } from "../prisma"; import { lookup } from "dns/promises"; import { unpackMasterPackets } from "../packets/masterServerInfo"; -import { resetPackets, getReceivedPackets, sendData, setupSockets, listenForPackets } from "../socket"; +import { resetPackets, getReceivedPackets, sendRequests, setupSockets } from "../socket"; import { PollMasterServerJobData, processPollMasterServerJobs, wait } from "@teerank/teerank"; +import { headerBuffer } from "../packet"; function stringToCharCode(str: string) { return str.split('').map((char) => char.charCodeAt(0)); @@ -24,6 +25,8 @@ const PACKET_GETLIST = Buffer.from([ ...stringToCharCode('req2'), ]); +const REQUEST_GETLIST7 = headerBuffer('req2'); + async function processor(jobData: PollMasterServerJobData) { const masterServer = await prisma.masterServer.findUniqueOrThrow({ where: { @@ -39,9 +42,7 @@ async function processor(jobData: PollMasterServerJobData) { console.log(`Polling ${masterServer.address}:${masterServer.port}`); const ip = await lookup(masterServer.address); - listenForPackets(sockets, ip.address, masterServer.port); - - sendData(sockets, PACKET_GETLIST, ip.address, masterServer.port); + sendRequests(sockets, ip.address, masterServer.port, [PACKET_GETLIST], REQUEST_GETLIST7); await wait(2000); const receivedPackets = getReceivedPackets(sockets, ip.address, masterServer.port);