1: <?php declare(strict_types=1);
2:
3: /**
4: * Clansuite Server Query
5: *
6: * SPDX-FileCopyrightText: 2003-2025 Jens A. Koch
7: * SPDX-License-Identifier: MIT
8: *
9: * For the full copyright and license information, please view
10: * the LICENSE file that was distributed with this source code.
11: */
12:
13: namespace Clansuite\ServerQuery\ServerProtocols;
14:
15: use function count;
16: use function pack;
17: use function preg_match;
18: use function strlen;
19: use function substr;
20: use Clansuite\Capture\Protocol\ProtocolInterface;
21: use Clansuite\Capture\ServerAddress;
22: use Clansuite\Capture\ServerInfo;
23: use Clansuite\ServerQuery\CSQuery;
24: use Override;
25:
26: /**
27: * DDnet protocol implementation.
28: *
29: * Based on Teeworlds protocol.
30: *
31: * Server: https://ddnet.org/status/#server-0
32: *
33: * Official website: https://ddnet.org
34: * Protocol: https://ddnet.org/docs/libtw2/protocol/
35: * Packet: https://ddnet.org/docs/libtw2/packet/
36: * Connection: https://ddnet.org/docs/libtw2/connection/
37: */
38: class Ddnet extends CSQuery implements ProtocolInterface
39: {
40: /**
41: * Protocol name.
42: */
43: public string $name = 'DDnet';
44:
45: /**
46: * List of supported games.
47: *
48: * @var array<string>
49: */
50: public array $supportedGames = ['DDnet'];
51:
52: /**
53: * Protocol identifier.
54: */
55: public string $protocol = 'ddnet';
56:
57: /**
58: * Game series.
59: *
60: * @var array<string>
61: */
62: public array $game_series_list = ['Teeworlds'];
63: public string $gameport = '8303';
64:
65: /**
66: * Constructor.
67: */
68: public function __construct(?string $address = null, ?int $queryport = null)
69: {
70: parent::__construct($address, $queryport);
71: }
72:
73: /**
74: * getServerLink method.
75: */
76: public function getServerLink(): string
77: {
78: return 'ddnet://' . $this->address . ':' . $this->gameport;
79: }
80:
81: /**
82: * query_server method.
83: */
84: #[Override]
85: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
86: {
87: if ($this->online) {
88: $this->reset();
89: }
90:
91: // Try extended server info first (DDnet specific)
92: $command = $this->buildExtendedQueryPacket();
93:
94: if (($result = $this->sendCommand($this->address ?? '', $this->queryport ?? 0, $command)) === '' || ($result = $this->sendCommand($this->address ?? '', $this->queryport ?? 0, $command)) === '0' || ($result = $this->sendCommand($this->address ?? '', $this->queryport ?? 0, $command)) === false) {
95: // Fall back to vanilla Teeworlds query
96: $command = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x67\x69\x65\x33\x05";
97: $result = $this->sendCommand($this->address ?? '', $this->queryport ?? 0, $command);
98: }
99:
100: if ($result === '' || $result === '0' || $result === false) {
101: $this->errstr = 'No response from server';
102:
103: return false;
104: }
105:
106: $this->hostport = $this->queryport ?? 0;
107:
108: // Parse the response
109: return $this->parseResponse($result);
110: }
111:
112: /**
113: * getProtocolName method.
114: */
115: #[Override]
116: public function getProtocolName(): string
117: {
118: return 'ddnet';
119: }
120:
121: /**
122: * getVersion method.
123: */
124: #[Override]
125: public function getVersion(ServerInfo $info): string
126: {
127: return (string) ($info->rules['version'] ?? 'unknown');
128: }
129:
130: /**
131: * getQueryString method.
132: */
133: public function getQueryString(ServerAddress $address): string
134: {
135: return "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x67\x69\x65\x33\x05";
136: }
137:
138: /**
139: * parseResponseData method.
140: */
141: public function parseResponseData(string $data): ServerInfo
142: {
143: $serverInfo = new ServerInfo;
144:
145: $len = strlen($data);
146:
147: if ($len < 15) {
148: return $serverInfo;
149: }
150:
151: // Check for extended response
152: if (substr($data, 10, 4) === 'iext') {
153: return $this->parseExtendedResponseData($data);
154: }
155:
156: // Check for vanilla response
157: if (substr($data, 10, 5) === 'inf35') {
158: return $this->parseVanillaResponseData($data);
159: }
160:
161: return $serverInfo;
162: }
163:
164: /**
165: * query method.
166: */
167: #[Override]
168: public function query(ServerAddress $addr): ServerInfo
169: {
170: // Try extended query first
171: $queryString = $this->buildExtendedQueryPacket();
172: $response = $this->sendCommand($addr->ip, $addr->port, $queryString);
173:
174: if ($response === '' || $response === '0' || $response === false) {
175: // Fall back to vanilla query
176: $queryString = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x67\x69\x65\x33\x05";
177: $response = $this->sendCommand($addr->ip, $addr->port, $queryString);
178: }
179:
180: if ($response === '' || $response === '0' || $response === false) {
181: return new ServerInfo;
182: }
183:
184: return $this->parseResponseData($response);
185: }
186:
187: private function buildExtendedQueryPacket(): string
188: {
189: // Extended server info request format:
190: // Connectionless header + extended request
191: $packet = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"; // connectionless header
192:
193: $packet .= 'xe'; // magic_bytes
194: $packet .= pack('n', 0); // extra_token (big-endian 16-bit)
195: $packet .= pack('n', 0); // reserved
196: $packet .= "\xff\xff\xff\xff"; // padding
197: $packet .= 'gie3'; // vanilla_request
198: $packet .= "\x00"; // token
199:
200: return $packet;
201: }
202:
203: private function parseResponse(string $result): bool
204: {
205: $len = strlen($result);
206:
207: if ($len < 15) {
208: $this->errstr = 'Invalid response (too short)';
209:
210: return false;
211: }
212:
213: // Check for extended response (starts with "iext")
214: if (substr($result, 10, 4) === 'iext') {
215: return $this->parseExtendedResponse($result);
216: }
217:
218: // Check for vanilla response (starts with "inf35")
219: if (substr($result, 10, 5) === 'inf35') {
220: return $this->parseVanillaResponse($result);
221: }
222:
223: $this->errstr = 'Unknown response format';
224:
225: return false;
226: }
227:
228: private function parseVanillaResponse(string $result): bool
229: {
230: $len = strlen($result);
231:
232: // Check header
233: $header = substr($result, 0, 15);
234:
235: if ($header !== "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xffinf35") {
236: $this->errstr = 'Invalid vanilla response header';
237:
238: return false;
239: }
240:
241: $i = 15; // start after header
242:
243: // Version
244: $this->rules['version'] = $this->readString($result, $i);
245:
246: // Hostname
247: $this->servertitle = $this->readString($result, $i);
248:
249: // Map
250: $this->mapname = $this->readString($result, $i);
251:
252: // Game description (string) and game directory (string)
253: $this->rules['game_descr'] = $this->readString($result, $i);
254: $this->rules['gamedir'] = $this->readString($result, $i);
255:
256: // Flags (string)
257: $this->rules['flags'] = $this->readString($result, $i);
258:
259: // Player count
260: $this->numplayers = (int) $this->readString($result, $i);
261:
262: // Max players
263: $this->maxplayers = (int) $this->readString($result, $i);
264:
265: // Num players total
266: $this->rules['num_players_total'] = (int) $this->readString($result, $i);
267:
268: // Max players total
269: $this->rules['maxplayers_total'] = (int) $this->readString($result, $i);
270:
271: // Players
272: $this->players = [];
273:
274: while ($i < $len) {
275: $player = [];
276:
277: // Vanilla Teeworlds player format (as used by many parsers):
278: // name (str), clan (str), flag/country (str), score (str), team (str)
279: $player['name'] = $this->readString($result, $i);
280: $player['clan'] = $this->readString($result, $i);
281: $player['country'] = $this->readString($result, $i);
282: $player['score'] = $this->readString($result, $i);
283: $player['team'] = $this->readString($result, $i);
284:
285: $this->players[] = $player;
286: }
287: $this->numplayers = count($this->players);
288:
289: // Try to parse maxplayers from map name like [num/max]
290: if (preg_match('/\[(\d+)\/(\d+)\]$/', $this->mapname, $matches) !== false && isset($matches[2])) {
291: $this->maxplayers = (int) $matches[2];
292: }
293:
294: $this->online = true;
295:
296: return true;
297: }
298:
299: private function parseExtendedResponse(string $result): bool
300: {
301: $len = strlen($result);
302:
303: // Check header (10 padding + "iext")
304: $header = substr($result, 0, 14);
305:
306: if ($header !== "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xffiext") {
307: $this->errstr = 'Invalid extended response header';
308:
309: return false;
310: }
311:
312: $i = 14; // start after header
313:
314: // Token (int)
315: $this->readInt($result, $i);
316:
317: // Version
318: $this->rules['version'] = $this->readString($result, $i);
319:
320: // Name
321: $this->servertitle = $this->readString($result, $i);
322:
323: // Map
324: $this->mapname = $this->readString($result, $i);
325:
326: // Map CRC (int)
327: $this->rules['map_crc'] = $this->readInt($result, $i);
328:
329: // Map size (int)
330: $this->rules['map_size'] = $this->readInt($result, $i);
331:
332: // Game type
333: $this->rules['gametype'] = $this->readString($result, $i);
334:
335: // Flags (int)
336: $this->rules['flags'] = $this->readInt($result, $i);
337:
338: // Num players (int)
339: $this->numplayers = $this->readInt($result, $i);
340:
341: // Max players (int)
342: $this->maxplayers = $this->readInt($result, $i);
343:
344: // Num clients (int)
345: $this->rules['num_clients'] = $this->readInt($result, $i);
346:
347: // Max clients (int)
348: $this->rules['max_clients'] = $this->readInt($result, $i);
349:
350: // Reserved (string, should be empty)
351: $this->readString($result, $i);
352:
353: // Players
354: while ($i < $len) {
355: $player = [];
356:
357: $player['name'] = $this->readString($result, $i);
358: $player['clan'] = $this->readString($result, $i);
359: $player['country'] = $this->readInt($result, $i);
360: $player['score'] = $this->readInt($result, $i);
361: $player['is_player'] = $this->readInt($result, $i);
362: // Reserved
363: $this->readString($result, $i);
364:
365: $this->players[] = $player;
366: }
367:
368: $this->online = true;
369:
370: return true;
371: }
372:
373: private function readInt(string $data, int &$i): int
374: {
375: // According to the DDNet / Teeworlds protocol, "int" is encoded as
376: // a decimal ASCII string terminated by a null byte. So read it as a
377: // null-terminated string and cast to int.
378: $str = $this->readString($data, $i);
379:
380: return (int) $str;
381: }
382:
383: private function readString(string $data, int &$i): string
384: {
385: $start = $i;
386:
387: while ($i < strlen($data) && $data[$i] !== "\x00") {
388: $i++;
389: }
390: $string = substr($data, $start, $i - $start);
391: $i++; // skip null terminator
392:
393: return $string;
394: }
395:
396: private function parseVanillaResponseData(string $data): ServerInfo
397: {
398: $serverInfo = new ServerInfo;
399: $len = strlen($data);
400:
401: $header = substr($data, 0, 15);
402:
403: if ($header !== "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xffinf35") {
404: return $serverInfo;
405: }
406:
407: $i = 15;
408:
409: $serverInfo->rules['version'] = $this->readString($data, $i);
410: $serverInfo->servertitle = $this->readString($data, $i);
411: $serverInfo->mapname = $this->readString($data, $i);
412: // Game description and game directory
413: $serverInfo->rules['game_descr'] = $this->readString($data, $i);
414: $serverInfo->rules['gamedir'] = $this->readString($data, $i);
415: $serverInfo->rules['flags'] = $this->readString($data, $i);
416: $serverInfo->numplayers = (int) $this->readString($data, $i);
417: $serverInfo->maxplayers = (int) $this->readString($data, $i);
418: $serverInfo->rules['num_players_total'] = (int) $this->readString($data, $i);
419: $serverInfo->rules['maxplayers_total'] = (int) $this->readString($data, $i);
420:
421: while ($i < $len) {
422: $player = [];
423:
424: // Vanilla Teeworlds player format: name, clan, flag/country, score, team
425: $player['name'] = $this->readString($data, $i);
426: $player['clan'] = $this->readString($data, $i);
427: $player['country'] = $this->readString($data, $i);
428: $player['score'] = $this->readString($data, $i);
429: $player['team'] = $this->readString($data, $i);
430: // Vanilla responses do not include is_player flag; assume true
431: $player['is_player'] = 1;
432:
433: $serverInfo->players[] = $player;
434: }
435:
436: $serverInfo->online = true;
437:
438: return $serverInfo;
439: }
440:
441: private function parseExtendedResponseData(string $data): ServerInfo
442: {
443: $serverInfo = new ServerInfo;
444: $len = strlen($data);
445:
446: $header = substr($data, 0, 14);
447:
448: if ($header !== "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xffiext") {
449: return $serverInfo;
450: }
451:
452: $i = 14;
453:
454: $this->readInt($data, $i);
455: $serverInfo->rules['version'] = $this->readString($data, $i);
456: $serverInfo->servertitle = $this->readString($data, $i);
457: $serverInfo->mapname = $this->readString($data, $i);
458: $serverInfo->rules['map_crc'] = $this->readInt($data, $i);
459: $serverInfo->rules['map_size'] = $this->readInt($data, $i);
460: $serverInfo->rules['gametype'] = $this->readString($data, $i);
461: $serverInfo->rules['flags'] = $this->readInt($data, $i);
462: $serverInfo->numplayers = $this->readInt($data, $i);
463: $serverInfo->maxplayers = $this->readInt($data, $i);
464: $serverInfo->rules['num_clients'] = $this->readInt($data, $i);
465: $serverInfo->rules['max_clients'] = $this->readInt($data, $i);
466: $this->readString($data, $i); // reserved
467:
468: while ($i < $len) {
469: $player = [];
470: $player['name'] = $this->readString($data, $i);
471: $player['clan'] = $this->readString($data, $i);
472: $player['country'] = $this->readInt($data, $i);
473: $player['score'] = $this->readInt($data, $i);
474: $player['is_player'] = $this->readInt($data, $i);
475: $this->readString($data, $i); // reserved
476:
477: $serverInfo->players[] = $player;
478: }
479:
480: $serverInfo->online = true;
481:
482: return $serverInfo;
483: }
484: }
485: