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 array_key_exists;
16: use function count;
17: use function is_int;
18: use function is_string;
19: use function ord;
20: use function preg_replace;
21: use function strlen;
22: use function substr;
23: use Clansuite\ServerQuery\CSQuery;
24: use Clansuite\ServerQuery\Util\UdpClient;
25: use Exception;
26: use Override;
27:
28: /**
29: * Cube Engine protocol implementation.
30: *
31: * Used for Cube 1, Assault Cube, Cube 2: Sauerbraten, Blood Frontier.
32: */
33: class Cube extends CSQuery
34: {
35: /**
36: * Extended ping commands.
37: */
38: private const EXTPING_NAMELIST = "\x01\x01";
39:
40: private const EXT_PLAYERSTATS = "\x00\x01";
41:
42: /**
43: * Protocol name.
44: */
45: public string $name = 'Cube Engine';
46:
47: /**
48: * Protocol identifier.
49: */
50: public string $protocol = 'Cube';
51:
52: /**
53: * List of supported games.
54: *
55: * @var array<string>
56: */
57: public array $supportedGames = [
58: 'Cube 1',
59: 'Assault Cube',
60: 'Cube 2: Sauerbraten',
61: 'Blood Frontier',
62: ];
63:
64: /**
65: * Game mode names.
66: *
67: * @var string[]
68: */
69: private array $modeNames = [
70: 'DEMO', 'TDM', 'coop', 'DM', 'SURV', 'TSURV', 'CTF', 'PF', 'BTDM', 'BDM', 'LSS',
71: 'OSOK', 'TOSOK', 'BOSOK', 'HTF', 'TKTF', 'KTF', 'TPF', 'TLSS', 'BPF', 'BLSS', 'BTSURV', 'BTOSOK',
72: ];
73:
74: /**
75: * State names.
76: *
77: * @var string[]
78: */
79: private array $stateNames = [
80: 'alive', 'dead', 'spawning', 'lagged', 'editing', 'spectating',
81: ];
82:
83: /**
84: * Constructor.
85: */
86: public function __construct(mixed $address, mixed $queryport)
87: {
88: parent::__construct((is_string($address) ? $address : null), (is_int($queryport) ? $queryport : null));
89: }
90:
91: /**
92: * query_server method.
93: */
94: #[Override]
95: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
96: {
97: if ($this->online) {
98: $this->reset();
99: }
100:
101: // Cube Engine queries use port + 1
102: $queryPort = ($this->queryport ?? 0) + 1;
103:
104: // Send EXTPING_NAMELIST to get basic server info and player names
105: $result1 = $this->sendCommand($this->address ?? '', $queryPort, self::EXTPING_NAMELIST);
106:
107: if ($result1 === '' || $result1 === '0' || $result1 === false) {
108: $this->errstr = 'No reply received for server info';
109:
110: return false;
111: }
112:
113: // Parse server info response
114: $buffer = new CubeReadBuffer($result1);
115:
116: // Skip extping_code (2 bytes)
117: $buffer->getUChar();
118: $buffer->getUChar();
119:
120: // Skip proto_version (3 bytes)
121: $buffer->getUChar();
122: $buffer->getUChar();
123: $buffer->getUChar();
124:
125: $gamemode = $buffer->getInt();
126: $nbConnectedClients = $buffer->getInt();
127: $buffer->getInt();
128: $serverMap = $buffer->getString();
129: $serverDescription = $this->filterText($buffer->getString());
130: $maxClients = $buffer->getInt();
131:
132: // Mastermode (2 bytes)
133: $mastermode1 = $buffer->getUChar();
134: $buffer->getUChar();
135:
136: // Convert mastermode
137: $mastermode = 'open'; // default
138:
139: if ($mastermode1 === 64 || $mastermode1 === 65) {
140: $mastermode = 'private';
141: } elseif ($mastermode1 === -128) {
142: $mastermode = 'match';
143: }
144:
145: // Read player names
146: $playerNames = [];
147:
148: while (!$buffer->isEmpty()) {
149: $playerName = $buffer->getString();
150:
151: if ($playerName === '') {
152: break;
153: }
154: $playerNames[] = $playerName;
155: }
156:
157: // Set basic server info
158: $this->servertitle = $serverDescription;
159: $this->mapname = $serverMap;
160: $this->maxplayers = $maxClients;
161: $this->numplayers = $nbConnectedClients;
162: $this->password = 0; // Not available in this response
163:
164: // Set game type
165: if (isset($this->modeNames[$gamemode])) {
166: $this->gametype = $this->modeNames[$gamemode];
167: }
168:
169: // Get detailed player stats if requested
170: if ($getPlayers && $nbConnectedClients > 0) {
171: $this->parsePlayerStats($queryPort);
172: }
173:
174: $this->online = true;
175:
176: return true;
177: }
178:
179: private function parsePlayerStats(int $queryPort): void
180: {
181: // Create UDP client for multi-packet queries
182: /** @var UdpClient $udpClient */
183: $udpClient = new UdpClient;
184: $udpClient->setTimeout(3); // 3 second timeout
185:
186: // Send EXT_PLAYERSTATS + \xff to get all player stats
187: $command = self::EXT_PLAYERSTATS . "\xff";
188: $packets = $udpClient->queryMultiPacket($this->address ?? '', $queryPort, $command, 0, 0.5); // 0.5s between packets
189:
190: if ($packets === []) {
191: return;
192: }
193:
194: if (!isset($packets[0])) {
195: return;
196: }
197:
198: // First packet should contain client numbers
199: $buffer = new CubeReadBuffer($packets[0]);
200:
201: // Skip extping_code (2 bytes)
202: $buffer->getUChar();
203: $buffer->getUChar();
204:
205: // Skip proto_version (3 bytes)
206: $buffer->getUChar();
207: $buffer->getUChar();
208: $buffer->getUChar();
209:
210: // EXT_PLAYERSTATS_RESP_IDS should be -10
211: $buffer->getUChar();
212: $respType2 = $buffer->getUChar();
213:
214: if ($respType2 !== 246) { // -10 as signed char
215: return; // Invalid response
216: }
217:
218: // Read client numbers
219: $clientNumbers = [];
220:
221: while (!$buffer->isEmpty()) {
222: try {
223: $clientNum = $buffer->getInt();
224: $clientNumbers[] = $clientNum;
225: } catch (Exception) {
226: break;
227: }
228: }
229:
230: // Remaining packets should be player data (skip first packet)
231: $players = [];
232: $packetIndex = 1;
233:
234: foreach ($clientNumbers as $clientNum) {
235: if ($packetIndex >= count($packets) || !array_key_exists($packetIndex, $packets)) {
236: break; // No more packets
237: }
238:
239: $playerResult = $packets[$packetIndex];
240: $packetIndex++;
241: $playerBuffer = new CubeReadBuffer($playerResult);
242:
243: try {
244: // Skip headers
245: $playerBuffer->getUChar(); // extping_code
246: $playerBuffer->getUChar();
247: $playerBuffer->getUChar(); // proto_version
248: $playerBuffer->getUChar();
249: $playerBuffer->getUChar();
250: $playerBuffer->getUChar(); // EXT_PLAYERSTATS_RESP_STATS
251: $playerBuffer->getUChar();
252:
253: $pClientNum = $playerBuffer->getInt();
254: $ping = $playerBuffer->getInt();
255: $name = $playerBuffer->getString();
256: $team = $playerBuffer->getString();
257: $frags = $playerBuffer->getInt();
258: $flags = $playerBuffer->getInt();
259: $deaths = $playerBuffer->getInt();
260: $teamkills = $playerBuffer->getInt();
261: $accuracy = $playerBuffer->getInt();
262: $health = $playerBuffer->getInt();
263: $armour = $playerBuffer->getInt();
264: $gun = $playerBuffer->getInt();
265: $role = $playerBuffer->getInt();
266: $state = $playerBuffer->getInt();
267:
268: // IP (3 bytes)
269: $ip1 = $playerBuffer->getUChar();
270: $ip2 = $playerBuffer->getUChar();
271: $ip3 = $playerBuffer->getUChar();
272: $ip = "{$ip1}.{$ip2}.{$ip3}.0";
273:
274: // Additional stats if available
275: $damage = -1;
276: $shotdamage = -1;
277:
278: if (!$playerBuffer->isEmpty()) {
279: $damage = $playerBuffer->getInt();
280: }
281:
282: if (!$playerBuffer->isEmpty()) {
283: $shotdamage = $playerBuffer->getInt();
284: }
285:
286: if ($name !== '' && $name !== '0') {
287: $players[] = [
288: 'name' => $name,
289: 'score' => $frags,
290: 'ping' => $ping,
291: 'team' => $team,
292: 'deaths' => $deaths,
293: 'health' => $health,
294: 'armour' => $armour,
295: 'accuracy' => $accuracy,
296: 'ip' => $ip,
297: 'state' => $this->stateNames[$state] ?? 'unknown',
298: 'role' => $role === 1 ? 'admin' : 'player',
299: 'damage' => $damage,
300: 'shotdamage' => $shotdamage,
301: ];
302: }
303: } catch (Exception) {
304: // Skip malformed packets
305: continue;
306: }
307: }
308:
309: $this->players = $players;
310: $this->playerkeys = [
311: 'name' => true,
312: 'score' => true,
313: 'ping' => true,
314: 'team' => true,
315: 'deaths' => true,
316: 'health' => true,
317: 'armour' => true,
318: 'accuracy' => true,
319: 'ip' => true,
320: 'state' => true,
321: 'role' => true,
322: 'damage' => true,
323: 'shotdamage' => true,
324: ];
325: }
326:
327: private function filterText(string $s): string
328: {
329: return preg_replace("/\f./", '', $s) ?? '';
330: }
331: }
332:
333: /**
334: * Cube Engine Read Buffer for parsing variable-length encoded data.
335: */
336: class CubeReadBuffer
337: {
338: private int $position;
339:
340: /**
341: * Constructor.
342: */
343: public function __construct(private string $data)
344: {
345: $this->position = 0;
346: }
347:
348: /**
349: * isEmpty method.
350: */
351: public function isEmpty(): bool
352: {
353: return $this->position >= strlen($this->data);
354: }
355:
356: /**
357: * hasMore method.
358: */
359: public function hasMore(): bool
360: {
361: return !$this->isEmpty();
362: }
363:
364: /**
365: * getUChar method.
366: */
367: public function getUChar(): int
368: {
369: if (!$this->hasMore()) {
370: throw new Exception('Message is too short');
371: }
372:
373: $char = $this->data[$this->position];
374: $uchar = ord($char);
375: $this->position++;
376:
377: return $uchar;
378: }
379:
380: /**
381: * getInt method.
382: */
383: public function getInt(): int
384: {
385: $b = $this->getUChar();
386:
387: if ($b === 0x80) {
388: // 16-bit value
389: $low = $this->getUChar();
390: $high = $this->getUChar();
391: $value = $low | ($high << 8);
392:
393: return $value < 0x8000 ? $value : $value - 0x10000;
394: }
395:
396: if ($b === 0x81) {
397: // 32-bit value
398: $b1 = $this->getUChar();
399: $b2 = $this->getUChar();
400: $b3 = $this->getUChar();
401: $b4 = $this->getUChar();
402: $value = $b1 | ($b2 << 8) | ($b3 << 16) | ($b4 << 24);
403:
404: return $value < 0x80000000 ? $value : $value - 0x100000000;
405: }
406:
407: // 8-bit value
408: return $b < 0x80 ? $b : $b - 0x100;
409: }
410:
411: /**
412: * getString method.
413: */
414: public function getString(): string
415: {
416: $startPosition = $this->position;
417:
418: while ($this->hasMore()) {
419: if ($this->getUChar() === 0) {
420: break;
421: }
422: }
423:
424: return substr($this->data, $startPosition, $this->position - $startPosition - 1);
425: }
426: }
427: