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 is_array;
16: use function ord;
17: use function strlen;
18: use function strpos;
19: use function substr;
20: use function unpack;
21: use Clansuite\Capture\Protocol\ProtocolInterface;
22: use Exception;
23: use Override;
24:
25: /**
26: * Tribes 2 protocol implementation.
27: *
28: * Tribes 2 uses the Torque Game Engine protocol.
29: */
30: class Tribes2 extends Torque implements ProtocolInterface
31: {
32: /**
33: * Protocol name.
34: */
35: public string $name = 'Tribes 2';
36:
37: /**
38: * List of supported games.
39: *
40: * @var array<string>
41: */
42: public array $supportedGames = ['Tribes 2'];
43:
44: /**
45: * Protocol identifier.
46: */
47: public string $protocol = 'Tribes2';
48:
49: /**
50: * Game series.
51: *
52: * @var array<string>
53: */
54: public array $game_series_list = ['Tribes'];
55:
56: /**
57: * Returns a native join URI for Tribes 2 or false if not available.
58: */
59: #[Override]
60: public function getNativeJoinURI(): string
61: {
62: // Tribes 2 uses tribes2:// protocol for joining servers
63: return 'tribes2://' . ($this->address ?? '') . ':' . ($this->hostport ?? 0);
64: }
65:
66: /**
67: * Query the Tribes 2 server.
68: *
69: * Tribes 2 uses a different packet format than the general Torque protocol.
70: * Based on LGSL protocol 25 implementation.
71: */
72: #[Override]
73: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
74: {
75: if ($this->online) {
76: $this->reset();
77: }
78:
79: $address = $this->address ?? '';
80: $port = $this->queryport ?? 0;
81:
82: // Tribes 2 uses a simple packet format: \x12\x02\x21\x21\x21\x21
83: $packet = "\x12\x02\x21\x21\x21\x21";
84:
85: if (($response = $this->sendCommand($address, $port, $packet)) === '' || ($response = $this->sendCommand($address, $port, $packet)) === '0' || ($response = $this->sendCommand($address, $port, $packet)) === false) {
86: $this->errstr = 'No response from server';
87:
88: return false;
89: }
90:
91: // Remove the 6-byte header from response
92: $buffer = substr($response, 6);
93:
94: if ($buffer === '' || $buffer === '0') {
95: $this->errstr = 'Invalid response from server';
96:
97: return false;
98: }
99:
100: // Parse the response using LGSL-style parsing
101: return $this->parseTribes2Response($buffer);
102: }
103:
104: /**
105: * Parse Tribes 2 server response.
106: *
107: * Based on LGSL protocol 25 parsing.
108: */
109: private function parseTribes2Response(string $buffer): bool
110: {
111: try {
112: // Game name
113: $this->gamename = $this->cutPascalString($buffer);
114:
115: // Game mode
116: $gamemode = $this->cutPascalString($buffer);
117: $this->gametype = $gamemode;
118:
119: // Map name
120: $this->mapname = $this->cutPascalString($buffer);
121:
122: // Bit flags
123: $bitFlags = ord($this->cutByte($buffer, 1));
124:
125: // Player counts
126: $this->numplayers = ord($this->cutByte($buffer, 1));
127: $this->maxplayers = ord($this->cutByte($buffer, 1));
128:
129: // Bots
130: $bots = ord($this->cutByte($buffer, 1));
131:
132: // CPU speed
133: $cpuUnpacked = unpack('S', $this->cutByte($buffer, 2));
134: $cpu = is_array($cpuUnpacked) && isset($cpuUnpacked[1]) ? $cpuUnpacked[1] : 0;
135:
136: // MOTD
137: $motd = $this->cutPascalString($buffer);
138:
139: // Unknown field
140: $unknownUnpacked = unpack('S', $this->cutByte($buffer, 2));
141: $unknown = is_array($unknownUnpacked) && isset($unknownUnpacked[1]) ? $unknownUnpacked[1] : 0;
142:
143: // Parse bit flags
144: $this->rules['dedicated'] = (($bitFlags & 1) !== 0) ? '1' : '0';
145: $this->password = (($bitFlags & 2) !== 0) ? 1 : 0;
146: $this->rules['os'] = (($bitFlags & 4) !== 0) ? 'L' : 'W';
147: $this->rules['tournament'] = (($bitFlags & 8) !== 0) ? '1' : '0';
148: $this->rules['no_alias'] = (($bitFlags & 16) !== 0) ? '1' : '0';
149:
150: // Additional rules
151: $this->rules['bots'] = $bots;
152: $this->rules['cpu'] = $cpu;
153: $this->rules['motd'] = $motd;
154:
155: // Skip team data for now (marked by \x0A)
156: $teamData = $this->cutString($buffer, "\x0A");
157: // TODO: Parse team data if needed
158:
159: // Player data
160: $playerCount = (int) $this->cutString($buffer, "\x0A");
161:
162: for ($i = 0; $i < $playerCount; $i++) {
163: // Skip some unknown bytes
164: $this->cutByte($buffer, 1); // ? 16
165: $this->cutByte($buffer, 1); // ? 8 or 14 = BOT / 12 = ALIAS / 11 = NORMAL
166:
167: if (ord($buffer[0]) < 32) {
168: $this->cutByte($buffer, 1); // ? 8 PREFIXES SOME NAMES
169: }
170:
171: $playerName = $this->cutString($buffer, "\x11");
172: $this->cutString($buffer, "\x09"); // ALWAYS BLANK
173: $team = $this->cutString($buffer, "\x09");
174: $score = $this->cutString($buffer, "\x0A");
175:
176: $this->players[$i] = [
177: 'name' => $playerName,
178: 'team' => $team,
179: 'score' => (int) $score,
180: ];
181: }
182:
183: $this->online = true;
184:
185: return true;
186: } catch (Exception $e) {
187: $this->errstr = 'Failed to parse server response: ' . $e->getMessage();
188:
189: return false;
190: }
191: }
192:
193: /**
194: * Cut a pascal string (length prefixed).
195: */
196: private function cutPascalString(string &$buffer): string
197: {
198: $length = ord($this->cutByte($buffer, 1));
199:
200: return $this->cutByte($buffer, $length);
201: }
202:
203: /**
204: * Cut a byte string.
205: */
206: private function cutByte(string &$buffer, int $length): string
207: {
208: $result = substr($buffer, 0, $length);
209: $buffer = substr($buffer, $length);
210:
211: return $result;
212: }
213:
214: /**
215: * Cut string until delimiter.
216: */
217: private function cutString(string &$buffer, string $delimiter): string
218: {
219: $pos = strpos($buffer, $delimiter);
220:
221: if ($pos === false) {
222: $result = $buffer;
223: $buffer = '';
224:
225: return $result;
226: }
227:
228: $result = substr($buffer, 0, $pos);
229: $buffer = substr($buffer, $pos + strlen($delimiter));
230:
231: return $result;
232: }
233: }
234: