Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 170
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 2
Cube
0.00% covered (danger)
0.00%
0 / 142
0.00% covered (danger)
0.00%
0 / 4
1056
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
12
 query_server
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
182
 parsePlayerStats
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 1
240
 filterText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
CubeReadBuffer
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 6
210
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
 isEmpty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasMore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUChar
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getInt
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getString
0.00% covered (danger)
0.00%
0 / 5
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 array_key_exists;
16use function count;
17use function is_int;
18use function is_string;
19use function ord;
20use function preg_replace;
21use function strlen;
22use function substr;
23use Clansuite\ServerQuery\CSQuery;
24use Clansuite\ServerQuery\Util\UdpClient;
25use Exception;
26use Override;
27
28/**
29 * Cube Engine protocol implementation.
30 *
31 * Used for Cube 1, Assault Cube, Cube 2: Sauerbraten, Blood Frontier.
32 */
33class Cube extends CSQuery
34{
35    /**
36     * Extended ping commands.
37     */
38    private const EXTPING_NAMELIST = "\x01\x01";
39
40    private const EXT_PLAYERSTATS = "\x00\x01";
41
42    /**
43     * Protocol name.
44     */
45    public string $name = 'Cube Engine';
46
47    /**
48     * Protocol identifier.
49     */
50    public string $protocol = 'Cube';
51
52    /**
53     * List of supported games.
54     *
55     * @var array<string>
56     */
57    public array $supportedGames = [
58        'Cube 1',
59        'Assault Cube',
60        'Cube 2: Sauerbraten',
61        'Blood Frontier',
62    ];
63
64    /**
65     * Game mode names.
66     *
67     * @var string[]
68     */
69    private array $modeNames = [
70        'DEMO', 'TDM', 'coop', 'DM', 'SURV', 'TSURV', 'CTF', 'PF', 'BTDM', 'BDM', 'LSS',
71        'OSOK', 'TOSOK', 'BOSOK', 'HTF', 'TKTF', 'KTF', 'TPF', 'TLSS', 'BPF', 'BLSS', 'BTSURV', 'BTOSOK',
72    ];
73
74    /**
75     * State names.
76     *
77     * @var string[]
78     */
79    private array $stateNames = [
80        'alive', 'dead', 'spawning', 'lagged', 'editing', 'spectating',
81    ];
82
83    /**
84     * Constructor.
85     */
86    public function __construct(mixed $address, mixed $queryport)
87    {
88        parent::__construct((is_string($address) ? $address : null), (is_int($queryport) ? $queryport : null));
89    }
90
91    /**
92     * query_server method.
93     */
94    #[Override]
95    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
96    {
97        if ($this->online) {
98            $this->reset();
99        }
100
101        // Cube Engine queries use port + 1
102        $queryPort = ($this->queryport ?? 0) + 1;
103
104        // Send EXTPING_NAMELIST to get basic server info and player names
105        $result1 = $this->sendCommand($this->address ?? '', $queryPort, self::EXTPING_NAMELIST);
106
107        if ($result1 === '' || $result1 === '0' || $result1 === false) {
108            $this->errstr = 'No reply received for server info';
109
110            return false;
111        }
112
113        // Parse server info response
114        $buffer = new CubeReadBuffer($result1);
115
116        // Skip extping_code (2 bytes)
117        $buffer->getUChar();
118        $buffer->getUChar();
119
120        // Skip proto_version (3 bytes)
121        $buffer->getUChar();
122        $buffer->getUChar();
123        $buffer->getUChar();
124
125        $gamemode           = $buffer->getInt();
126        $nbConnectedClients = $buffer->getInt();
127        $buffer->getInt();
128        $serverMap         = $buffer->getString();
129        $serverDescription = $this->filterText($buffer->getString());
130        $maxClients        = $buffer->getInt();
131
132        // Mastermode (2 bytes)
133        $mastermode1 = $buffer->getUChar();
134        $buffer->getUChar();
135
136        // Convert mastermode
137        $mastermode = 'open'; // default
138
139        if ($mastermode1 === 64 || $mastermode1 === 65) {
140            $mastermode = 'private';
141        } elseif ($mastermode1 === -128) {
142            $mastermode = 'match';
143        }
144
145        // Read player names
146        $playerNames = [];
147
148        while (!$buffer->isEmpty()) {
149            $playerName = $buffer->getString();
150
151            if ($playerName === '') {
152                break;
153            }
154            $playerNames[] = $playerName;
155        }
156
157        // Set basic server info
158        $this->servertitle = $serverDescription;
159        $this->mapname     = $serverMap;
160        $this->maxplayers  = $maxClients;
161        $this->numplayers  = $nbConnectedClients;
162        $this->password    = 0; // Not available in this response
163
164        // Set game type
165        if (isset($this->modeNames[$gamemode])) {
166            $this->gametype = $this->modeNames[$gamemode];
167        }
168
169        // Get detailed player stats if requested
170        if ($getPlayers && $nbConnectedClients > 0) {
171            $this->parsePlayerStats($queryPort);
172        }
173
174        $this->online = true;
175
176        return true;
177    }
178
179    private function parsePlayerStats(int $queryPort): void
180    {
181        // Create UDP client for multi-packet queries
182        /** @var UdpClient $udpClient */
183        $udpClient = new UdpClient;
184        $udpClient->setTimeout(3); // 3 second timeout
185
186        // Send EXT_PLAYERSTATS + \xff to get all player stats
187        $command = self::EXT_PLAYERSTATS . "\xff";
188        $packets = $udpClient->queryMultiPacket($this->address ?? '', $queryPort, $command, 0, 0.5); // 0.5s between packets
189
190        if ($packets === []) {
191            return;
192        }
193
194        if (!isset($packets[0])) {
195            return;
196        }
197
198        // First packet should contain client numbers
199        $buffer = new CubeReadBuffer($packets[0]);
200
201        // Skip extping_code (2 bytes)
202        $buffer->getUChar();
203        $buffer->getUChar();
204
205        // Skip proto_version (3 bytes)
206        $buffer->getUChar();
207        $buffer->getUChar();
208        $buffer->getUChar();
209
210        // EXT_PLAYERSTATS_RESP_IDS should be -10
211        $buffer->getUChar();
212        $respType2 = $buffer->getUChar();
213
214        if ($respType2 !== 246) { // -10 as signed char
215            return; // Invalid response
216        }
217
218        // Read client numbers
219        $clientNumbers = [];
220
221        while (!$buffer->isEmpty()) {
222            try {
223                $clientNum       = $buffer->getInt();
224                $clientNumbers[] = $clientNum;
225            } catch (Exception) {
226                break;
227            }
228        }
229
230        // Remaining packets should be player data (skip first packet)
231        $players     = [];
232        $packetIndex = 1;
233
234        foreach ($clientNumbers as $clientNum) {
235            if ($packetIndex >= count($packets) || !array_key_exists($packetIndex, $packets)) {
236                break; // No more packets
237            }
238
239            $playerResult = $packets[$packetIndex];
240            $packetIndex++;
241            $playerBuffer = new CubeReadBuffer($playerResult);
242
243            try {
244                // Skip headers
245                $playerBuffer->getUChar(); // extping_code
246                $playerBuffer->getUChar();
247                $playerBuffer->getUChar(); // proto_version
248                $playerBuffer->getUChar();
249                $playerBuffer->getUChar();
250                $playerBuffer->getUChar(); // EXT_PLAYERSTATS_RESP_STATS
251                $playerBuffer->getUChar();
252
253                $pClientNum = $playerBuffer->getInt();
254                $ping       = $playerBuffer->getInt();
255                $name       = $playerBuffer->getString();
256                $team       = $playerBuffer->getString();
257                $frags      = $playerBuffer->getInt();
258                $flags      = $playerBuffer->getInt();
259                $deaths     = $playerBuffer->getInt();
260                $teamkills  = $playerBuffer->getInt();
261                $accuracy   = $playerBuffer->getInt();
262                $health     = $playerBuffer->getInt();
263                $armour     = $playerBuffer->getInt();
264                $gun        = $playerBuffer->getInt();
265                $role       = $playerBuffer->getInt();
266                $state      = $playerBuffer->getInt();
267
268                // IP (3 bytes)
269                $ip1 = $playerBuffer->getUChar();
270                $ip2 = $playerBuffer->getUChar();
271                $ip3 = $playerBuffer->getUChar();
272                $ip  = "{$ip1}.{$ip2}.{$ip3}.0";
273
274                // Additional stats if available
275                $damage     = -1;
276                $shotdamage = -1;
277
278                if (!$playerBuffer->isEmpty()) {
279                    $damage = $playerBuffer->getInt();
280                }
281
282                if (!$playerBuffer->isEmpty()) {
283                    $shotdamage = $playerBuffer->getInt();
284                }
285
286                if ($name !== '' && $name !== '0') {
287                    $players[] = [
288                        'name'       => $name,
289                        'score'      => $frags,
290                        'ping'       => $ping,
291                        'team'       => $team,
292                        'deaths'     => $deaths,
293                        'health'     => $health,
294                        'armour'     => $armour,
295                        'accuracy'   => $accuracy,
296                        'ip'         => $ip,
297                        'state'      => $this->stateNames[$state] ?? 'unknown',
298                        'role'       => $role === 1 ? 'admin' : 'player',
299                        'damage'     => $damage,
300                        'shotdamage' => $shotdamage,
301                    ];
302                }
303            } catch (Exception) {
304                // Skip malformed packets
305                continue;
306            }
307        }
308
309        $this->players    = $players;
310        $this->playerkeys = [
311            'name'       => true,
312            'score'      => true,
313            'ping'       => true,
314            'team'       => true,
315            'deaths'     => true,
316            'health'     => true,
317            'armour'     => true,
318            'accuracy'   => true,
319            'ip'         => true,
320            'state'      => true,
321            'role'       => true,
322            'damage'     => true,
323            'shotdamage' => true,
324        ];
325    }
326
327    private function filterText(string $s): string
328    {
329        return preg_replace("/\f./", '', $s) ?? '';
330    }
331}
332
333/**
334 * Cube Engine Read Buffer for parsing variable-length encoded data.
335 */
336class CubeReadBuffer
337{
338    private int $position;
339
340    /**
341     * Constructor.
342     */
343    public function __construct(private string $data)
344    {
345        $this->position = 0;
346    }
347
348    /**
349     * isEmpty method.
350     */
351    public function isEmpty(): bool
352    {
353        return $this->position >= strlen($this->data);
354    }
355
356    /**
357     * hasMore method.
358     */
359    public function hasMore(): bool
360    {
361        return !$this->isEmpty();
362    }
363
364    /**
365     * getUChar method.
366     */
367    public function getUChar(): int
368    {
369        if (!$this->hasMore()) {
370            throw new Exception('Message is too short');
371        }
372
373        $char  = $this->data[$this->position];
374        $uchar = ord($char);
375        $this->position++;
376
377        return $uchar;
378    }
379
380    /**
381     * getInt method.
382     */
383    public function getInt(): int
384    {
385        $b = $this->getUChar();
386
387        if ($b === 0x80) {
388            // 16-bit value
389            $low   = $this->getUChar();
390            $high  = $this->getUChar();
391            $value = $low | ($high << 8);
392
393            return $value < 0x8000 ? $value : $value - 0x10000;
394        }
395
396        if ($b === 0x81) {
397            // 32-bit value
398            $b1    = $this->getUChar();
399            $b2    = $this->getUChar();
400            $b3    = $this->getUChar();
401            $b4    = $this->getUChar();
402            $value = $b1 | ($b2 << 8) | ($b3 << 16) | ($b4 << 24);
403
404            return $value < 0x80000000 ? $value : $value - 0x100000000;
405        }
406
407        // 8-bit value
408        return $b < 0x80 ? $b : $b - 0x100;
409    }
410
411    /**
412     * getString method.
413     */
414    public function getString(): string
415    {
416        $startPosition = $this->position;
417
418        while ($this->hasMore()) {
419            if ($this->getUChar() === 0) {
420                break;
421            }
422        }
423
424        return substr($this->data, $startPosition, $this->position - $startPosition - 1);
425    }
426}