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:
13: namespace Clansuite\ServerQuery\ServerProtocols;
14:
15: use function count;
16: use function explode;
17: use function preg_match;
18: use function preg_split;
19: use function str_starts_with;
20: use function strlen;
21: use function substr;
22: use function trim;
23: use 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: */
32: class 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: }
238: