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 count;
16: use function fclose;
17: use function fread;
18: use function fsockopen;
19: use function fwrite;
20: use function is_float;
21: use function is_int;
22: use function is_numeric;
23: use function is_string;
24: use function stream_set_blocking;
25: use function stream_set_timeout;
26: use function strlen;
27: use function substr;
28: use function time;
29: use function unpack;
30: use Clansuite\Capture\Protocol\ProtocolInterface;
31: use Clansuite\Capture\ServerAddress;
32: use Clansuite\Capture\ServerInfo;
33: use Clansuite\ServerQuery\CSQuery;
34: use LogicException;
35: use 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: */
41: class 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: }
356: