Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bc2
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 12
2652
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
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 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 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
 tcpQuery
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getPacket
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 decodePacket
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 parseServerInfo
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
342
 parsePlayers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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 count;
16use function fclose;
17use function fread;
18use function fsockopen;
19use function fwrite;
20use function is_float;
21use function is_int;
22use function is_numeric;
23use function is_string;
24use function stream_set_blocking;
25use function stream_set_timeout;
26use function strlen;
27use function substr;
28use function time;
29use function unpack;
30use Clansuite\Capture\Protocol\ProtocolInterface;
31use Clansuite\Capture\ServerAddress;
32use Clansuite\Capture\ServerInfo;
33use Clansuite\ServerQuery\CSQuery;
34use LogicException;
35use Override;
36
37/**
38 * Implements the query protocol for Battlefield: Bad Company 2 servers.
39 * Handles server queries, player information retrieval, and game-specific data parsing.
40 */
41class Bc2 extends CSQuery implements ProtocolInterface
42{
43    /**
44     * Real game host (may differ from query host).
45     */
46    public ?string $gameHost = null;
47
48    /**
49     * Real game port (may differ from query port).
50     */
51    public ?int $gamePort = null;
52
53    /**
54     * Protocol name.
55     */
56    public string $name = 'Battlefield Bad Company 2';
57
58    /**
59     * List of supported games.
60     *
61     * @var array<string>
62     */
63    public array $supportedGames = ['Battlefield Bad Company 2'];
64
65    /**
66     * Protocol identifier.
67     */
68    public string $protocol  = 'bc2';
69    protected int $port_diff = 29321;
70
71    /**
72     * Constructor.
73     */
74    public function __construct(?string $address = null, ?int $queryport = null)
75    {
76        parent::__construct($address, $queryport);
77    }
78
79    /**
80     * Returns a native join URI for BC2 or false if not available.
81     */
82    #[Override]
83    public function getNativeJoinURI(): false|string
84    {
85        return false; // BC2 doesn't have native join URI like BF4
86    }
87
88    /**
89     * query_server method.
90     */
91    #[Override]
92    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
93    {
94        if ($this->online) {
95            $this->reset();
96        }
97
98        $queryPort = ($this->queryport ?? 0) + $this->port_diff;
99
100        // Attempt TCP query to client_port + 29321 (BC2 convention)
101        $errno   = 0;
102        $errstr  = '';
103        $address = $this->address ?? '';
104        $fp      = @fsockopen($address, $queryPort, $errno, $errstr, 5);
105
106        if ($fp === false) {
107            $this->errstr = 'Unable to open TCP socket to BC2 query port';
108
109            return false;
110        }
111        stream_set_blocking($fp, true);
112        stream_set_timeout($fp, 5);
113
114        // serverInfo
115        $info = $this->tcpQuery($fp, 'serverInfo');
116
117        if ($info === false) {
118            fclose($fp);
119            $this->errstr = 'No BC2 serverInfo response';
120
121            return false;
122        }
123
124        // parse serverInfo
125        $this->parseServerInfo($info);
126        $players = $this->tcpQuery($fp, 'listPlayers');
127
128        if ($players !== false) {
129            $this->parsePlayers($players);
130        }
131
132        // version
133        $ver = $this->tcpQuery($fp, 'version');
134
135        if ($ver !== false) {
136            $this->parseVersion($ver);
137        }
138
139        fclose($fp);
140        $this->online = true;
141
142        return true;
143    }
144
145    /**
146     * query method.
147     */
148    #[Override]
149    public function query(ServerAddress $addr): ServerInfo
150    {
151        $this->address   = $addr->ip;
152        $this->queryport = $addr->port;
153        $this->query_server(true, true);
154
155        return new ServerInfo(
156            address: $this->address,
157            queryport: $this->queryport,
158            online: $this->online,
159            gamename: $this->gamename,
160            gameversion: $this->gameversion,
161            servertitle: $this->servertitle,
162            mapname: $this->mapname,
163            gametype: $this->gametype,
164            numplayers: $this->numplayers,
165            maxplayers: $this->maxplayers,
166            rules: $this->rules,
167            players: $this->players,
168            errstr: $this->errstr,
169        );
170    }
171
172    /**
173     * getProtocolName method.
174     */
175    #[Override]
176    public function getProtocolName(): string
177    {
178        return 'bc2';
179    }
180
181    /**
182     * getVersion method.
183     */
184    #[Override]
185    public function getVersion(ServerInfo $info): string
186    {
187        return $info->gameversion ?? 'unknown';
188    }
189
190    /**
191     * @param resource $fp
192     *
193     * @return array<mixed>|false
194     */
195    private function tcpQuery(mixed $fp, string $command): array|false
196    {
197        $packet  = $this->getPacket($command);
198        $written = fwrite($fp, $packet);
199
200        if ($written === false) {
201            return false;
202        }
203
204        $buf   = '';
205        $start = time();
206
207        while (true) {
208            $chunk = fread($fp, 8192);
209
210            if ($chunk === false) {
211                break;
212            }
213
214            if ($chunk !== '') {
215                $buf .= $chunk;
216            }
217
218            $decoded = $this->decodePacket($buf);
219
220            if ($decoded !== false) {
221                return $decoded;
222            }
223
224            // timeout 2s
225            if ((time() - $start) > 2) {
226                break;
227            }
228        }
229
230        // If we get here, decoding hasn't produced a useful result yet.
231        // Returning false signals failure to caller consistent with the phpdoc.
232        return false;
233    }
234
235    private function getPacket(string $command): string
236    {
237        return match ($command) {
238            'version'     => "\x00\x00\x00\x00\x18\x00\x00\x00\x01\x00\x00\x00\x07\x00\x00\x00version\x00",
239            'serverInfo'  => "\x00\x00\x00\x00\x1b\x00\x00\x00\x01\x00\x00\x00\x0a\x00\x00\x00serverInfo\x00",
240            'listPlayers' => "\x00\x00\x00\x00\x24\x00\x00\x00\x02\x00\x00\x00\x0b\x00\x00\x00listPlayers\x00\x03\x00\x00\x00\x61ll\x00",
241            default       => '',
242        };
243    }
244
245    /**
246     * Decode buffer into associative array or return false when incomplete/invalid.
247     *
248     * @return array<mixed>|false
249     */
250    private function decodePacket(string $buffer): array|false
251    {
252        if (strlen($buffer) < 4) {
253            return false;
254        }
255
256        $unpacked = unpack('V', $buffer);
257
258        if ($unpacked === false) {
259            return false;
260        }
261
262        /** @var array{1: int} $unpacked */
263        $itemCount = isset($unpacked[1]) ? (int) $unpacked[1] : 0;
264        $ptr       = 4;
265        $items     = [];
266
267        for ($i = 0; $i < $itemCount; $i++) {
268            if ($ptr + 4 > strlen($buffer)) {
269                return false;
270            }
271            $unpacked = unpack('V', substr($buffer, $ptr, 4));
272
273            if ($unpacked === false) {
274                return false;
275            }
276
277            /** @var array{1: int} $unpacked */
278            $len = $unpacked[1];
279            $ptr += 4;
280
281            if ($ptr + $len > strlen($buffer)) {
282                return false;
283            }
284            $items[] = substr($buffer, $ptr, $len);
285            $ptr += $len;
286        }
287
288        return $items;
289    }
290
291    /**
292     * @param array<mixed> $info
293     */
294    private function parseServerInfo(array $info): void
295    {
296        if (count($info) < 9) {
297            return;
298        }
299
300        $this->playerteams = [];
301
302        $st                = $info[1] ?? null;
303        $this->servertitle = is_string($st) ? $st : '';
304
305        $np               = $info[2] ?? null;
306        $this->numplayers = is_int($np) ? $np : (is_numeric($np) ? (int) $np : 0);
307
308        $mp               = $info[3] ?? null;
309        $this->maxplayers = is_int($mp) ? $mp : (is_numeric($mp) ? (int) $mp : 0);
310
311        $gt             = $info[4] ?? null;
312        $this->gametype = is_string($gt) ? $gt : '';
313
314        $mn            = $info[5] ?? null;
315        $this->mapname = is_string($mn) ? $mn : '';
316
317        $idx       = 9;
318        $tc        = $info[8] ?? null;
319        $teamCount = is_int($tc) ? $tc : (is_numeric($tc) ? (int) $tc : 0);
320
321        for ($i = 0; $i < $teamCount; $i++) {
322            $tval                = $info[$idx++] ?? null;
323            $tickets             = is_float($tval) ? $tval : (is_numeric($tval) ? (float) $tval : 0.0);
324            $this->playerteams[] = ['tickets' => $tickets];
325        }
326
327        $tsVal                      = $info[$idx++] ?? null;
328        $this->rules['targetscore'] = is_int($tsVal) ? $tsVal : (is_numeric($tsVal) ? (int) $tsVal : 0);
329
330        $this->rules['ranked']     = (($info[$idx + 1] ?? '') === 'true');
331        $this->rules['punkbuster'] = (($info[$idx + 2] ?? '') === 'true');
332        $this->rules['password']   = (($info[$idx + 3] ?? '') === 'true');
333
334        $uptVal                = $info[$idx + 4] ?? null;
335        $this->rules['uptime'] = is_int($uptVal) ? $uptVal : (is_numeric($uptVal) ? (int) $uptVal : 0);
336    }
337
338    /**
339     * @param array<mixed> $players
340     */
341    private function parsePlayers(array $players): void
342    {
343        // TODO: implement player parsing
344        throw new LogicException('Not implemented yet.');
345    }
346
347    /**
348     * @param array<mixed> $version
349     */
350    private function parseVersion(array $version): void
351    {
352        // TODO: implement version parsing
353        throw new LogicException('Not implemented yet.');
354    }
355}