Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
28.01% covered (danger)
28.01%
114 / 407
7.14% covered (danger)
7.14%
1 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CounterStrike16
28.01% covered (danger)
28.01%
114 / 407
7.14% covered (danger)
7.14%
1 / 14
10572.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 query_server
65.22% covered (warning)
65.22%
30 / 46
0.00% covered (danger)
0.00%
0 / 1
34.19
 rcon_query_server
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 getPlayers
50.00% covered (danger)
50.00%
15 / 30
0.00% covered (danger)
0.00%
0 / 1
58.50
 collectMultiPacketResponse
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
756
 tryGoldSourcePlayers
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 parseGoldSourcePlayers
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
132
 parseBinaryPlayers
75.61% covered (warning)
75.61%
31 / 41
0.00% covered (danger)
0.00%
0 / 1
15.45
 parseTextPlayers
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
272
 getRules
24.14% covered (danger)
24.14%
7 / 29
0.00% covered (danger)
0.00%
0 / 1
74.87
 readString
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 unpackFirstValue
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseSourceInfo
81.82% covered (warning)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
12.87
 parseGoldSourceInfo
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
182
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 bzdecompress;
16use function count;
17use function crc32;
18use function explode;
19use function function_exists;
20use function gmdate;
21use function implode;
22use function is_array;
23use function is_int;
24use function is_string;
25use function ksort;
26use function microtime;
27use function preg_match;
28use function preg_replace;
29use function round;
30use function str_contains;
31use function strlen;
32use function strtolower;
33use function substr;
34use function trim;
35use function unpack;
36use Clansuite\ServerQuery\CSQuery;
37use Clansuite\ServerQuery\Util\PacketReader;
38use Override;
39
40/**
41 * Queries a Counter Strike 1.6 server.
42 *
43 * This class works with Counter Strike 1.6 (based on Half-Life engine).
44 */
45class CounterStrike16 extends CSQuery
46{
47    /**
48     * Protocol name.
49     */
50    public string $name = 'Counter Strike 1.6';
51
52    /**
53     * Protocol identifier.
54     */
55    public string $protocol = 'CounterStrike16';
56
57    /**
58     * Game series.
59     *
60     * @var array<string>
61     */
62    public array $game_series_list = ['Counter-Strike'];
63
64    /**
65     * List of supported games.
66     *
67     * @var array<string>
68     */
69    public array $supportedGames = ['Counter Strike 1.6'];
70    public string $playerFormat  = '/sscore/x2/ftime';
71    public float $response       = 0.0;
72
73    /**
74     * Constructor.
75     */
76    public function __construct(mixed $address, mixed $queryport)
77    {
78        parent::__construct((is_string($address) ? $address : null), (is_int($queryport) ? $queryport : null));
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        $starttime = microtime(true);
92
93        // Use Source engine A2S_INFO query
94        $command = "\xFF\xFF\xFF\xFFTSource Engine Query\x00";
95
96        // Try up to three times to obtain a valid response, but preserve the
97        // first valid reply to avoid overwriting it with subsequent
98        // '(no response)' fixtures from the mock UDP client.
99        $attempts = 0;
100        $result   = false;
101
102        while ($attempts < 3) {
103            $attempts++;
104            $tmp = $this->sendCommand((string) $this->address, (int) $this->queryport, $command);
105
106            if ($tmp !== '' && $tmp !== false && $tmp !== '0') {
107                $result = $tmp;
108
109                break;
110            }
111
112            if ($result === false) {
113                $result = $tmp;
114            }
115        }
116
117        if ($result === '' || $result === '0' || $result === false) {
118            $this->errstr = 'No reply received';
119
120            return false;
121        }
122
123        $endtime = microtime(true);
124        $diff    = round(($endtime - $starttime) * 1000, 0);
125        // response time
126        $this->response = round($diff, 2);
127
128        // Parse Source engine A2S_INFO response
129        if (strlen($result) < 5) {
130            $this->errstr = 'Response too short';
131
132            return false;
133        }
134
135        // Check header (should be 0xFFFFFFFF)
136        $header = $this->unpackFirstValue('N', substr($result, 0, 4));
137
138        if ($header === null) {
139            $this->errstr = 'Invalid header unpack';
140
141            return false;
142        }
143
144        if ($header !== 4294967295) {
145            $this->errstr = 'Invalid header';
146
147            return false;
148        }
149
150        // Check response type (should be 'I' for Source engine INFO or 'm' for GoldSource INFO)
151        $responseType = substr($result, 4, 1);
152
153        $success = false;
154
155        if ($responseType === 'I') {
156            $success = $this->parseSourceInfo($result);
157        } elseif ($responseType === 'm') {
158            $success = $this->parseGoldSourceInfo($result);
159        } else {
160            $this->errstr = 'Not an INFO response (got: ' . $responseType . ')';
161
162            return false;
163        }
164
165        if (!$success) {
166            return false;
167        }
168
169        // Get players if requested
170        if ($getPlayers && $this->numplayers > 0) {
171            $this->getPlayers();
172        }
173
174        // Get rules if requested
175        if ($getRules) {
176            $this->getRules();
177        }
178
179        $this->online = true;
180
181        return true;
182    }
183
184    /**
185     * rcon_query_server method.
186     */
187    public function rcon_query_server(string $command, string $rcon_pwd): false|string
188    {
189        $get_challenge = "\xFF\xFF\xFF\xFFchallenge rcon\n";
190
191        $challengeResponse = $this->sendCommand((string) $this->address, (int) $this->queryport, $get_challenge);
192
193        if ($challengeResponse === '' || $challengeResponse === '0' || $challengeResponse === false) {
194            $this->debug['Command send ' . $command] = 'No challenge rcon received';
195
196            return false;
197        }
198
199        if (preg_match('/challenge rcon (?P<challenge>[0-9]+)/D', $challengeResponse, $matches) !== 1) {
200            $this->debug['Command send ' . $command] = 'No valid challenge rcon received';
201
202            return false;
203        }
204
205        $challenge      = $matches['challenge'];
206        $commandPayload = "\xFF\xFF\xFF\xFFrcon {$challenge} \"{$rcon_pwd}\" {$command}\n";
207
208        $result = $this->sendCommand((string) $this->address, (int) $this->queryport, $commandPayload);
209
210        if ($result === '' || $result === '0' || $result === false) {
211            $this->debug['Command send ' . $command] = 'No rcon reply received';
212
213            return false;
214        }
215
216        return $result;
217    }
218
219    private function getPlayers(): bool
220    {
221        // Use a Source-style flow: send A2S_PLAYER with -1 and inspect reply
222        $playerRequest = "\xFF\xFF\xFF\xFF\x55\xFF\xFF\xFF\xFF";
223
224        // Send initial player request and get raw response using a persistent socket
225        $initial = $this->sendCommand((string) $this->address, (int) $this->queryport, $playerRequest);
226
227        if ($initial !== false && strlen($initial) >= 5) {
228            // Inspect header
229            $header = $this->unpackFirstValue('l', substr($initial, 0, 4));
230
231            if ($header === -1) {
232                $respType = substr($initial, 4, 1);
233
234                if ($respType === 'D') {
235                    // Full player response returned immediately
236                    if ($this->parseBinaryPlayers($initial)) {
237                        return true;
238                    }
239                } elseif ($respType === 'A') {
240                    // Challenge returned: extract 4 bytes starting at pos 5 (or 1?)
241                    // The challenge is usually the 4 bytes after the response byte
242                    if (strlen($initial) >= 9) {
243                        $challenge     = substr($initial, 5, 4);
244                        $playerCommand = "\xFF\xFF\xFF\xFF\x55" . $challenge;
245                        $result        = $this->sendCommand((string) $this->address, (int) $this->queryport, $playerCommand);
246
247                        if ($result !== false && strlen($result) > 5) {
248                            if ($this->parseBinaryPlayers($result)) {
249                                return true;
250                            }
251                        }
252                    }
253                }
254            } elseif ($header === -2) {
255                // Multi-packet response: collect and assemble
256                $assembled = $this->collectMultiPacketResponse($initial);
257
258                if ($assembled !== false && $assembled !== '') {
259                    if ($this->parseBinaryPlayers($assembled)) {
260                        return true;
261                    }
262                }
263            }
264        }
265
266        // Try GoldSource legacy challenge/players flow as a fallback
267        if ($this->tryGoldSourcePlayers()) {
268            return true;
269        }
270
271        // Fallback to text-based status command
272        $command = "status\n";
273        $result  = $this->sendCommand((string) $this->address, (int) $this->queryport, $command);
274
275        if ($result !== false && $result !== '') {
276            return $this->parseTextPlayers($result);
277        }
278
279        $this->players = [];
280
281        return true;
282    }
283
284    /**
285     * Basic multi-packet collector. Uses existing _sendCommand to read subsequent parts
286     * and assembles payloads. Supports bzip2 compressed payloads when indicated.
287     */
288    private function collectMultiPacketResponse(string $firstChunk): false|string
289    {
290        // parse header similar to PHP-Source-Query's ReadInternal
291        $reader = new PacketReader($firstChunk);
292
293        $header = $reader->readInt32();
294
295        if ($header === null || $header !== -2) {
296            return false;
297        }
298
299        // RequestID (may include compression flag in high bit)
300        $requestId = $reader->readInt32();
301
302        if ($requestId === null) {
303            return false;
304        }
305
306        $isCompressed = ($requestId & 0x80000000) !== 0;
307
308        // Determine Source vs GoldSource split format by peeking next bytes
309        $parts = [];
310
311        // Helper to parse a chunk's header and return [count, number, payloadPart, checksum|null]
312        $parseChunk = static function (mixed $chunk): ?array
313        {
314            $r = is_string($chunk) ? new PacketReader($chunk) : null;
315
316            if ($r === null) {
317                return null;
318            }
319
320            $h = $r->readInt32();
321
322            if ($h === null || $h !== -2) {
323                return null;
324            }
325            $rid = $r->readInt32();
326
327            if ($rid === null) {
328                return null;
329            }
330            $compressed = ($rid & 0x80000000) !== 0;
331
332            // Attempt Source-style parsing
333            $remaining = strlen($chunk) - $r->pos();
334
335            if ($remaining >= 2) {
336                $packetCount  = $r->readUint8();
337                $packetNumber = $r->readUint8();
338
339                if ($packetCount !== null && $packetNumber !== null) {
340                    $packetNumber = $packetNumber + 1; // Source packet numbers are 1-based in many libs
341                    $checksum     = null;
342
343                    if ($compressed) {
344                        // split size (int32) and checksum(uint32)
345                        $r->readInt32(); // ignore split size
346                        $checksum = $r->readUint32();
347                    } else {
348                        // split size int16
349                        $r->readUint16();
350                    }
351
352                    $payloadPart = $r->rest();
353
354                    return [$packetCount, $packetNumber, $payloadPart, $checksum];
355                }
356            }
357
358            // Fallback to GoldSource-style parsing
359            $r = is_string($chunk) ? new PacketReader($chunk) : null;
360
361            if ($r === null) {
362                return null;
363            }
364
365            $r->readInt32();
366            $r->readInt32();
367            $b = $r->readUint8();
368
369            if ($b === null) {
370                return null;
371            }
372            $packetCount  = $b & 0xF;
373            $packetNumber = $b >> 4;
374            $payloadPart  = $r->rest();
375
376            return [$packetCount, $packetNumber, $payloadPart, null];
377        };
378
379        // Parse first chunk
380        $firstParsed = $parseChunk($firstChunk);
381
382        if ($firstParsed === null) {
383            return false;
384        }
385        [$total, $number, $part, $packetChecksum] = $firstParsed;
386        $parts[$number]                           = $part;
387
388        // Collect remaining parts
389        $received = count($parts);
390
391        while ($received < $total) {
392            $chunk = $this->sendCommand((string) $this->address, (int) $this->queryport, '');
393
394            if ($chunk === false || strlen($chunk) < 8) {
395                break;
396            }
397            $parsed = $parseChunk($chunk);
398
399            if ($parsed === null) {
400                break;
401            }
402            [$t, $n, $p, $chk] = $parsed;
403            $parts[$n]         = $p;
404
405            if ($chk !== null) {
406                $packetChecksum = $chk;
407            }
408            $received = count($parts);
409        }
410
411        ksort($parts);
412        $data = implode('', $parts);
413
414        if ($isCompressed) {
415            if (!function_exists('bzdecompress')) {
416                return false;
417            }
418            $decompressed = bzdecompress($data);
419
420            if ($decompressed === false) {
421                return false;
422            }
423
424            if ($packetChecksum !== null && crc32((string) $decompressed) !== $packetChecksum) {
425                return false;
426            }
427
428            // As upstream does, strip the first 4 bytes from assembled data
429            return substr((string) $decompressed, 4);
430        }
431
432        return substr($data, 4);
433    }
434
435    private function tryGoldSourcePlayers(): bool
436    {
437        // Get challenge for GoldSource player query
438        $challengeCommand = "\xFF\xFF\xFF\xFF\x57";
439        $challengeResult  = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengeCommand);
440
441        if ($challengeResult === false || strlen($challengeResult) < 5) {
442            return false;
443        }
444
445        // Check header
446        $header = $this->unpackFirstValue('N', substr($challengeResult, 0, 4));
447
448        if ($header === null || $header !== 4294967295) {
449            return false;
450        }
451
452        // Check response type (should be 0x41 'A')
453        $responseType = substr($challengeResult, 4, 1);
454
455        if ($responseType !== 'A') {
456            return false;
457        }
458
459        // Extract challenge number
460        if (strlen($challengeResult) < 9) {
461            return false;
462        }
463        $challenge = substr($challengeResult, 5, 4);
464
465        // Query players with challenge
466        $playerCommand = "\xFF\xFF\xFF\xFF\x55" . $challenge;
467        $result        = $this->sendCommand((string) $this->address, (int) $this->queryport, $playerCommand);
468
469        if ($result === false || strlen($result) < 5) {
470            return false;
471        }
472
473        return $this->parseGoldSourcePlayers($result);
474    }
475
476    private function parseGoldSourcePlayers(string $result): bool
477    {
478        if (strlen($result) < 5) {
479            return false;
480        }
481
482        // Check header
483        $header = $this->unpackFirstValue('N', substr($result, 0, 4));
484
485        if ($header === null || $header !== 4294967295) {
486            return false;
487        }
488
489        // Check response type (should be 0x44 'D')
490        $responseType = substr($result, 4, 1);
491
492        if ($responseType !== 'D') {
493            return false;
494        }
495
496        $buf    = substr($result, 5);
497        $reader = new PacketReader($buf);
498
499        $numPlayers = $reader->readUint8();
500
501        if ($numPlayers === null) {
502            return false;
503        }
504
505        $players = [];
506
507        for ($i = 0; $i < $numPlayers; $i++) {
508            // Skip player index byte
509            if ($reader->readUint8() === null) {
510                break;
511            }
512
513            $name = $reader->readString();
514
515            if ($name === null) {
516                break;
517            }
518
519            $score = $reader->readInt32();
520
521            if ($score === null) {
522                break;
523            }
524
525            $timeValue = $reader->readFloat();
526
527            if ($timeValue === null) {
528                break;
529            }
530
531            $players[] = [
532                'name'  => $name,
533                'score' => $score,
534                'time'  => gmdate('H:i:s', (int) $timeValue),
535            ];
536        }
537
538        $this->players = $players;
539
540        return true;
541    }
542
543    private function parseBinaryPlayers(string $result): bool
544    {
545        if (strlen($result) < 5) {
546            return false;
547        }
548
549        // Check header (should be 0xFFFFFFFF)
550        $header = $this->unpackFirstValue('N', substr($result, 0, 4));
551
552        if ($header === null || $header !== 4294967295) {
553            return false;
554        }
555
556        // Check response type (should be 'D' for PLAYER)
557        $responseType = substr($result, 4, 1);
558
559        if ($responseType !== 'D') {
560            return false;
561        }
562
563        // Parse A2S_PLAYER response
564        $buf    = substr($result, 5);
565        $reader = new PacketReader($buf);
566
567        $numPlayers = $reader->readUint8();
568
569        if ($numPlayers === null) {
570            return false;
571        }
572
573        $players = [];
574
575        for ($i = 0; $i < $numPlayers; $i++) {
576            if ($reader->readUint8() === null) {
577                break;
578            }
579
580            $name = $reader->readString();
581
582            if ($name === null) {
583                break;
584            }
585
586            $scoreRaw = $reader->readUint32();
587
588            if ($scoreRaw === null) {
589                break;
590            }
591
592            // Convert unsigned to signed 32-bit
593            if (($scoreRaw & 0x80000000) !== 0) {
594                $scoreRaw -= 0x100000000;
595            }
596
597            $timeValue = $reader->readFloat();
598
599            if ($timeValue === null) {
600                break;
601            }
602
603            // sanitize name: remove non-printable/control characters
604            $name = preg_replace('/[[:^print:]]/', '', $name);
605            $name = trim((string) $name);
606
607            // Format time: display as H:i:s if >= 3600s, otherwise mm:ss
608            $timeInt = (int) round($timeValue);
609
610            if ($timeInt >= 3600) {
611                $timeStr = gmdate('H:i:s', $timeInt);
612            } else {
613                $timeStr = gmdate('i:s', $timeInt);
614            }
615
616            $players[] = [
617                'name'  => $name,
618                'score' => $scoreRaw,
619                'time'  => $timeStr,
620            ];
621        }
622
623        $this->players = $players;
624
625        return true;
626    }
627
628    private function parseTextPlayers(string $result): bool
629    {
630        // Parse text-based status response
631        $lines = explode("\n", trim($result));
632
633        $players          = [];
634        $inPlayersSection = false;
635
636        /** @var null|array<string, mixed> $currentPlayer */
637        $currentPlayer = null;
638
639        foreach ($lines as $line) {
640            $line = trim($line);
641
642            if ($line === '') {
643                continue;
644            }
645
646            if ($line === '0') {
647                continue;
648            }
649
650            // Look for the start of players section
651            if (strtolower($line) === 'players') {
652                $inPlayersSection = true;
653
654                continue;
655            }
656
657            if (!$inPlayersSection) {
658                continue;
659            }
660
661            // Check if this is a player number line (like "2.")
662            if (preg_match('/^(\d+)\.$/', $line, $matches) === 1) {
663                // This is a player number, previous lines should have name and time
664                if ($currentPlayer !== null && isset($currentPlayer['name'], $currentPlayer['time'])) {
665                    $players[] = [
666                        'name'  => $currentPlayer['name'],
667                        'score' => $currentPlayer['score'] ?? 0,
668                        'time'  => $currentPlayer['time'],
669                    ];
670                }
671                $currentPlayer = ['index' => (int) $matches[1]];
672
673                continue;
674            }
675
676            // Check if this looks like a time format (HHH:MM:SS)
677            if (preg_match('/^\d{1,3}:\d{2}:\d{2}$/', $line) === 1) {
678                if ($currentPlayer !== null) {
679                    $currentPlayer['time'] = $line;
680                }
681
682                continue;
683            }
684
685            // If it doesn't match the above patterns and we have a current player,
686            // it might be the player name
687            if ($currentPlayer !== null && !isset($currentPlayer['name'])) {
688                $currentPlayer['name'] = $line;
689
690                continue;
691            }
692
693            // Fallback: try the original format
694            if (preg_match('/^#(\d+)\s+"([^"]+)"\s+(\d+)\s+([\d:]+)\s+/', $line, $matches) === 1) {
695                $players[] = [
696                    'name'  => $matches[2],
697                    'score' => (int) $matches[3],
698                    'time'  => $matches[4],
699                ];
700            }
701        }
702
703        // Don't forget the last player
704        if ($currentPlayer !== null && isset($currentPlayer['name'], $currentPlayer['time'])) {
705            $players[] = [
706                'name'  => $currentPlayer['name'],
707                'score' => $currentPlayer['score'] ?? 0,
708                'time'  => $currentPlayer['time'],
709            ];
710        }
711
712        $this->players = $players;
713
714        return true;
715    }
716
717    /**
718     * Get rules from the server.
719     */
720    private function getRules(): bool
721    {
722        $command = "\xFF\xFF\xFF\xFFrules\x00\x00";
723
724        $result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command);
725
726        if ($result === '' || $result === '0' || $result === false) {
727            return false;
728        }
729
730        if (strlen($result) < 5) {
731            return false;
732        }
733
734        // Check header
735        $header = $this->unpackFirstValue('N', substr($result, 0, 4));
736
737        if ($header === null || $header !== 4294967295) {
738            return false;
739        }
740
741        // Check indicator (should be 'E' for rules)
742        $indicator = substr($result, 4, 1);
743
744        if ($indicator !== 'E') {
745            return false;
746        }
747
748        $data = substr($result, 5);
749        $pos  = 0;
750
751        if (strlen($data) - $pos < 2) {
752            return false;
753        }
754
755        $numRulesValue = $this->unpackFirstValue('n', substr($data, $pos, 2));
756
757        if ($numRulesValue === null) {
758            return false;
759        }
760        $pos += 2;
761
762        $rules = [];
763
764        for ($i = 0; $i < $numRulesValue; $i++) {
765            if ($pos >= strlen($data)) {
766                break;
767            }
768
769            $ruleName         = $this->readString($data, $pos);
770            $ruleValue        = $this->readString($data, $pos);
771            $rules[$ruleName] = $ruleValue;
772        }
773
774        $this->rules = $rules;
775
776        return true;
777    }
778
779    /**
780     * Read a null-terminated string from data.
781     */
782    private function readString(string $data, int &$pos): string
783    {
784        $start = $pos;
785
786        while ($pos < strlen($data) && $data[$pos] !== "\x00") {
787            $pos++;
788        }
789        $string = substr($data, $start, $pos - $start);
790        $pos++; // Skip null terminator
791
792        return $string;
793    }
794
795    /**
796     * Safely unpack binary data and return the first value.
797     */
798    private function unpackFirstValue(string $format, string $data): mixed
799    {
800        $unpacked = unpack($format, $data);
801
802        if (!is_array($unpacked) || !isset($unpacked[1])) {
803            return null;
804        }
805
806        return $unpacked[1];
807    }
808
809    /**
810     * Parse Source engine A2S_INFO response.
811     */
812    private function parseSourceInfo(mixed $result): bool
813    {
814        if (!is_string($result) || strlen($result) < 6) {
815            return false;
816        }
817
818        $reader = new PacketReader(substr($result, 5));
819
820        // Read protocol version (ignored, but must consume the byte)
821        if ($reader->readUint8() === null) {
822            return false;
823        }
824
825        $serverTitle = $reader->readString();
826        $mapName     = $reader->readString();
827        $gameDir     = $reader->readString();
828        $gameName    = $reader->readString();
829
830        if ($serverTitle === null || $mapName === null || $gameDir === null || $gameName === null) {
831            return false;
832        }
833
834        $appId = $reader->readUint16();
835
836        if ($appId === null) {
837            return false;
838        }
839
840        if ($reader->remaining() < 3) {
841            return false;
842        }
843
844        $numPlayers = $reader->readUint8();
845        $maxPlayers = $reader->readUint8();
846        $password   = $reader->readUint8();
847
848        $gameVersion = $reader->readString();
849
850        if ($gameVersion === null) {
851            return false;
852        }
853
854        $this->servertitle = $serverTitle;
855        $this->mapname     = $mapName;
856        $this->gamename    = $gameName;
857        $this->steamAppID  = $appId;
858        $this->numplayers  = $numPlayers;
859        $this->maxplayers  = $maxPlayers;
860        $this->password    = $password > 0 ? 1 : 0;
861        $this->gameversion = $gameVersion;
862
863        // Set game type
864        $this->gametype = 'Counter-Strike 1.6';
865
866        $this->online = true;
867
868        return true;
869    }
870
871    /**
872     * Parse GoldSource INFO response.
873     */
874    private function parseGoldSourceInfo(string $result): bool
875    {
876        if (strlen($result) < 5) {
877            return false;
878        }
879
880        $header = $this->unpackFirstValue('N', substr($result, 0, 4));
881
882        if ($header === null || $header !== 4294967295) {
883            return false;
884        }
885
886        $reader = new PacketReader(substr($result, 5));
887
888        // GoldSource INFO layout is:
889        //   address, hostname, map, game_dir, game_descr, num_players, max_players, version, ...
890        $reader->readString(); // address (ignored)
891
892        $serverTitle = $reader->readString();
893        $mapName     = $reader->readString();
894        $gameDir     = $reader->readString();
895        $gameName    = $reader->readString();
896
897        if ($serverTitle === null || $mapName === null || $gameDir === null || $gameName === null) {
898            return false;
899        }
900
901        $currentPlayers = $reader->readUint8();
902        $maxPlayers     = $reader->readUint8();
903
904        if ($currentPlayers === null || $maxPlayers === null) {
905            return false;
906        }
907
908        $gameVersion = $reader->readString();
909
910        if ($gameVersion === null) {
911            return false;
912        }
913
914        $this->servertitle = $serverTitle;
915        $this->mapname     = $mapName;
916        $this->gamename    = $gameName;
917        $this->numplayers  = $currentPlayers;
918        $this->maxplayers  = $maxPlayers;
919        $this->gameversion = $gameVersion;
920
921        // Set game type based on game directory
922        if (str_contains(strtolower($gameDir), 'cstrike')) {
923            $this->gametype = 'Counter-Strike 1.6';
924        } elseif (str_contains(strtolower($gameDir), 'czero')) {
925            $this->gametype = 'Counter-Strike: Condition Zero';
926        } else {
927            $this->gametype = $this->gamename;
928        }
929
930        return true;
931    }
932}