Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
LauncherProtocol
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 11
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 query_server
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 parseResponseData
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 queryLauncherServer
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 parseDecompressedData
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 parseSegmentedResponse
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 packLong
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLong
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getShort
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getByte
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getString
0.00% covered (danger)
0.00%
0 / 6
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 define;
17use function is_array;
18use function ord;
19use function pack;
20use function strlen;
21use function substr;
22use function time;
23use function unpack;
24use Clansuite\ServerQuery\CSQuery;
25use Clansuite\ServerQuery\Util\HuffmanDecoder;
26use Exception;
27use Override;
28use RuntimeException;
29
30/**
31 * Base class for Launcher Protocol.
32 *
33 * Implements the Launcher Protocol used by Skulltag and Zandronum servers.
34 *
35 * Zandronum is backward compatible with Skulltag's query structure.
36 */
37abstract class LauncherProtocol extends CSQuery
38{
39    /**
40     * Game name for this protocol.
41     */
42    protected string $gameName;
43
44    /**
45     * Constructor.
46     */
47    public function __construct(string $address, int $queryport)
48    {
49        parent::__construct();
50        $this->address   = $address;
51        $this->queryport = $queryport;
52    }
53
54    /**
55     * query_server method.
56     */
57    #[Override]
58    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
59    {
60        if ($this->online) {
61            $this->reset();
62        }
63
64        // Query the server using the Launcher Protocol
65        $info = $this->queryLauncherServer((string) $this->address, (int) $this->queryport, $getPlayers);
66
67        if ($info === [] || isset($info['error'])) {
68            $this->errstr = (string) ($info['error'] ?? 'No response received');
69
70            return false;
71        }
72
73        // Parse the response data
74        $this->parseResponseData($info, $getPlayers);
75        $this->online = true;
76
77        return true;
78    }
79
80    /**
81     * Parse response data into CSQuery properties.
82     *
83     * @param array<mixed> $info
84     */
85    protected function parseResponseData(array $info, bool $getPlayers): void
86    {
87        $this->gamename    = $this->gameName;
88        $this->gameversion = (string) ($info['version'] ?? '');
89
90        if (isset($info['name'])) {
91            $this->servertitle = (string) $info['name'];
92        }
93
94        if (isset($info['numPlayers'])) {
95            $this->numplayers = (int) $info['numPlayers'];
96            $this->maxplayers = 64; // Default max, could be parsed from server info if available
97        }
98
99        if ($getPlayers && isset($info['players'])) {
100            $this->players = [];
101
102            foreach ($info['players'] as $player) {
103                if (is_array($player)) {
104                    $this->players[] = [
105                        'name'           => (string) ($player['name'] ?? ''),
106                        'score'          => (int) ($player['frags'] ?? 0),
107                        'ping'           => (int) ($player['ping'] ?? 0),
108                        'team'           => (string) ($player['team'] ?? ''),
109                        'time_in_server' => (int) ($player['time_in_server'] ?? 0),
110                        'spectator'      => (bool) ($player['spectator'] ?? false),
111                        'bot'            => (bool) ($player['bot'] ?? false),
112                    ];
113                }
114            }
115        }
116    }
117
118    /**
119     * Query server using Launcher Protocol.
120     *
121     * @return array<mixed>
122     */
123    private function queryLauncherServer(string $ip, int $port, bool $getPlayers = true): array
124    {
125        // Choose flags: server name + player count + player data
126        $flags = SQF_NAME | SQF_NUMPLAYERS;
127
128        if ($getPlayers) {
129            $flags |= SQF_PLAYERDATA;
130        }
131
132        $time = time();
133
134        // Build uncompressed query packet
135        $unencodedPacket = $this->packLong(LAUNCHER_CHALLENGE);
136        $unencodedPacket .= $this->packLong($flags);
137        $unencodedPacket .= $this->packLong($time);
138        $unencodedPacket .= $this->packLong(0); // extended_flags
139
140        // Huffman-encode the query packet
141        $encoder = new HuffmanDecoder;
142        $packet  = $encoder->compress($unencodedPacket);
143
144        // Send query using UDP client
145        $response = $this->udpClient->query($ip, $port, $packet);
146
147        if ($response === null || $response === '' || $response === '0') {
148            return ['error' => 'No response received'];
149        }
150
151        // Step 1: Huffman decompress the response
152        $decoder = new HuffmanDecoder;
153
154        try {
155            $decompressed = $decoder->decompress($response);
156        } catch (Exception $e) {
157            return ['error' => 'Huffman decompression failed: ' . $e->getMessage()];
158        }
159
160        // Step 2: Parse response
161        return $this->parseDecompressedData($decompressed);
162    }
163
164    /**
165     * Parse decompressed response data.
166     *
167     * @return array<mixed>
168     */
169    private function parseDecompressedData(string $data): array
170    {
171        $offset = 0;
172
173        $responseCode = $this->getLong($data, $offset);
174
175        // Handle different response codes
176        if ($responseCode === 5660024) { // SERVER_LAUNCHER_IGNORING
177            return ['error' => 'Request ignored. Try again later.'];
178        }
179
180        if ($responseCode === 5660025) { // SERVER_LAUNCHER_BANNED
181            return ['error' => 'Your IP is banned from this server.'];
182        }
183
184        if ($responseCode === 5660031) { // SERVER_LAUNCHER_SEGMENTED_CHALLENGE
185            // Handle segmented response
186            return $this->parseSegmentedResponse();
187        }
188
189        if ($responseCode !== 5660023) { // SERVER_LAUNCHER_CHALLENGE
190            return ['error' => "Invalid response code ({$responseCode}), expected 5660023"];
191        }
192
193        // Parse regular response
194        $this->getLong($data, $offset);
195        $version       = $this->getString($data, $offset);
196        $returnedFlags = $this->getLong($data, $offset);
197
198        $result = [
199            'version' => $version,
200            'players' => [],
201        ];
202
203        if (($returnedFlags & SQF_NAME) !== 0) {
204            $result['name'] = $this->getString($data, $offset);
205        }
206
207        if (($returnedFlags & SQF_NUMPLAYERS) !== 0) {
208            $numPlayers           = $this->getByte($data, $offset);
209            $result['numPlayers'] = $numPlayers;
210        }
211
212        if (($returnedFlags & SQF_PLAYERDATA) !== 0 && isset($result['numPlayers'])) {
213            for ($i = 0; $i < $result['numPlayers']; $i++) {
214                $player                   = [];
215                $player['name']           = $this->getString($data, $offset);
216                $player['frags']          = $this->getShort($data, $offset);
217                $player['ping']           = $this->getShort($data, $offset);
218                $player['spectator']      = $this->getByte($data, $offset);
219                $player['bot']            = $this->getByte($data, $offset);
220                $player['team']           = $this->getByte($data, $offset);
221                $player['time_in_server'] = $this->getByte($data, $offset); // seconds
222                $result['players'][]      = $player;
223            }
224        }
225
226        return $result;
227    }
228
229    /**
230     * Parse segmented response data.
231     *
232     * @return array<mixed>
233     */
234    private function parseSegmentedResponse(): array
235    {
236        // For now, return error - segmented responses need more complex handling
237        return ['error' => 'Segmented responses not yet implemented'];
238    }
239
240    /**
241     * Helper: pack a 32-bit little endian.
242     */
243    private function packLong(int $val): string
244    {
245        return pack('V', $val); // little-endian 32-bit
246    }
247
248    /**
249     * Helper: get 32-bit little endian from data.
250     */
251    private function getLong(string $data, int &$offset): int
252    {
253        $unpacked = unpack('V', substr($data, $offset, 4));
254
255        if (!is_array($unpacked) || !isset($unpacked[1])) {
256            throw new RuntimeException('Invalid data for getLong');
257        }
258        $val = (int) $unpacked[1];
259        $offset += 4;
260
261        return $val;
262    }
263
264    /**
265     * Helper: get 16-bit little endian from data.
266     */
267    private function getShort(string $data, int &$offset): int
268    {
269        $unpacked = unpack('v', substr($data, $offset, 2));
270
271        if (!is_array($unpacked) || !isset($unpacked[1])) {
272            throw new RuntimeException('Invalid data for getShort');
273        }
274        $val = (int) $unpacked[1];
275        $offset += 2;
276
277        return $val;
278    }
279
280    /**
281     * Helper: get byte from data.
282     */
283    private function getByte(string $data, int &$offset): int
284    {
285        $val = ord($data[$offset]);
286        $offset++;
287
288        return $val;
289    }
290
291    /**
292     * Helper: get null-terminated string from data.
293     */
294    private function getString(string $data, int &$offset): string
295    {
296        $out = '';
297
298        while ($offset < strlen($data) && $data[$offset] !== "\x00") {
299            $out .= $data[$offset];
300            $offset++;
301        }
302        $offset++; // skip null terminator
303
304        return $out;
305    }
306}
307
308/**
309 * Protocol Constants.
310 */
311define('LAUNCHER_CHALLENGE', 199);
312
313/**
314 * Query flags.
315 */
316define('SQF_NAME', 0x00000001);
317define('SQF_NUMPLAYERS', 0x00080000);
318define('SQF_PLAYERDATA', 0x00100000);