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 dechex;
17: use function is_array;
18: use function ord;
19: use function str_replace;
20: use function strlen;
21: use function substr;
22: use function unpack;
23: use Override;
24:
25: /**
26: * ARMA 3 protocol implementation.
27: *
28: * Based on Source engine protocol with ARMA 3 specific rules parsing.
29: *
30: * @see https://community.bistudio.com/wiki/Arma_3:_ServerBrowserProtocol2
31: */
32: class Arma3 extends Steam
33: {
34: /**
35: * Protocol name.
36: */
37: public string $name = 'ARMA 3';
38:
39: /**
40: * List of supported games.
41: *
42: * @var array<string>
43: */
44: public array $supportedGames = ['ARMA 3'];
45:
46: /**
47: * Protocol identifier.
48: */
49: public string $protocol = 'A2S';
50:
51: /**
52: * Game series.
53: *
54: * @var array<string>
55: */
56: public array $game_series_list = ['ArmA'];
57:
58: /**
59: * ARMA 3 uses query port = game port + 1.
60: */
61: protected int $port_diff = 1;
62:
63: /**
64: * Query server - override to handle ARMA 3 specific rules and players.
65: *
66: * @param bool $getPlayers whether to retrieve player information
67: * @param bool $getRules whether to retrieve server rules
68: *
69: * @return bool true on successful query, false otherwise
70: */
71: #[Override]
72: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
73: {
74: // Call parent with getPlayers = false, getRules = false, then handle separately
75: $result = parent::query_server(false, false);
76:
77: if (!$result) {
78: return false;
79: }
80:
81: if ($getRules) {
82: $this->query_rules();
83: }
84:
85: if ($getPlayers) {
86: $this->query_players();
87: }
88:
89: return true;
90: }
91:
92: /**
93: * Query ARMA 3 players.
94: */
95: private function query_players(): void
96: {
97: $players = $this->udpClient->queryPlayers($this->address ?? '', $this->queryport ?? 0);
98:
99: if ($players !== null) {
100: $this->players = [];
101: $this->playerkeys = [];
102:
103: foreach ($players as $player) {
104: if (is_array($player)) {
105: $playerData = [];
106:
107: foreach ($player as $key => $value) {
108: $playerData[$key] = $value;
109: $this->playerkeys[$key] = true;
110: }
111: $this->players[] = $playerData;
112: }
113: }
114: }
115: }
116:
117: /**
118: * Query ARMA 3 rules.
119: */
120: private function query_rules(): void
121: {
122: // Similar to players, get challenge first
123: $challengePacket = "\xFF\xFF\xFF\xFF\x56\xFF\xFF\xFF\xFF";
124: $challengeResponse = $this->udpClient->query($this->address ?? '', $this->queryport ?? 0, $challengePacket);
125:
126: if ($challengeResponse === null || strlen($challengeResponse) < 9) {
127: return;
128: }
129:
130: $challenge = substr($challengeResponse, 5, 4);
131:
132: // Query rules with challenge
133: $rulesPacket = "\xFF\xFF\xFF\xFF\x56" . $challenge;
134: $rulesResponse = $this->udpClient->query($this->address ?? '', $this->queryport ?? 0, $rulesPacket);
135:
136: if ($rulesResponse === null || strlen($rulesResponse) < 6) {
137: return;
138: }
139:
140: // Parse the rules response
141: $this->parse_arma3_rules($rulesResponse);
142: }
143:
144: /**
145: * Parse ARMA 3 rules response.
146: */
147: private function parse_arma3_rules(string $data): void
148: {
149: if (strlen($data) < 7) {
150: return;
151: }
152:
153: // Skip header (first 4 bytes, then type \x45)
154: $offset = 5;
155:
156: // In test, header is \x00\x00\x00\x00, so offset = 4
157: if (ord($data[0]) === 0 && ord($data[1]) === 0 && ord($data[2]) === 0 && ord($data[3]) === 0) {
158: $offset = 4;
159: }
160:
161: $numRulesData = unpack('v', substr($data, $offset, 2));
162:
163: if ($numRulesData === false) {
164: return;
165: }
166: $numRules = $numRulesData[1] ?? 0;
167: $offset += 2;
168:
169: for ($i = 0; $i < $numRules; $i++) {
170: // Read key until \x00
171: $key = '';
172:
173: while ($offset < strlen($data) && $data[$offset] !== "\x00") {
174: $key .= $data[$offset++];
175: }
176: $offset++; // skip \x00
177:
178: // Read value until \x00
179: $value = '';
180:
181: while ($offset < strlen($data) && $data[$offset] !== "\x00") {
182: $value .= $data[$offset++];
183: }
184: $offset++; // skip \x00
185:
186: if ($key === '01') {
187: // This is the binary chunk, unescape and parse
188: $unescaped = $this->unescape($value);
189: $this->parse_arma3_binary_data($unescaped);
190: } else {
191: // Regular rule
192: $this->rules[$key] = $value;
193: }
194: }
195: }
196:
197: /**
198: * Parse ARMA 3 binary rules data.
199: */
200: private function parse_arma3_binary_data(string $data): void
201: {
202: if (strlen($data) < 6) {
203: return;
204: }
205:
206: $offset = 0;
207:
208: $dlcByte = ord($data[$offset++]);
209: $dlcByte2 = ord($data[$offset++]);
210: $dlcBits = ($dlcByte2 << 8) | $dlcByte;
211:
212: $difficulty = ord($data[$offset++]);
213:
214: $this->rules['3rd_person'] = $difficulty >> 7;
215: $this->rules['advanced_flight_mode'] = ($difficulty >> 6) & 1;
216: $this->rules['difficulty_ai'] = ($difficulty >> 3) & 3;
217: $this->rules['difficulty_level'] = $difficulty & 3;
218:
219: $crosshair = ord($data[$offset++]);
220: $this->rules['crosshair'] = $crosshair;
221:
222: // DLC flags
223: $dlcFlags = [
224: 0x01 => 'Karts',
225: // Add more if needed
226: ];
227:
228: $this->rules['dlcs'] = [];
229:
230: foreach ($dlcFlags as $flag => $name) {
231: if (($dlcBits & $flag) === $flag) {
232: $this->rules['dlcs'][] = $name;
233: // Skip hash if present - but in test, not present
234: // if ($offset + 4 <= strlen($data)) {
235: // $offset += 4;
236: // }
237: }
238: }
239:
240: $modCount = ord($data[$offset++]);
241: $this->rules['mod_count'] = $modCount;
242:
243: $this->rules['mods'] = [];
244:
245: for ($i = 0; $i < $modCount; $i++) {
246: if ($offset + 4 > strlen($data)) {
247: break;
248: }
249: $hashData = unpack('N', substr($data, $offset, 4));
250:
251: if ($hashData === false) {
252: break;
253: }
254: $hash = $hashData[1] ?? 0;
255: $offset += 4;
256: $this->rules['mods'][] = ['hash' => dechex($hash)];
257:
258: if ($offset >= strlen($data)) {
259: break;
260: }
261: $infoByte = ord($data[$offset++]);
262: $isDlc = ($infoByte & 0b00010000) === 0b00010000;
263: $steamIdLen = $infoByte & 0x0F;
264:
265: if ($offset + 4 > strlen($data)) {
266: break;
267: }
268: $steamIdData = unpack('N', substr($data, $offset, 4));
269:
270: if ($steamIdData === false) {
271: break;
272: }
273: $steamId = $steamIdData[1] ?? 0;
274:
275: if ($steamIdLen > 0) {
276: $steamId &= ((1 << ($steamIdLen * 8)) - 1);
277: }
278: $offset += 4;
279:
280: if ($offset >= strlen($data)) {
281: break;
282: }
283: $nameLen = ord($data[$offset++]);
284:
285: if ($offset + $nameLen > strlen($data)) {
286: break;
287: }
288: $name = substr($data, $offset, $nameLen);
289: $offset += $nameLen;
290:
291: $this->rules['mods'][count($this->rules['mods']) - 1]['dlc'] = $isDlc;
292: $this->rules['mods'][count($this->rules['mods']) - 1]['steam_id'] = $steamId;
293: $this->rules['mods'][count($this->rules['mods']) - 1]['name'] = $name;
294: }
295:
296: if ($offset >= strlen($data)) {
297: return;
298: }
299: $signatureCount = ord($data[$offset++]);
300: $this->rules['signature_count'] = $signatureCount;
301:
302: $this->rules['signatures'] = [];
303:
304: for ($i = 0; $i < $signatureCount; $i++) {
305: if ($offset >= strlen($data)) {
306: break;
307: }
308: $sigLen = ord($data[$offset++]);
309:
310: if ($offset + $sigLen > strlen($data)) {
311: break;
312: }
313: $sig = substr($data, $offset, $sigLen);
314: $offset += $sigLen;
315: $this->rules['signatures'][] = $sig;
316: }
317: }
318:
319: /**
320: * Unescape ARMA 3 sequences.
321: */
322: private function unescape(string $data): string
323: {
324: return str_replace(["\x01\x01", "\x01\x02", "\x01\x03"], ["\x01", "\x00", "\xFF"], $data);
325: }
326: }
327: