1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * Clansuite Server Query
7: *
8: * SPDX-FileCopyrightText: 2003-2025 Jens A. Koch
9: * SPDX-License-Identifier: MIT
10: *
11: * For the full copyright and license information, please view
12: * the LICENSE file that was distributed with this source code.
13: */
14:
15: namespace Clansuite\ServerQuery\ServerProtocols;
16:
17: use function array_key_exists;
18: use function max;
19: use function ord;
20: use function strlen;
21: use function strpos;
22: use function substr;
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: * All-Seeing Eye protocol implementation.
31: *
32: * Used for Multi Theft Auto and other ASE-based games.
33: */
34: class Ase extends CSQuery implements ProtocolInterface
35: {
36: /**
37: * Protocol name.
38: */
39: public string $name = 'All-Seeing Eye';
40:
41: /**
42: * List of supported games.
43: *
44: * @var array<string>
45: */
46: public array $supportedGames = ['Multi Theft Auto'];
47:
48: /**
49: * Protocol identifier.
50: */
51: public string $protocol = 'ASE';
52:
53: /**
54: * Game series.
55: *
56: * @var array<string>
57: */
58: public array $game_series_list = ['ASE'];
59:
60: /**
61: * Constructor.
62: */
63: public function __construct(?string $address = null, ?int $queryport = null)
64: {
65: parent::__construct();
66:
67: if ($address !== null) {
68: $this->address = $address;
69: }
70:
71: if ($queryport !== null) {
72: $this->queryport = $queryport;
73: }
74: }
75:
76: /**
77: * query_server method.
78: */
79: #[Override]
80: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
81: {
82: if ($this->online) {
83: $this->reset();
84: }
85:
86: $command = 's';
87:
88: $address = $this->address ?? '';
89: $port = $this->queryport ?? 0;
90:
91: if (($result = $this->sendCommand($address, $port, $command)) === '' || ($result = $this->sendCommand($address, $port, $command)) === '0' || ($result = $this->sendCommand($address, $port, $command)) === false) {
92: $this->errstr = 'No reply received';
93:
94: return false;
95: }
96:
97: // Check for valid response
98: if (strlen($result) < 4) {
99: $this->errstr = 'Response too short';
100:
101: return false;
102: }
103:
104: // Read the header
105: $header = substr($result, 0, 4);
106:
107: if ($header !== 'EYE1') {
108: $this->errstr = 'Invalid header';
109:
110: return false;
111: }
112:
113: $buffer = substr($result, 4);
114:
115: // Read fixed header fields
116: $this->rules = [];
117: $gamename = $this->readLengthPrefixedString($buffer);
118: $port = $this->readLengthPrefixedString($buffer);
119: $servername = $this->readLengthPrefixedString($buffer);
120: $gametype = $this->readLengthPrefixedString($buffer);
121: $map = $this->readLengthPrefixedString($buffer);
122: $version = $this->readLengthPrefixedString($buffer);
123: $password = $this->readLengthPrefixedString($buffer);
124: $num_players = $this->readLengthPrefixedString($buffer);
125: $max_players = $this->readLengthPrefixedString($buffer);
126:
127: // Populate initial rule set from those fields so callers can access them via rules
128: if ($gamename !== '') {
129: $this->rules['gamename'] = $gamename;
130: }
131:
132: if ($port !== '') {
133: $this->rules['port'] = $port;
134: }
135:
136: if ($servername !== '') {
137: $this->rules['hostname'] = $servername;
138: }
139:
140: if ($gametype !== '') {
141: $this->rules['gametype'] = $gametype;
142: }
143:
144: if ($map !== '') {
145: $this->rules['map'] = $map;
146: }
147:
148: if ($version !== '') {
149: $this->rules['version'] = $version;
150: }
151:
152: if ($password !== '') {
153: $this->rules['password'] = $password;
154: }
155:
156: if ($num_players !== '') {
157: $this->rules['num_players'] = $num_players;
158: }
159:
160: if ($max_players !== '') {
161: $this->rules['max_players'] = $max_players;
162: }
163:
164: // Default dedicated flag
165: if (!array_key_exists('dedicated', $this->rules)) {
166: $this->rules['dedicated'] = 1;
167: }
168:
169: // Parse remaining key/value pairs
170: while ($buffer !== '') {
171: $key = $this->readLengthPrefixedString($buffer);
172:
173: if ($key === '' || $key === '0') {
174: break;
175: }
176: $value = $this->readLengthPrefixedString($buffer);
177: $this->rules[$key] = $value;
178: }
179:
180: // Parse players
181: $this->players = [];
182:
183: while ($buffer !== '') {
184: $flags = ord($buffer[0]);
185: $buffer = substr($buffer, 1);
186:
187: $player = [];
188:
189: if (($flags & 1) !== 0) {
190: $player['name'] = $this->readLengthPrefixedString($buffer);
191: }
192:
193: if (($flags & 2) !== 0) {
194: $player['team'] = $this->readLengthPrefixedString($buffer);
195: }
196:
197: if (($flags & 4) !== 0) {
198: $player['skin'] = $this->readLengthPrefixedString($buffer);
199: }
200:
201: if (($flags & 8) !== 0) {
202: $player['score'] = $this->readLengthPrefixedString($buffer);
203: }
204:
205: if (($flags & 16) !== 0) {
206: $player['ping'] = $this->readLengthPrefixedString($buffer);
207: }
208:
209: if (($flags & 32) !== 0) {
210: $player['time'] = $this->readLengthPrefixedString($buffer);
211: }
212:
213: if ($player !== []) {
214: $this->players[] = $player;
215: }
216: }
217:
218: // Set info from rules
219: $this->gamename = (string) ($this->rules['gamename'] ?? '');
220: $this->gametype = (string) ($this->rules['gametype'] ?? '');
221: $this->mapname = (string) ($this->rules['map'] ?? '');
222: $this->gameversion = (string) ($this->rules['version'] ?? '');
223: $this->servertitle = (string) ($this->rules['hostname'] ?? '');
224: $this->numplayers = (int) ($this->rules['num_players'] ?? 0);
225: $this->maxplayers = (int) ($this->rules['max_players'] ?? 0);
226: $this->password = (int) ($this->rules['password'] ?? 0);
227: $this->hostport = (int) ($this->rules['port'] ?? 0);
228:
229: $this->online = true;
230:
231: return true;
232: }
233:
234: /**
235: * query method.
236: */
237: #[Override]
238: public function query(ServerAddress $addr): ServerInfo
239: {
240: $this->address = $addr->ip;
241: $this->queryport = $addr->port;
242:
243: $this->query_server();
244:
245: $info = new ServerInfo;
246: $info->address = $addr->ip;
247: $info->queryport = $addr->port;
248: $info->online = $this->online;
249: $info->gamename = $this->gamename;
250: $info->gameversion = $this->gameversion;
251: $info->servertitle = $this->servertitle;
252: $info->mapname = $this->mapname;
253: $info->gametype = $this->gametype;
254: $info->numplayers = $this->numplayers;
255: $info->maxplayers = $this->maxplayers;
256: $info->players = $this->players;
257: $info->rules = $this->rules;
258: $info->errstr = $this->errstr;
259:
260: return $info;
261: }
262:
263: /**
264: * getProtocolName method.
265: */
266: #[Override]
267: public function getProtocolName(): string
268: {
269: return $this->protocol;
270: }
271:
272: /**
273: * getVersion method.
274: */
275: #[Override]
276: public function getVersion(ServerInfo $info): string
277: {
278: return $info->gameversion ?? 'unknown';
279: }
280:
281: private function readLengthPrefixedString(string &$buffer): string
282: {
283: // If buffer is empty, return empty string
284: if (!isset($buffer[0])) {
285: return '';
286: }
287:
288: $length = ord($buffer[0]);
289: // consume length byte
290: $buffer = substr($buffer, 1);
291:
292: // the strings include an extra trailing byte; callers expect length-1 content
293: $readLen = max($length - 1, 0);
294:
295: // if declared read length is larger than remaining buffer, clamp it
296: $remaining = strlen($buffer);
297:
298: if ($readLen > $remaining) {
299: $readLen = $remaining;
300: }
301:
302: $string = substr($buffer, 0, $readLen);
303: $nullPos = strpos($string, "\0");
304:
305: if ($nullPos !== false) {
306: $string = substr($string, 0, $nullPos);
307: }
308:
309: // advance buffer to the next length-prefixed string
310: $buffer = substr($buffer, $readLen);
311:
312: return $string;
313: }
314: }
315: