Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tribes2
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
650
0.00% covered (danger)
0.00%
0 / 1
 getNativeJoinURI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 query_server
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 parseTribes2Response
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
182
 cutPascalString
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cutByte
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 cutString
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
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 is_array;
16use function ord;
17use function strlen;
18use function strpos;
19use function substr;
20use function unpack;
21use Clansuite\Capture\Protocol\ProtocolInterface;
22use Exception;
23use Override;
24
25/**
26 * Tribes 2 protocol implementation.
27 *
28 * Tribes 2 uses the Torque Game Engine protocol.
29 */
30class 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}