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 array_shift;
16: use function chr;
17: use function count;
18: use function explode;
19: use function fclose;
20: use function fread;
21: use function fsockopen;
22: use function fwrite;
23: use function in_array;
24: use function is_array;
25: use function is_numeric;
26: use function is_string;
27: use function pack;
28: use function str_contains;
29: use function str_starts_with;
30: use function stream_set_blocking;
31: use function stream_set_timeout;
32: use function strlen;
33: use function substr;
34: use function time;
35: use function unpack;
36: use Clansuite\Capture\Protocol\ProtocolInterface;
37: use Clansuite\Capture\ServerAddress;
38: use Clansuite\Capture\ServerInfo;
39: use Clansuite\ServerQuery\CSQuery;
40: use Override;
41:
42: /**
43: * Battlefield 3 / 4 / Hardline (Frostbite) Server Query Class.
44: */
45: class Bf3 extends CSQuery implements ProtocolInterface
46: {
47: /**
48: * Real game host (may differ from query host).
49: */
50: public ?string $gameHost = null;
51:
52: /**
53: * Real game port (may differ from query port).
54: */
55: public ?int $gamePort = null;
56:
57: /**
58: * Protocol name.
59: */
60: public string $name = 'Battlefield 3';
61:
62: /**
63: * List of supported games.
64: *
65: * @var array<string>
66: */
67: public array $supportedGames = ['Battlefield 3', 'Battlefield 4', 'Battlefield: Hardline'];
68:
69: /**
70: * Protocol identifier.
71: */
72: public string $protocol = 'Frostbite';
73:
74: /**
75: * Game series.
76: *
77: * @var array<string>
78: */
79: public array $game_series_list = ['Battlefield'];
80: protected int $port_diff = 22000;
81:
82: /**
83: * Constructor.
84: */
85: public function __construct(?string $address = null, ?int $queryport = null)
86: {
87: parent::__construct();
88: $this->address = $address ?? '';
89: $this->queryport = $queryport ?? 0;
90: }
91:
92: /**
93: * Returns a native join URI for BF4.
94: */
95: #[Override]
96: public function getNativeJoinURI(): string
97: {
98: return 'bf4://' . $this->address . ':' . $this->hostport;
99: }
100:
101: /**
102: * query_server method.
103: */
104: #[Override]
105: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
106: {
107: if ($this->online) {
108: $this->reset();
109: }
110:
111: $queryPort = ($this->queryport ?? 0) + $this->port_diff;
112:
113: // Attempt TCP query to client_port + 22000 (BF3/BF4 convention)
114: $errno = 0;
115: $errstr = '';
116: $address = $this->address ?? '';
117: $fp = @fsockopen($address, $queryPort, $errno, $errstr, 5);
118:
119: if ($fp === false) {
120: $this->errstr = 'Unable to open TCP socket to BF4 query port';
121:
122: return false;
123: }
124: stream_set_blocking($fp, true);
125: stream_set_timeout($fp, 5);
126:
127: // serverInfo
128: $info = $this->tcpQuery($fp, ['serverInfo']);
129:
130: if ($info === false) {
131: fclose($fp);
132: $this->errstr = 'No BF4 serverInfo response';
133:
134: return false;
135: }
136:
137: // serverInfo: first element should be 'OK'
138: if (!isset($info[0]) || $info[0] !== 'OK') {
139: // not a valid response
140: fclose($fp);
141: $this->errstr = 'Invalid BF4 serverInfo response';
142:
143: return false;
144: }
145:
146: // parse fields following node-gamedig logic
147: $st = $info[0] ?? null;
148: $this->servertitle = is_string($st) ? $st : '';
149:
150: $np = $info[1] ?? null;
151:
152: if (is_numeric($np)) {
153: $this->numplayers = (int) $np;
154: } else {
155: $this->numplayers = 0;
156: }
157:
158: $mp = $info[2] ?? null;
159:
160: if (is_numeric($mp)) {
161: $this->maxplayers = (int) $mp;
162: } else {
163: $this->maxplayers = 0;
164: }
165:
166: $gt = $info[3] ?? null;
167: $this->gametype = is_string($gt) ? $gt : '';
168:
169: $mn = $info[4] ?? null;
170: $this->mapname = is_string($mn) ? $mn : '';
171:
172: $idx = 5;
173: $idx++;
174: $idx++;
175:
176: if (isset($info[$idx]) && is_numeric($info[$idx])) {
177: $teamCount = (int) $info[$idx];
178: } else {
179: $teamCount = 0;
180: }
181: $idx++;
182: $this->playerteams = [];
183:
184: for ($i = 0; $i < $teamCount; $i++) {
185: if (isset($info[$idx]) && is_numeric($info[$idx])) {
186: $tickets = (float) $info[$idx];
187: } else {
188: $tickets = 0.0;
189: }
190: $this->playerteams[] = ['tickets' => $tickets];
191: $idx++;
192: }
193:
194: if (isset($info[$idx]) && is_numeric($info[$idx])) {
195: $this->rules['targetscore'] = (int) $info[$idx];
196: } else {
197: $this->rules['targetscore'] = 0;
198: }
199: $idx++;
200: $this->rules['status'] = isset($info[$idx]) && is_string($info[$idx]) ? $info[$idx] : null;
201: $idx++;
202:
203: // optional fields (ranked, punkbuster, password, uptime, roundtime)
204: if (isset($info[$idx])) {
205: $this->rules['isRanked'] = ($info[$idx] === 'true');
206: }
207: $idx++;
208:
209: if (isset($info[$idx])) {
210: $this->rules['punkbuster'] = ($info[$idx] === 'true');
211: }
212: $idx++;
213:
214: if (isset($info[$idx])) {
215: $this->password = ($info[$idx] === 'true') ? 1 : 0;
216: }
217: $idx++;
218:
219: if (isset($info[$idx])) {
220: if (is_numeric($info[$idx])) {
221: $this->rules['serveruptime'] = (int) $info[$idx];
222: } else {
223: $this->rules['serveruptime'] = 0;
224: }
225: }
226: $idx++;
227:
228: if (isset($info[$idx])) {
229: if (is_numeric($info[$idx])) {
230: $this->rules['roundTime'] = (int) $info[$idx];
231: } else {
232: $this->rules['roundTime'] = 0;
233: }
234: }
235: $idx++;
236:
237: // try to read ip:port
238: if (isset($info[$idx]) && is_string($info[$idx]) && str_contains($info[$idx], ':')) {
239: $this->rules['ip'] = $info[$idx];
240: $exploded = explode(':', $info[$idx]);
241:
242: /** @var array{0: string, 1: string} $exploded */
243: $host = $exploded[0];
244: $port = $exploded[1];
245: $this->gameHost = $host;
246: $this->gamePort = is_numeric($port) ? (int) $port : 0;
247: $idx++;
248: }
249:
250: // version
251: $ver = $this->tcpQuery($fp, ['version']);
252:
253: if (is_array($ver) && count($ver) >= 2 && isset($ver[0]) && $ver[0] === 'OK') {
254: $this->gameversion = isset($ver[1]) && is_string($ver[1]) ? $ver[1] : '';
255: }
256: // players
257: $players = $this->tcpQuery($fp, ['listPlayers', 'all']);
258:
259: if (is_array($players) && count($players) > 0 && ($first = array_shift($players)) === 'OK') {
260: $fieldCount = isset($players[0]) && is_numeric($players[0]) ? (int) $players[0] : 0;
261: $pos = 1;
262:
263: /** @var array<string> $fields */
264: $fields = [];
265:
266: for ($i = 0; $i < $fieldCount; $i++, $pos++) {
267: $fields[] = isset($players[$pos]) && is_string($players[$pos]) ? $players[$pos] : '';
268: }
269: $pos++;
270: $numplayers = isset($players[$pos - 1]) && is_numeric($players[$pos - 1]) ? (int) $players[$pos - 1] : 0;
271: $this->players = [];
272:
273: for ($i = 0; $i < $numplayers; $i++) {
274: /** @var array<string, mixed> $player */
275: $player = [];
276:
277: foreach ($fields as $key) {
278: $val = $players[$pos] ?? null;
279:
280: // numeric fields -> cast
281: if (in_array($key, ['kills', 'deaths', 'score', 'rank', 'team', 'squad', 'ping', 'type'], true)) {
282: if (is_numeric($val)) {
283: $val = (int) $val;
284: } else {
285: $val = 0;
286: }
287: }
288:
289: // normalize sentinel ping values (65535 and other very large values) to 0
290: if ($key === 'ping' && is_numeric($val)) {
291: $ival = (int) $val;
292:
293: if ($ival >= 60000) {
294: $val = 0;
295: } else {
296: $val = $ival;
297: }
298: }
299:
300: $player[$key] = $val;
301: $pos++;
302: }
303: $this->players[] = $player;
304: }
305: }
306:
307: fclose($fp);
308:
309: return true;
310: }
311:
312: /**
313: * Public helper to parse a captured binary blob (one or more BF packets).
314: * Returns structured data: serverInfoParams, serverInfo (mapped), players, rawPackets.
315: *
316: * @param string $data raw captured binary data
317: *
318: * @return array<string,mixed>
319: */
320: public function parseCaptured(string $data): array
321: {
322: $ptr = 0;
323: $len = strlen($data);
324:
325: /** @var string[] $packets */
326: $packets = [];
327:
328: while ($ptr + 8 <= $len) {
329: $unpacked = unpack('V', substr($data, $ptr + 4, 4));
330:
331: if ($unpacked === false) {
332: break;
333: }
334:
335: /** @var array{1: int} $unpacked */
336: $totalLength = $unpacked[1];
337:
338: if ($totalLength <= 0 || ($ptr + $totalLength) > $len) {
339: break;
340: }
341:
342: $packet = substr($data, $ptr, $totalLength);
343: $params = $this->decodePacket($packet);
344:
345: if ($params !== false) {
346: $packets[] = $params;
347: }
348: $ptr += $totalLength;
349: }
350:
351: $result = [
352: 'rawPackets' => $packets,
353: 'serverInfoParams' => null,
354: 'serverInfo' => [],
355: 'players' => [],
356: 'rules' => [],
357: ];
358:
359: if (count($packets) === 0) {
360: return $result;
361: }
362:
363: // serverInfo is usually the first packet
364: /** @var string $si */
365: /** @phpstan-ignore-next-line offsetAccess.notFound */
366: $si = $packets[0];
367: $result['serverInfoParams'] = $si;
368:
369: if ($si !== '' && str_starts_with($si, 'OK')) {
370: $info = explode("\t", $si);
371: $mapped = [];
372: $st = $info[1] ?? null;
373: $mapped['servertitle'] = $st ?? '';
374:
375: $np = $info[2] ?? null;
376: $mapped['numplayers'] = (int) ($np ?? 0);
377:
378: $mp = $info[3] ?? null;
379: $mapped['maxplayers'] = (int) ($mp ?? 0);
380:
381: $gt = $info[4] ?? null;
382: $mapped['gametype'] = $gt ?? '';
383:
384: $mn = $info[5] ?? null;
385: $mapped['mapname'] = is_string($mn) ? $mn : '';
386:
387: $idx = 6;
388: $mapped['roundsplayed'] = isset($info[$idx]) && is_numeric($info[$idx]) ? (int) $info[$idx] : 0;
389: $idx++;
390: $mapped['roundstotal'] = isset($info[$idx]) && is_numeric($info[$idx]) ? (int) $info[$idx] : 0;
391: $idx++;
392:
393: $teamCount = isset($info[$idx]) && is_numeric($info[$idx]) ? (int) $info[$idx] : 0;
394: $idx++;
395: $mapped['teams'] = [];
396:
397: for ($i = 0; $i < $teamCount; $i++) {
398: $mapped['teams'][] = isset($info[$idx]) && is_numeric($info[$idx]) ? (float) $info[$idx] : 0.0;
399: $idx++;
400: }
401:
402: $mapped['targetscore'] = isset($info[$idx]) && is_numeric($info[$idx]) ? (int) $info[$idx] : 0;
403: $idx++;
404: $mapped['status'] = $info[$idx] ?? null;
405: $idx++;
406:
407: // optional flags
408: if (isset($info[$idx])) {
409: $mapped['ranked'] = ($info[$idx] === 'true');
410: } $idx++;
411:
412: if (isset($info[$idx])) {
413: $mapped['punkbuster'] = ($info[$idx] === 'true');
414: } $idx++;
415:
416: if (isset($info[$idx])) {
417: $mapped['password'] = ($info[$idx] === 'true');
418: } $idx++;
419:
420: if (isset($info[$idx]) && is_numeric($info[$idx])) {
421: $mapped['uptime'] = (int) $info[$idx];
422: } $idx++;
423:
424: if (isset($info[$idx]) && is_numeric($info[$idx])) {
425: $mapped['roundtime'] = (int) $info[$idx];
426: } $idx++;
427:
428: if (isset($info[$idx]) && is_string($info[$idx]) && str_contains($info[$idx], ':')) {
429: $mapped['gameIpAndPort'] = $info[$idx];
430: }
431:
432: $result['serverInfo'] = $mapped;
433: }
434:
435: // Find players packet (look for a packet that starts with OK and appears to contain fields)
436: foreach ($packets as $p) {
437: /** @var string $p */
438: if ($p === '') {
439: continue;
440: }
441:
442: $p = explode("\t", $p);
443:
444: if (count($p) === 0) {
445: continue;
446: }
447:
448: if ($p[0] !== 'OK') {
449: continue;
450: }
451:
452: // heuristic: if packet length indicates fieldCount and fields follow
453: $pos = 1;
454:
455: if (!isset($p[$pos])) {
456: continue;
457: }
458: $fieldCount = (int) $p[$pos];
459: $pos++;
460:
461: /** @var array<string> $fields */
462: $fields = [];
463:
464: for ($i = 0; $i < $fieldCount; $i++, $pos++) {
465: $fields[] = $p[$pos] ?? '';
466: }
467:
468: if (!isset($p[$pos])) {
469: continue;
470: }
471: $playerCount = (int) $p[$pos];
472: $pos++;
473:
474: $players = [];
475:
476: for ($i = 0; $i < $playerCount; $i++) {
477: /** @var array<string, mixed> $player */
478: $player = [];
479:
480: foreach ($fields as $f) {
481: $val = $p[$pos] ?? null;
482: $pos++;
483:
484: if ($f === 'teamId') {
485: $f = 'team';
486: }
487:
488: if ($f === 'squadId') {
489: $f = 'squad';
490: }
491:
492: if (in_array($f, ['kills', 'deaths', 'score', 'rank', 'team', 'squad', 'ping', 'type'], true)) {
493: $val = is_numeric($val) ? (int) $val : 0;
494: }
495:
496: if ($f === 'ping') {
497: $ival = $val;
498:
499: if ($ival >= 60000) {
500: $val = 0;
501: } else {
502: $val = $ival;
503: }
504: }
505: $player[$f] = $val;
506: }
507: $players[] = $player;
508: }
509:
510: $result['players'] = $players;
511:
512: break;
513: }
514:
515: return $result;
516: }
517:
518: /**
519: * query method.
520: */
521: #[Override]
522: public function query(ServerAddress $addr): ServerInfo
523: {
524: $this->address = $addr->ip;
525: $this->queryport = $addr->port;
526: $this->query_server(true, true);
527:
528: return new ServerInfo(
529: address: $this->address,
530: queryport: $this->queryport,
531: online: $this->online,
532: gamename: $this->gamename,
533: gameversion: $this->gameversion,
534: servertitle: $this->servertitle,
535: mapname: $this->mapname,
536: gametype: $this->gametype,
537: numplayers: $this->numplayers,
538: maxplayers: $this->maxplayers,
539: rules: $this->rules,
540: players: $this->players,
541: errstr: $this->errstr,
542: );
543: }
544:
545: /**
546: * getProtocolName method.
547: */
548: #[Override]
549: public function getProtocolName(): string
550: {
551: return $this->protocol;
552: }
553:
554: /**
555: * getVersion method.
556: */
557: #[Override]
558: public function getVersion(ServerInfo $info): string
559: {
560: return $info->gameversion ?? 'unknown';
561: }
562:
563: /**
564: * @param array<int,string> $params
565: */
566: private function buildPacket(array $params): string
567: {
568: $parts = [];
569: $totalLength = 12;
570:
571: foreach ($params as $p) {
572: $b = (string) $p;
573: $parts[] = $b;
574: $totalLength += 4 + strlen($b) + 1;
575: }
576:
577: $out = '';
578: // header (0) little-endian
579: $out .= pack('V', 0);
580: // total length
581: $out .= pack('V', $totalLength);
582: // param count
583: $out .= pack('V', count($params));
584:
585: foreach ($parts as $p) {
586: $out .= pack('V', strlen($p));
587: $out .= $p;
588: $out .= chr(0);
589: }
590:
591: return $out;
592: }
593:
594: /**
595: * TCP query helper.
596: *
597: * @param resource $fp
598: * @param array<int,string> $params
599: *
600: * @return array<mixed>|false
601: */
602: private function tcpQuery(mixed $fp, array $params): array|false
603: {
604: $packet = $this->buildPacket($params);
605: $written = fwrite($fp, $packet);
606:
607: if ($written === false) {
608: return false;
609: }
610:
611: $buf = '';
612: $start = time();
613:
614: while (true) {
615: $chunk = fread($fp, 8192);
616:
617: if ($chunk === false) {
618: break;
619: }
620:
621: if ($chunk !== '') {
622: $buf .= $chunk;
623: }
624:
625: $decoded = $this->decodePacket($buf);
626:
627: if ($decoded === false) {
628: // need more data
629: } else {
630: return $decoded;
631: }
632:
633: // timeout 2s
634: if ((time() - $start) > 2) {
635: break;
636: }
637: }
638:
639: return false;
640: }
641:
642: /**
643: * @return array<string,mixed>|false
644: */
645: private function decodePacket(string $buffer): array|false
646: {
647: if (strlen($buffer) < 8) {
648: return false;
649: }
650:
651: // manual pointer decode (header + totalLength already present)
652: $headerUnpacked = unpack('V', substr($buffer, 0, 4));
653:
654: if ($headerUnpacked === false || !isset($headerUnpacked[1]) || !is_numeric($headerUnpacked[1])) {
655: return false;
656: }
657:
658: $header = (int) $headerUnpacked[1];
659:
660: $totalLengthUnpacked = unpack('V', substr($buffer, 4, 4));
661:
662: if ($totalLengthUnpacked === false || !isset($totalLengthUnpacked[1]) || !is_numeric($totalLengthUnpacked[1])) {
663: return false;
664: }
665:
666: $totalLength = (int) $totalLengthUnpacked[1];
667:
668: // ensure we have whole packet
669: if (strlen($buffer) < $totalLength) {
670: return false;
671: }
672:
673: // check response flag (0x40000000)
674: if ((($header & 0x40000000) === 0)) {
675: // not a response packet
676: return false;
677: }
678:
679: $ptr = 8;
680: $result = [];
681:
682: // decode key/value pairs
683: for ($i = 0; $i < $header; $i++) {
684: if (strlen($buffer) < $ptr + 4) {
685: break;
686: }
687: $keyLenUnpacked = unpack('V', substr($buffer, $ptr, 4));
688:
689: if ($keyLenUnpacked === false || !isset($keyLenUnpacked[1]) || !is_numeric($keyLenUnpacked[1])) {
690: break;
691: }
692:
693: $keyLen = (int) $keyLenUnpacked[1];
694: $ptr += 4;
695:
696: if (strlen($buffer) < $ptr + $keyLen) {
697: break;
698: }
699: $key = substr($buffer, $ptr, $keyLen);
700: $ptr += $keyLen;
701:
702: if (strlen($buffer) < $ptr + 4) {
703: break;
704: }
705: $valLenUnpacked = unpack('V', substr($buffer, $ptr, 4));
706:
707: if ($valLenUnpacked === false || !isset($valLenUnpacked[1]) || !is_numeric($valLenUnpacked[1])) {
708: break;
709: }
710:
711: $valLen = (int) $valLenUnpacked[1];
712: $ptr += 4;
713:
714: if (strlen($buffer) < $ptr + $valLen) {
715: break;
716: }
717: $val = substr($buffer, $ptr, $valLen);
718: $ptr += $valLen;
719:
720: $result[$key] = $val;
721: }
722:
723: return $result;
724: }
725: }
726: