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 is_array;
17: use function min;
18: use function ord;
19: use function pack;
20: use function strlen;
21: use function substr;
22: use function unpack;
23: use Clansuite\Capture\Protocol\ProtocolInterface;
24: use Clansuite\Capture\ServerAddress;
25: use Clansuite\Capture\ServerInfo;
26: use Clansuite\ServerQuery\CSQuery;
27: use Override;
28:
29: /**
30: * Torque Game Engine protocol implementation.
31: *
32: * Based on Torque Game Engine server query protocol from serverQuery.cc
33: * Used by Tribes 2, Blockland, Age of Time, and other Torque-based games.
34: */
35: class Torque extends CSQuery implements ProtocolInterface
36: {
37: /**
38: * Protocol name.
39: */
40: public string $name = 'Torque';
41:
42: /**
43: * List of supported games.
44: *
45: * @var array<string>
46: */
47: public array $supportedGames = ['Torque'];
48:
49: /**
50: * Protocol identifier.
51: */
52: public string $protocol = 'Torque';
53:
54: /**
55: * Game series.
56: *
57: * @var array<string>
58: */
59: public array $game_series_list = ['Torque'];
60:
61: /**
62: * Constructor.
63: */
64: public function __construct(?string $address = null, ?int $queryport = null)
65: {
66: parent::__construct();
67: $this->address = $address;
68: $this->queryport = $queryport;
69: }
70:
71: /**
72: * query_server method.
73: */
74: #[Override]
75: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
76: {
77: if ($this->online) {
78: $this->reset();
79: }
80:
81: $address = (string) $this->address;
82: $port = (int) $this->queryport;
83:
84: // Send ping request first
85: $pingPacket = $this->createPingPacket();
86:
87: if (($pingResponse = $this->sendCommand($address, $port, $pingPacket)) === '' || ($pingResponse = $this->sendCommand($address, $port, $pingPacket)) === '0' || ($pingResponse = $this->sendCommand($address, $port, $pingPacket)) === false) {
88: return false;
89: }
90:
91: // Parse ping response to get server info
92: if (!$this->processPingResponse($pingResponse)) {
93: return false;
94: }
95:
96: // Send info request
97: $infoPacket = $this->createInfoPacket();
98:
99: if (($infoResponse = $this->sendCommand($address, $port, $infoPacket)) === '' || ($infoResponse = $this->sendCommand($address, $port, $infoPacket)) === '0' || ($infoResponse = $this->sendCommand($address, $port, $infoPacket)) === false) {
100: return false;
101: }
102:
103: // Parse info response
104: if (!$this->processInfoResponse($infoResponse)) {
105: return false;
106: }
107:
108: $this->online = true;
109:
110: return true;
111: }
112:
113: /**
114: * query method.
115: */
116: #[Override]
117: public function query(ServerAddress $addr): ServerInfo
118: {
119: $this->address = $addr->ip;
120: $this->queryport = $addr->port;
121: $this->query_server(true, true);
122:
123: return new ServerInfo(
124: address: $this->address,
125: queryport: $this->queryport,
126: online: $this->online,
127: gamename: $this->gamename,
128: gameversion: $this->gameversion,
129: servertitle: $this->servertitle,
130: mapname: $this->mapname,
131: gametype: $this->gametype,
132: numplayers: $this->numplayers,
133: maxplayers: $this->maxplayers,
134: rules: $this->rules,
135: players: $this->players,
136: errstr: $this->errstr,
137: );
138: }
139:
140: /**
141: * getProtocolName method.
142: */
143: #[Override]
144: public function getProtocolName(): string
145: {
146: return $this->protocol;
147: }
148:
149: /**
150: * getVersion method.
151: */
152: #[Override]
153: public function getVersion(ServerInfo $info): string
154: {
155: return $info->gameversion ?? 'unknown';
156: }
157:
158: private function createPingPacket(): string
159: {
160: // Based on Torque protocol: GamePingRequest packet
161: // Format: packet_type(1) + flags(1) + key(4)
162: $packetType = "\x02"; // GamePingRequest
163: $flags = "\x00"; // No special flags
164: $key = pack('N', 0); // Key (will be set by session)
165:
166: return $packetType . $flags . $key;
167: }
168:
169: private function createInfoPacket(): string
170: {
171: // Based on Torque protocol: GameInfoRequest packet
172: // Format: packet_type(1) + flags(1) + key(4)
173: $packetType = "\x04"; // GameInfoRequest
174: $flags = "\x00"; // No special flags
175: $key = pack('N', 0); // Key (will be set by session)
176:
177: return $packetType . $flags . $key;
178: }
179:
180: private function processPingResponse(string $buffer): bool
181: {
182: if (strlen($buffer) < 14) {
183: return false;
184: }
185:
186: // Parse ping response based on Torque protocol
187: // Format: packet_type(1) + flags(1) + key(4) + version_string + protocol_version(4) + min_protocol(4) + build_version(4) + server_name(24)
188:
189: $offset = 6; // Skip packet_type, flags, key
190:
191: // Read version string (null terminated)
192: $versionString = '';
193:
194: while ($offset < strlen($buffer) && ord($buffer[$offset]) !== 0) {
195: $versionString .= $buffer[$offset];
196: $offset++;
197: }
198: $offset++; // Skip null
199:
200: $this->gameversion = $versionString;
201:
202: // Read protocol versions
203: if ($offset + 8 <= strlen($buffer)) {
204: $tmp = @unpack('N', substr($buffer, $offset, 4));
205: $protocolVersion = is_array($tmp) && isset($tmp[1]) ? (int) $tmp[1] : 0;
206: $tmp = @unpack('N', substr($buffer, $offset + 4, 4));
207: $minProtocolVersion = is_array($tmp) && isset($tmp[1]) ? (int) $tmp[1] : 0;
208: $offset += 8;
209:
210: // Read build version
211: if ($offset + 4 <= strlen($buffer)) {
212: $tmp = @unpack('N', substr($buffer, $offset, 4));
213: $buildVersion = is_array($tmp) && isset($tmp[1]) ? (int) $tmp[1] : 0;
214: $offset += 4;
215:
216: // Read server name (up to 24 chars, null terminated)
217: $serverName = '';
218: $maxLen = min(24, strlen($buffer) - $offset);
219:
220: for ($i = 0; $i < $maxLen && ord($buffer[$offset + $i]) !== 0; $i++) {
221: $serverName .= $buffer[$offset + $i];
222: }
223:
224: $this->servertitle = $serverName;
225:
226: return true;
227: }
228: }
229:
230: return false;
231: }
232:
233: private function processInfoResponse(string $buffer): bool
234: {
235: if (strlen($buffer) < 10) {
236: return false;
237: }
238:
239: // Parse info response based on Torque protocol
240: // Format: packet_type(1) + flags(1) + key(4) + game_type + mission_type + mission_name + status(1) + num_players(1) + max_players(1) + num_bots(1) + cpu_speed(2) + ...
241:
242: $offset = 6; // Skip packet_type, flags, key
243:
244: // Read strings (null terminated)
245: $strings = [];
246:
247: for ($i = 0; $i < 3; $i++) { // game_type, mission_type, mission_name
248: $str = '';
249:
250: while ($offset < strlen($buffer) && ord($buffer[$offset]) !== 0) {
251: $str .= $buffer[$offset];
252: $offset++;
253: }
254: $offset++; // Skip null
255: $strings[] = $str;
256: }
257:
258: if (count($strings) >= 3) {
259: $this->gametype = $strings[0];
260:
261: /** @phpstan-ignore offsetAccess.notFound */
262: $this->rules['mission_type'] = $strings[1];
263:
264: /** @phpstan-ignore offsetAccess.notFound */
265: $this->mapname = $strings[2];
266: }
267:
268: // Read status byte
269: if ($offset < strlen($buffer)) {
270: $status = ord($buffer[$offset]);
271: $offset++;
272: $this->rules['status'] = $status;
273: }
274:
275: // Read player counts
276: if ($offset + 2 <= strlen($buffer)) {
277: $this->numplayers = ord($buffer[$offset]);
278: $this->maxplayers = ord($buffer[$offset + 1]);
279: $offset += 2;
280: }
281:
282: // Read bot count
283: if ($offset < strlen($buffer)) {
284: $this->rules['num_bots'] = ord($buffer[$offset]);
285: $offset++;
286: }
287:
288: // Read CPU speed
289: if ($offset + 2 <= strlen($buffer)) {
290: $tmp = @unpack('n', substr($buffer, $offset, 2));
291: $cpuSpeed = is_array($tmp) && isset($tmp[1]) ? (int) $tmp[1] : 0;
292: $this->rules['cpu_speed'] = $cpuSpeed;
293: $offset += 2;
294: }
295:
296: return true;
297: }
298: }
299: