Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 288
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQP
0.00% covered (danger)
0.00%
0 / 288
0.00% covered (danger)
0.00%
0 / 11
16770
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 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 getChallenge
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
132
 buildQueryPacket
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 parseResponse
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
702
 parseServerInfo
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
182
 parseServerRules
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 parsePlayerInfo
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
156
 readInfoHeader
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 readDynamicValue
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
1892
 readString
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 is_array;
16use function is_float;
17use function is_int;
18use function is_string;
19use function pack;
20use function strlen;
21use function substr;
22use function unpack;
23use Clansuite\ServerQuery\CSQuery;
24use Override;
25
26/**
27 * SQP (Server Query Protocol) implementation.
28 *
29 * This is based on Unity Technologies' SQP protocol.
30 * Used for querying Unity-based game servers, like TF2E, Unturned, etc.
31 *
32 * @see https://docs.unity.com/ugs/en-us/manual/game-server-hosting/manual/concepts/sqp
33 */
34class SQP extends CSQuery
35{
36    /**
37     * SQP constants.
38     */
39    public const QueryRequestType = 0x00;
40
41    public const QueryResponseType     = 0x00;
42    public const ChallengeRequestType  = 0x01;
43    public const ChallengeResponseType = 0x01;
44    public const Version               = 1;
45    public const DefaultMaxPacketSize  = 1400;
46
47    // Chunk types
48    public const ServerInfo  = 0x01;
49    public const ServerRules = 0x02;
50    public const PlayerInfo  = 0x04;
51    public const TeamInfo    = 0x08;
52    public const Metrics     = 0x10;
53
54    /**
55     * Protocol name.
56     */
57    public string $name = 'SQP';
58
59    /**
60     * Protocol identifier.
61     */
62    public string $protocol = 'SQP';
63
64    /**
65     * List of supported games.
66     *
67     * @var array<string>
68     */
69    public array $supportedGames = ['Unity'];
70
71    /**
72     * Challenge ID.
73     */
74    private int $challengeID = 0;
75
76    /**
77     * Constructor.
78     */
79    public function __construct(string $address, int $queryport)
80    {
81        parent::__construct();
82        $this->address   = $address;
83        $this->queryport = $queryport;
84    }
85
86    /**
87     * query_server method.
88     */
89    #[Override]
90    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
91    {
92        if ($this->online) {
93            $this->reset();
94        }
95
96        $address = (string) $this->address;
97        $port    = (int) $this->queryport;
98
99        // Get challenge
100        if (!$this->getChallenge()) {
101            return false;
102        }
103
104        // Build requested chunks
105        $requestedChunks = self::ServerInfo;
106
107        if ($getRules) {
108            $requestedChunks |= self::ServerRules;
109        }
110
111        if ($getPlayers) {
112            $requestedChunks |= self::PlayerInfo;
113        }
114
115        // Send query
116        $queryPacket = $this->buildQueryPacket($requestedChunks);
117
118        if (($result = $this->sendCommand($address, $port, $queryPacket)) === '' || ($result = $this->sendCommand($address, $port, $queryPacket)) === '0' || ($result = $this->sendCommand($address, $port, $queryPacket)) === false) {
119            return false;
120        }
121
122        // Parse response
123        return $this->parseResponse($result, $requestedChunks);
124    }
125
126    private function getChallenge(): bool
127    {
128        $address = (string) $this->address;
129        $port    = (int) $this->queryport;
130
131        $challengePacket = pack('C', self::ChallengeRequestType);
132
133        if (($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengePacket)) === '' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengePacket)) === '0' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengePacket)) === false) {
134            return false;
135        }
136
137        if (strlen($result) < 5) {
138            $this->errstr = 'Invalid challenge response';
139
140            return false;
141        }
142
143        $data = @unpack('Ctype/Nchallenge', $result);
144
145        if (!is_array($data) || !isset($data['type']) || !isset($data['challenge']) || !is_int($data['type']) || !is_int($data['challenge'])) {
146            $this->errstr = 'Invalid challenge response';
147
148            return false;
149        }
150
151        if ($data['type'] !== self::ChallengeResponseType) {
152            $this->errstr = 'Invalid challenge response type';
153
154            return false;
155        }
156
157        $this->challengeID = $data['challenge'];
158
159        return true;
160    }
161
162    private function buildQueryPacket(int $requestedChunks): string
163    {
164        return pack(
165            'CNNC',
166            self::QueryRequestType,
167            $this->challengeID,
168            self::Version,
169            $requestedChunks,
170        );
171    }
172
173    private function parseResponse(string $data, int $requestedChunks): bool
174    {
175        $pos = 0;
176        $len = strlen($data);
177        $tmp = null;
178
179        // Read header
180        if (1 > $len) {
181            return false;
182        }
183        $tmp = @unpack('C', substr($data, $pos, 1));
184
185        if (!is_array($tmp) || !isset($tmp[1])) {
186            return false;
187        }
188        $pktType = $tmp[1];
189        $pos++;
190
191        if ($pktType !== self::QueryResponseType) {
192            $this->errstr = 'Invalid response type';
193
194            return false;
195        }
196
197        // Validate challenge
198        if ($pos + 4 > $len) {
199            return false;
200        }
201        $tmp = @unpack('N', substr($data, $pos, 4));
202
203        if (!is_array($tmp) || !isset($tmp[1])) {
204            return false;
205        }
206        $challenge = $tmp[1];
207        $pos += 4;
208
209        if ($challenge !== $this->challengeID) {
210            $this->errstr = 'Challenge mismatch';
211
212            return false;
213        }
214
215        // Version
216        if ($pos + 2 > $len) {
217            return false;
218        }
219        $tmp = @unpack('n', substr($data, $pos, 2));
220
221        if (!is_array($tmp) || !isset($tmp[1])) {
222            return false;
223        }
224        unset($tmp); // Currently unused
225        $pos += 2;
226
227        // Packet info (assuming single packet for simplicity)
228        if ($pos + 4 > $len) {
229            return false;
230        }
231        $tmp = @unpack('C', substr($data, $pos, 1));
232
233        if (!is_array($tmp) || !isset($tmp[1])) {
234            return false;
235        }
236        $curPkt = $tmp[1];
237        $pos++;
238        $tmp = @unpack('C', substr($data, $pos, 1));
239
240        if (!is_array($tmp) || !isset($tmp[1])) {
241            return false;
242        }
243        $lastPkt = $tmp[1];
244        $pos++;
245        $tmp = @unpack('n', substr($data, $pos, 2));
246
247        if (!is_array($tmp) || !isset($tmp[1])) {
248            return false;
249        }
250        $pktLen = $tmp[1];
251        $pos += 2;
252
253        if ($curPkt > $lastPkt) {
254            $this->errstr = 'Invalid packet sequence';
255
256            return false;
257        }
258
259        // Parse chunks
260        $remaining = $pktLen;
261
262        if (($requestedChunks & self::ServerInfo) !== 0) {
263            $bytesRead = $this->parseServerInfo($data, $pos);
264
265            if ($bytesRead === false) {
266                return false;
267            }
268            $pos += $bytesRead;
269            $remaining -= $bytesRead;
270        }
271
272        if (($requestedChunks & self::ServerRules) !== 0) {
273            $bytesRead = $this->parseServerRules($data, $pos);
274
275            if ($bytesRead === false) {
276                return false;
277            }
278            $pos += $bytesRead;
279            $remaining -= $bytesRead;
280        }
281
282        if (($requestedChunks & self::PlayerInfo) !== 0) {
283            $bytesRead = $this->parsePlayerInfo($data, $pos);
284
285            if ($bytesRead === false) {
286                return false;
287            }
288            $pos += $bytesRead;
289            $remaining -= $bytesRead;
290        }
291
292        // Skip remaining bytes
293        $this->online = true;
294
295        return true;
296    }
297
298    private function parseServerInfo(string $data, int $pos): false|int
299    {
300        $startPos = $pos;
301        $tmp      = null;
302
303        if ($pos + 4 > strlen($data)) {
304            return false;
305        }
306        $tmp = @unpack('N', substr($data, $pos, 4));
307
308        if (!is_array($tmp) || !isset($tmp[1])) {
309            return false;
310        }
311        $this->challengeID = $tmp[1];
312        $pos += 4;
313
314        if ($pos + 2 > strlen($data)) {
315            return false;
316        }
317        $tmp = @unpack('n', substr($data, $pos, 2));
318
319        if (!is_array($tmp) || !isset($tmp[1])) {
320            return false;
321        }
322        $this->numplayers = $tmp[1];
323        $pos += 2;
324
325        if ($pos + 2 > strlen($data)) {
326            return false;
327        }
328        $tmp = @unpack('n', substr($data, $pos, 2));
329
330        if (!is_array($tmp) || !isset($tmp[1])) {
331            return false;
332        }
333        $this->maxplayers = $tmp[1];
334        $pos += 2;
335
336        $this->servertitle = $this->readString($data, $pos);
337        $this->gametype    = $this->readString($data, $pos);
338        $this->readString($data, $pos);
339        $this->mapname = $this->readString($data, $pos);
340
341        if ($pos + 2 > strlen($data)) {
342            return false;
343        }
344        $tmp = @unpack('n', substr($data, $pos, 2));
345
346        if (!is_array($tmp) || !isset($tmp[1])) {
347            return false;
348        }
349        $this->hostport = $tmp[1];
350        $pos += 2;
351
352        return $pos - $startPos;
353    }
354
355    private function parseServerRules(string $data, int &$pos): false|int
356    {
357        $startPos = $pos;
358        $tmp      = null;
359
360        if ($pos + 4 > strlen($data)) {
361            return false;
362        }
363        $tmp = @unpack('N', substr($data, $pos, 4));
364
365        if (!is_array($tmp) || !isset($tmp[1])) {
366            return false;
367        }
368        $chunkLen = $tmp[1];
369        $pos += 4;
370
371        $endPos = $startPos + 4 + $chunkLen;
372
373        while ($pos < $endPos) {
374            $key               = $this->readString($data, $pos);
375            $value             = $this->readString($data, $pos);
376            $this->rules[$key] = $value;
377        }
378
379        return $pos - $startPos;
380    }
381
382    private function parsePlayerInfo(string $data, int &$pos): false|int
383    {
384        $startPos = $pos;
385        $tmp      = null;
386
387        if ($pos + 4 > strlen($data)) {
388            return false;
389        }
390        $tmp = @unpack('N', substr($data, $pos, 4));
391
392        if (!is_array($tmp) || !isset($tmp[1])) {
393            return false;
394        }
395        $chunkLen = $tmp[1];
396        $pos += 4;
397
398        if ($pos + 2 > strlen($data)) {
399            return false;
400        }
401        $tmp = @unpack('n', substr($data, $pos, 2));
402
403        if (!is_array($tmp) || !isset($tmp[1])) {
404            return false;
405        }
406        $playerCount = $tmp[1];
407        $pos += 2;
408
409        if ($playerCount === 0) {
410            return $pos - $startPos + $chunkLen - 6; // Skip chunk
411        }
412
413        // Read header
414        $header = $this->readInfoHeader($data, $pos);
415
416        // Read players
417        for ($i = 0; $i < $playerCount; $i++) {
418            $player = [];
419
420            foreach ($header as $field) {
421                $type = $field['type'] ?? 0;
422                $name = $field['name'] ?? '';
423
424                if (is_int($type) && is_string($name)) {
425                    $value         = $this->readDynamicValue($data, $pos, $type);
426                    $player[$name] = $value;
427                }
428            }
429            $this->players[] = $player;
430        }
431
432        return $pos - $startPos;
433    }
434
435    /**
436     * @return array<array<string, mixed>>
437     */
438    private function readInfoHeader(string $data, int &$pos): array
439    {
440        $tmp = null;
441        $tmp = @unpack('C', substr($data, $pos, 1));
442        $pos++;
443        $fieldCount = 0;
444
445        if (is_array($tmp) && isset($tmp[1])) {
446            $fieldCount = $tmp[1];
447        }
448        $header = [];
449
450        for ($i = 0; $i < $fieldCount; $i++) {
451            $name = $this->readString($data, $pos);
452            $tmp  = @unpack('C', substr($data, $pos, 1));
453            $pos++;
454            $type = 0;
455
456            if (is_array($tmp) && isset($tmp[1])) {
457                $type = $tmp[1];
458            }
459            $header[] = ['name' => $name, 'type' => $type];
460        }
461
462        return $header;
463    }
464
465    private function readDynamicValue(string $data, int &$pos, int $type): null|float|int|string
466    {
467        $tmp = null;
468
469        switch ($type) {
470            case 0: // String
471                return $this->readString($data, $pos);
472
473            case 1: // Uint8
474                $tmp = @unpack('C', $data[$pos] ?? "\x00");
475                $pos++;
476                $val = 0;
477
478                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
479                    $val = $tmp[1];
480                }
481
482                return $val;
483
484            case 2: // Uint16
485                $tmp = @unpack('n', substr($data, $pos, 2));
486                $pos += 2;
487                $val = 0;
488
489                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
490                    $val = $tmp[1];
491                }
492
493                return $val;
494
495            case 3: // Uint32
496                $tmp = @unpack('N', substr($data, $pos, 4));
497                $pos += 4;
498                $val = 0;
499
500                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
501                    $val = $tmp[1];
502                }
503
504                return $val;
505
506            case 4: // Uint64
507                $tmp = @unpack('J', substr($data, $pos, 8));
508                $pos += 8;
509                $val = 0;
510
511                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
512                    $val = $tmp[1];
513                }
514
515                return $val;
516
517            case 5: // Int8
518                $tmp = @unpack('c', $data[$pos] ?? "\x00");
519                $pos++;
520                $val = 0;
521
522                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
523                    $val = $tmp[1];
524                }
525
526                return $val;
527
528            case 6: // Int16
529                $tmp = @unpack('s', substr($data, $pos, 2));
530                $pos += 2;
531                $val = 0;
532
533                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
534                    $val = $tmp[1];
535                }
536
537                return $val;
538
539            case 7: // Int32
540                $tmp = @unpack('l', substr($data, $pos, 4));
541                $pos += 4;
542                $val = 0;
543
544                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
545                    $val = $tmp[1];
546                }
547
548                return $val;
549
550            case 8: // Int64
551                $tmp = @unpack('q', substr($data, $pos, 8));
552                $pos += 8;
553                $val = 0;
554
555                if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
556                    $val = $tmp[1];
557                }
558
559                return $val;
560
561            case 9: // Float32
562                $tmp = @unpack('f', substr($data, $pos, 4));
563                $pos += 4;
564                $val = 0.0;
565
566                if (is_array($tmp) && isset($tmp[1]) && is_float($tmp[1])) {
567                    $val = $tmp[1];
568                }
569
570                return $val;
571
572            case 10: // Float64
573                $tmp = @unpack('d', substr($data, $pos, 8));
574                $pos += 8;
575                $val = 0.0;
576
577                if (is_array($tmp) && isset($tmp[1]) && is_float($tmp[1])) {
578                    $val = $tmp[1];
579                }
580
581                return $val;
582
583            default:
584                return null;
585        }
586    }
587
588    private function readString(string $data, int &$pos): string
589    {
590        $start = $pos;
591
592        while ($pos < strlen($data) && $data[$pos] !== "\x00") {
593            $pos++;
594        }
595        $str = substr($data, $start, $pos - $start);
596        $pos++; // Skip null terminator
597
598        return $str;
599    }
600}