Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.07% covered (warning)
63.07%
193 / 306
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Battlefield4
63.07% covered (warning)
63.07%
193 / 306
60.00% covered (warning)
60.00%
6 / 10
683.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNativeJoinURI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 query_server
8.41% covered (danger)
8.41%
9 / 107
0.00% covered (danger)
0.00%
0 / 1
1397.27
 parseCaptured
94.55% covered (success)
94.55%
104 / 110
0.00% covered (danger)
0.00%
0 / 1
40.26
 query
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 getProtocolName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildPacket
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 tcpQuery
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
7.54
 decodePacket
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
10.32
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 array_values;
17use function chr;
18use function count;
19use function explode;
20use function fclose;
21use function fread;
22use function fsockopen;
23use function fwrite;
24use function in_array;
25use function is_int;
26use function is_numeric;
27use function pack;
28use function reset;
29use function str_contains;
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 4 Server Query Class.
44 */
45class 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}