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 define;
17: use function is_array;
18: use function ord;
19: use function pack;
20: use function strlen;
21: use function substr;
22: use function time;
23: use function unpack;
24: use Clansuite\ServerQuery\CSQuery;
25: use Clansuite\ServerQuery\Util\HuffmanDecoder;
26: use Exception;
27: use Override;
28: use 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: */
37: abstract 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: */
311: define('LAUNCHER_CHALLENGE', 199);
312:
313: /**
314: * Query flags.
315: */
316: define('SQF_NAME', 0x00000001);
317: define('SQF_NUMPLAYERS', 0x00080000);
318: define('SQF_PLAYERDATA', 0x00100000);
319: