Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Quake4
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 4
552
0.00% covered (danger)
0.00%
0 / 1
 query_server
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 parseDoom3Info
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 parsePlayers
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 translateProtocolVersion
0.00% covered (danger)
0.00%
0 / 19
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 explode;
17use function preg_match;
18use function preg_split;
19use function str_starts_with;
20use function strlen;
21use function substr;
22use function trim;
23use Override;
24
25/**
26 * Queries Quake 4 game servers.
27 *
28 * Extends the base Quake protocol to handle Quake 4 specific server queries,
29 * retrieving information about server status, players, and game settings.
30 * Enables monitoring of Quake 4 multiplayer servers.
31 */
32class Quake4 extends Quake
33{
34    /**
35     * Protocol name.
36     */
37    public string $name = 'Quake 4';
38
39    /**
40     * List of supported games.
41     */
42    public array $supportedGames = ['Quake 4'];
43
44    /**
45     * Protocol identifier.
46     */
47    public string $protocol = 'Quake4';
48
49    /**
50     * Game series.
51     */
52    public array $game_series_list = ['Quake'];
53
54    /**
55     * query_server method.
56     *
57     * Queries the Quake 4 server and populates server information.
58     *
59     * Sends a Quake 4 specific query command and processes the response
60     * to extract server details, player information, and game settings.
61     *
62     * @param bool $getPlayers Whether to retrieve player information
63     * @param bool $getRules   Whether to retrieve server rules/settings
64     *
65     * @return bool True on successful query, false on failure
66     */
67    #[Override]
68    public function query_server(mixed $getPlayers = true, mixed $getRules = true): bool
69    {
70        if ($this->online) {
71            $this->reset();
72        }
73
74        $address = (string) $this->address;
75        $port    = (int) $this->queryport;
76
77        // Doom3/Quake4 protocol uses different packet format
78        $command = "\xFF\xFFgetInfo\x00\x01\x00\x00\x00";
79
80        if (($result = $this->sendCommand($address, $port, $command)) === '' || ($result = $this->sendCommand($address, $port, $command)) === '0' || ($result = $this->sendCommand($address, $port, $command)) === false) {
81            $this->errstr = 'No reply received';
82
83            return false;
84        }
85
86        // Parse the Doom3 packet format
87        if (strlen($result) < 16) {
88            $this->errstr = 'Invalid packet received';
89
90            return false;
91        }
92
93        // Skip the packet header (16 bytes: short check, 12 bytes check2, int challengeId, int protocol, char pack)
94        $data = substr($result, 16);
95
96        // Parse server info
97        $info = $this->parseDoom3Info($data);
98
99        // Extract basic server information
100        $this->gamename    = (string) ($info['gamename'] ?? 'quake4');
101        $this->gameversion = $this->translateProtocolVersion((string) ($info['protocol'] ?? $info['si_version'] ?? ''));
102        $this->servertitle = (string) ($info['si_name'] ?? '');
103        $this->mapname     = (string) ($info['si_map'] ?? '');
104        $this->gametype    = (string) ($info['si_gameType'] ?? '');
105        $this->maxplayers  = (int) ($info['si_maxPlayers'] ?? 0);
106        $this->numplayers  = 0; // Will be calculated from players
107
108        // Store all rules
109        $this->rules = $info;
110
111        // Get players if requested
112        if ($getPlayers && isset($info['si_players'])) {
113            $this->parsePlayers((string) $info['si_players']);
114        }
115
116        $this->online = true;
117
118        return true;
119    }
120
121    /**
122     * Parse Doom3 server info string.
123     *
124     * @return array<string, mixed>
125     */
126    protected function parseDoom3Info(string $data): array
127    {
128        $info = [];
129
130        // Remove the "infoResponse" header if present
131        if (str_starts_with($data, 'infoResponse')) {
132            $data = substr($data, 12); // Remove "infoResponse"
133        }
134
135        // The format is key\x00value\x00key\x00value\x00...
136        // Split on null bytes
137        $parts = explode("\x00", $data);
138
139        // Skip empty parts at the beginning
140        $i = 0;
141
142        while ($i < count($parts) && ($parts[$i] ?? '') === '') {
143            $i++;
144        }
145
146        // Parse key-value pairs
147        // The format should be key\x00value\x00key\x00value\x00...
148        while ($i < count($parts) - 1) {
149            $potentialKey   = $parts[$i] ?? '';
150            $potentialValue = $parts[$i + 1] ?? '';
151
152            // Check if this looks like a key (contains known key patterns)
153            $isKey = false;
154
155            if (preg_match('/^(sv_|si_|fs_|net_|gamename|protocol)/', $potentialKey) === 1) {
156                $isKey = true;
157            }
158
159            if ($isKey && $potentialKey !== '') {
160                $info[$potentialKey] = $potentialValue;
161                $i += 2; // Skip the value
162            } else {
163                // Not a key, skip this part
164                $i++;
165            }
166        }
167
168        return $info;
169    }
170
171    /**
172     * Parse player information from si_players.
173     *
174     * @param string $playersData The si_players string containing player info
175     */
176    protected function parsePlayers(string $playersData): void
177    {
178        // si_players format: score ping name clan score ping name clan ...
179        $parts   = preg_split('/\s+/', trim($playersData));
180        $parts   = $parts !== false ? $parts : [];
181        $players = [];
182
183        for ($i = 0; $i < count($parts); $i += 4) {
184            if ($i + 3 < count($parts)) {
185                $score = (int) ($parts[$i] ?? 0);
186                $ping  = (int) ($parts[$i + 1] ?? 0);
187                $name  = $parts[$i + 2] ?? '';
188                $clan  = $parts[$i + 3] ?? '';
189
190                $players[] = [
191                    'name'  => $name,
192                    'score' => $score,
193                    'ping'  => $ping,
194                    'clan'  => $clan,
195                ];
196            }
197        }
198
199        $this->numplayers = count($players);
200        $this->playerkeys = ['name' => true, 'score' => true, 'ping' => true, 'clan' => true];
201        $this->players    = $players;
202    }
203
204    /**
205     * Translate protocol version to human readable format.
206     *
207     * @param string $protocol The protocol version string
208     *
209     * @return string Human readable protocol version
210     */
211    protected function translateProtocolVersion(string $protocol): string
212    {
213        // If it's already a descriptive string, return it
214        if (str_starts_with($protocol, 'Quake4') || str_starts_with($protocol, 'Q4')) {
215            return $protocol;
216        }
217
218        $versions = [
219            '2.62'    => 'Q4 1.0',
220            '2.63'    => 'Q4 1.0 (German)',
221            '2.66'    => 'Q4 Demo',
222            '2.67'    => 'Q4 1.1 Beta',
223            '2.68'    => 'Q4 1.1',
224            '2.69'    => 'Q4 1.2',
225            '2.71'    => 'Q4 1.3',
226            '2.76'    => 'Q4 1.4 Beta',
227            '2.77'    => 'Q4 1.4.1',
228            '2.85'    => 'Q4 1.4.2',
229            '2.77 DE' => 'Q4 1.4.1 (German)',
230            '2.71 DE' => 'Q4 1.3 (German)',
231            '2.85 DE' => 'Q4 1.4.2 (German)',
232            '2.86'    => 'Q4 1.4.2 Demo',
233        ];
234
235        return $versions[$protocol] ?? $protocol;
236    }
237}