Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ddnet
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 16
2256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getServerLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 query_server
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
72
 getProtocolName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseResponseData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 query
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 buildExtendedQueryPacket
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 parseResponse
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 parseVanillaResponse
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 parseExtendedResponse
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 readInt
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 readString
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 parseVanillaResponseData
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 parseExtendedResponseData
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
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
13namespace Clansuite\ServerQuery\ServerProtocols;
14
15use function count;
16use function pack;
17use function preg_match;
18use function strlen;
19use function substr;
20use Clansuite\Capture\Protocol\ProtocolInterface;
21use Clansuite\Capture\ServerAddress;
22use Clansuite\Capture\ServerInfo;
23use Clansuite\ServerQuery\CSQuery;
24use 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 */
38class 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}