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 substr;
18: use function unpack;
19: use Clansuite\Capture\Protocol\ProtocolInterface;
20: use Clansuite\Capture\ServerAddress;
21: use Clansuite\Capture\ServerInfo;
22: use Clansuite\ServerQuery\CSQuery;
23: use Override;
24:
25: /**
26: * Unreal 2 Protocol implementation.
27: *
28: * Base protocol for Unreal Engine 2 games like Killing Floor.
29: */
30: class Unreal2 extends CSQuery implements ProtocolInterface
31: {
32: /**
33: * Protocol name.
34: */
35: public string $name = 'Unreal2';
36:
37: /**
38: * List of supported games.
39: *
40: * @var array<string>
41: */
42: public array $supportedGames = ['Unreal2', 'KillingFloor'];
43:
44: /**
45: * Protocol identifier.
46: */
47: public string $protocol = 'Unreal2';
48:
49: /**
50: * Game series.
51: *
52: * @var array<string>
53: */
54: public array $game_series_list = ['Unreal Tournament'];
55: public ?int $serverID = null;
56:
57: /**
58: * Constructor.
59: */
60: public function __construct(?string $address = null, ?int $queryport = null)
61: {
62: parent::__construct();
63: $this->address = $address;
64: $this->queryport = $queryport;
65: }
66:
67: /**
68: * query_server method.
69: */
70: #[Override]
71: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
72: {
73: if ($this->online) {
74: $this->reset();
75: }
76:
77: $address = (string) $this->address;
78: $port = (int) $this->queryport;
79:
80: // Send details query
81: $command = "\x79\x00\x00\x00\x00"; // Details packet
82:
83: if (($result = $this->sendCommand($address, $port, $command)) === '' || ($result = $this->sendCommand($address, $port, $command)) === '0' || ($result = $this->sendCommand($address, $port, $command)) === false) {
84: return false;
85: }
86:
87: $this->processDetails($result);
88: // Send rules query if requested
89: $command = "\x79\x00\x00\x00\x01";
90:
91: // Rules packet
92: if (($result = $this->sendCommand($address, $port, $command)) !== false) {
93: $this->processRules($result);
94: }
95: // Send players query if requested
96: $command = "\x79\x00\x00\x00\x02";
97:
98: // Players packet
99: if (($result = $this->sendCommand($address, $port, $command)) !== false) {
100: $this->processPlayers($result);
101: }
102:
103: $this->online = true;
104:
105: return true;
106: }
107:
108: /**
109: * query method.
110: */
111: #[Override]
112: public function query(ServerAddress $addr): ServerInfo
113: {
114: $this->address = $addr->ip;
115: $this->queryport = $addr->port;
116: $this->query_server(true, true);
117:
118: return new ServerInfo(
119: address: $this->address,
120: queryport: $this->queryport,
121: online: $this->online,
122: gamename: $this->gamename,
123: gameversion: $this->gameversion,
124: servertitle: $this->servertitle,
125: mapname: $this->mapname,
126: gametype: $this->gametype,
127: numplayers: $this->numplayers,
128: maxplayers: $this->maxplayers,
129: rules: $this->rules,
130: players: $this->players,
131: errstr: $this->errstr,
132: );
133: }
134:
135: /**
136: * getProtocolName method.
137: */
138: #[Override]
139: public function getProtocolName(): string
140: {
141: return $this->protocol;
142: }
143:
144: /**
145: * getVersion method.
146: */
147: #[Override]
148: public function getVersion(ServerInfo $info): string
149: {
150: return $info->gameversion ?? 'unknown';
151: }
152:
153: /**
154: * _processDetails method.
155: */
156: protected function processDetails(string $data): void
157: {
158: // Skip header (5 bytes)
159: $data = substr($data, 5);
160:
161: // Server ID (4 bytes)
162: $tmp = @unpack('V', substr($data, 0, 4));
163:
164: if (!is_array($tmp) || !isset($tmp[1])) {
165: // leave serverID null on malformed response
166: $this->serverID = null;
167: } else {
168: $this->serverID = $tmp[1];
169: }
170: $data = substr($data, 4);
171:
172: // Server IP (pascal string, skip)
173: $len = ord($data[0] ?? "\0");
174: $data = substr($data, 1 + $len);
175:
176: // Game port (4 bytes)
177: $tmp = @unpack('V', substr($data, 0, 4));
178:
179: if (!is_array($tmp) || !isset($tmp[1])) {
180: // malformed, default to 0
181: $this->hostport = 0;
182: } else {
183: $this->hostport = (int) $tmp[1];
184: }
185: $data = substr($data, 4);
186:
187: // Query port (4 bytes, skip)
188: $data = substr($data, 4);
189:
190: // Server name (pascal string)
191: $len = ord($data[0] ?? "\0");
192: $this->servertitle = substr($data, 1, $len);
193: $data = substr($data, 1 + $len);
194:
195: // Map name (pascal string)
196: $len = ord($data[0] ?? "\0");
197: $this->mapname = substr($data, 1, $len);
198: $data = substr($data, 1 + $len);
199:
200: // Game type (pascal string)
201: $len = ord($data[0] ?? "\0");
202: $this->gametype = substr($data, 1, $len);
203: $data = substr($data, 1 + $len);
204:
205: // Num players (4 bytes)
206: $tmp = @unpack('V', substr($data, 0, 4));
207:
208: if (!is_array($tmp) || !isset($tmp[1])) {
209: $this->numplayers = 0;
210: } else {
211: $this->numplayers = (int) $tmp[1];
212: }
213: $data = substr($data, 4);
214:
215: // Max players (4 bytes)
216: $tmp = @unpack('V', substr($data, 0, 4));
217:
218: if (!is_array($tmp) || !isset($tmp[1])) {
219: $this->maxplayers = 0;
220: } else {
221: $this->maxplayers = (int) $tmp[1];
222: }
223:
224: // Ping (4 bytes, skip)
225: // $data = substr($data, 4);
226: }
227:
228: /**
229: * _processRules method.
230: */
231: protected function processRules(string $data): void
232: {
233: // Skip header (5 bytes)
234: $data = substr($data, 5);
235:
236: while ($data !== '') {
237: // Key (pascal string)
238: $len = ord($data[0] ?? "\0");
239: $key = substr($data, 1, $len);
240: $data = substr($data, 1 + $len);
241:
242: // Value (pascal string)
243: $len = ord($data[0] ?? "\0");
244: $value = substr($data, 1, $len);
245: $data = substr($data, 1 + $len);
246:
247: $this->rules[$key] = $value;
248: }
249: }
250:
251: /**
252: * _processPlayers method.
253: */
254: protected function processPlayers(string $data): void
255: {
256: // Skip header (5 bytes)
257: $data = substr($data, 5);
258:
259: $this->players = [];
260:
261: while ($data !== '') {
262: // Player ID (4 bytes)
263: $tmp = @unpack('V', substr($data, 0, 4));
264:
265: if (!is_array($tmp) || !isset($tmp[1])) {
266: // malformed, stop parsing players
267: break;
268: }
269: $id = (int) $tmp[1];
270: $data = substr($data, 4);
271:
272: if ($id === 0) {
273: break; // End of players
274: }
275:
276: // Player name (pascal string)
277: $len = ord($data[0] ?? "\0");
278: $name = substr($data, 1, $len);
279: $data = substr($data, 1 + $len);
280:
281: // Ping (4 bytes)
282: $tmp = @unpack('V', substr($data, 0, 4));
283: $ping = (is_array($tmp) && isset($tmp[1])) ? (int) $tmp[1] : 0;
284: $data = substr($data, 4);
285:
286: // Score (4 bytes)
287: $tmp = @unpack('V', substr($data, 0, 4));
288: $score = (is_array($tmp) && isset($tmp[1])) ? (int) $tmp[1] : 0;
289: $data = substr($data, 4);
290:
291: // Skip 4 unknown bytes
292: $data = substr($data, 4);
293:
294: $this->players[] = [
295: 'name' => $name,
296: 'ping' => $ping,
297: 'score' => $score,
298: ];
299: }
300: }
301: }
302: