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