Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 325
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bf3
0.00% covered (danger)
0.00%
0 / 325
0.00% covered (danger)
0.00%
0 / 10
18906
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getNativeJoinURI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 query_server
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 1
3080
 parseCaptured
0.00% covered (danger)
0.00%
0 / 114
0.00% covered (danger)
0.00%
0 / 1
2162
 query
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getProtocolName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildPacket
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 tcpQuery
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 decodePacket
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
462
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
13namespace Clansuite\ServerQuery\ServerProtocols;
14
15use function array_shift;
16use function chr;
17use function count;
18use function explode;
19use function fclose;
20use function fread;
21use function fsockopen;
22use function fwrite;
23use function in_array;
24use function is_array;
25use function is_numeric;
26use function is_string;
27use function pack;
28use function str_contains;
29use function str_starts_with;
30use function stream_set_blocking;
31use function stream_set_timeout;
32use function strlen;
33use function substr;
34use function time;
35use function unpack;
36use Clansuite\Capture\Protocol\ProtocolInterface;
37use Clansuite\Capture\ServerAddress;
38use Clansuite\Capture\ServerInfo;
39use Clansuite\ServerQuery\CSQuery;
40use Override;
41
42/**
43 * Battlefield 3 / 4 / Hardline (Frostbite) Server Query Class.
44 */
45class 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}