Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.30% covered (warning)
66.30%
61 / 92
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UdpClient
66.30% covered (warning)
66.30%
61 / 92
50.00% covered (danger)
50.00%
3 / 6
105.31
0.00% covered (danger)
0.00%
0 / 1
 setTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 query
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 queryPlayers
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 queryMultiPacket
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
156
 createSocket
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 parsePlayerResponse
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
10.08
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\Util;
14
15use function fclose;
16use function fread;
17use function fsockopen;
18use function fwrite;
19use function is_array;
20use function ord;
21use function stream_get_meta_data;
22use function stream_set_blocking;
23use function stream_set_timeout;
24use function strlen;
25use function substr;
26use function unpack;
27use function usleep;
28
29/**
30 * UDP Client for game server queries.
31 *
32 * @method string[] queryMultiPacket(string $address, int $port, string $packet, int $maxPackets = 0, float $interPacketTimeout = 0.1)
33 */
34class UdpClient
35{
36    private int $timeout = 10; // default timeout in seconds
37
38    /**
39     * setTimeout method.
40     */
41    public function setTimeout(int $timeout): void
42    {
43        $this->timeout = $timeout;
44    }
45
46    /**
47     * Send a UDP query and receive response.
48     */
49    public function query(string $address, int $port, string $packet): ?string
50    {
51        $fp = $this->createSocket($address, $port);
52
53        if ($fp === false) {
54            return null;
55        }
56
57        stream_set_blocking($fp, true);
58        stream_set_timeout($fp, $this->timeout, 0);
59
60        // Send packet
61        if (fwrite($fp, $packet, strlen($packet)) === false) {
62            fclose($fp);
63
64            return null;
65        }
66
67        $result       = '';
68        $socketstatus = stream_get_meta_data($fp);
69
70        while (!$socketstatus['timed_out'] && !$socketstatus['eof']) {
71            $result .= fread($fp, 128);
72            $socketstatus = stream_get_meta_data($fp);
73        }
74
75        fclose($fp);
76
77        if ($result === '' || $result === '0') {
78            return null;
79        }
80
81        return $result;
82    }
83
84    /**
85     * Send a UDP query and receive multiple packets.
86     * Some protocols send multiple separate UDP packets in response to a single query.
87     *
88     * @param string $address Server address
89     * @param int    $port    server port
90     *
91     * @return ?array<mixed>
92     */
93    public function queryPlayers(string $address, int $port): ?array
94    {
95        // Step 1: Get challenge
96        $challengePacket   = "\xFF\xFF\xFF\xFF\x55\xFF\xFF\xFF\xFF";
97        $challengeResponse = $this->query($address, $port, $challengePacket);
98
99        if ($challengeResponse === null || $challengeResponse === '' || $challengeResponse === '0' || strlen($challengeResponse) < 9) {
100            return null;
101        }
102
103        $challenge = substr($challengeResponse, 5, 4);
104
105        // Step 2: Query players with challenge
106        $playerPacket   = "\xFF\xFF\xFF\xFF\x55" . $challenge;
107        $playerResponse = $this->query($address, $port, $playerPacket);
108
109        if ($playerResponse === null || $playerResponse === '' || $playerResponse === '0' || strlen($playerResponse) < 6) {
110            return null;
111        }
112
113        // Parse response
114        return $this->parsePlayerResponse($playerResponse);
115    }
116
117    /**
118     * Send a UDP query and receive multiple packets.
119     *
120     * @param string $address            Server address
121     * @param int    $port               Server port
122     * @param string $packet             Packet to send
123     * @param int    $maxPackets         Maximum number of packets to receive (0 for unlimited)
124     * @param float  $interPacketTimeout Timeout between packets
125     *
126     * @return string[] Array of received packets
127     */
128    public function queryMultiPacket(string $address, int $port, string $packet, int $maxPackets = 0, float $interPacketTimeout = 0.1): array
129    {
130        $fp = $this->createSocket($address, $port);
131
132        if ($fp === false) {
133            return [];
134        }
135
136        stream_set_blocking($fp, true);
137        stream_set_timeout($fp, $this->timeout, 0);
138
139        // Send packet
140        if (fwrite($fp, $packet, strlen($packet)) === false) {
141            fclose($fp);
142
143            return [];
144        }
145
146        $packets     = [];
147        $packetCount = 0;
148
149        while ($maxPackets === 0 || $packetCount < $maxPackets) {
150            $result       = '';
151            $socketstatus = stream_get_meta_data($fp);
152
153            while (!$socketstatus['timed_out'] && !$socketstatus['eof']) {
154                $data = fread($fp, 128);
155
156                if ($data === false || $data === '') {
157                    break;
158                }
159                $result .= $data;
160                $socketstatus = stream_get_meta_data($fp);
161            }
162
163            if ($result === '' || $result === '0') {
164                break;
165            }
166
167            $packets[] = $result;
168            $packetCount++;
169
170            // Wait for inter-packet timeout
171            if ($interPacketTimeout > 0) {
172                usleep((int) ($interPacketTimeout * 1000000));
173            }
174        }
175
176        fclose($fp);
177
178        return $packets;
179    }
180
181    /**
182     * Create a UDP socket connection.
183     *
184     * @return false|resource returns a socket resource on success, or false on failure
185     *
186     * @phpstan-return resource|false
187     *
188     * @psalm-return resource|false
189     *
190     * @phpstan-ignore-next-line you can not annotate ": bool|resource" to fix it!
191     */
192    protected function createSocket(string $address, int $port)
193    {
194        $socket = @fsockopen('udp://' . $address, $port, $errno, $errstr, $this->timeout);
195
196        if ($socket === false) {
197            return false;
198        }
199
200        return $socket;
201    }
202
203    /**
204     * Parse A2S_PLAYER response.
205     *
206     * @return array<mixed>
207     */
208    private function parsePlayerResponse(string $data): array
209    {
210        $data       = substr($data, 5); // Skip header
211        $numPlayers = ord($data[0]);
212        $offset     = 1;
213        $players    = [];
214
215        for ($i = 0; $i < $numPlayers; $i++) {
216            if ($offset >= strlen($data)) {
217                break;
218            }
219
220            $index = ord($data[$offset]);
221            $offset++;
222
223            // Player name (null-terminated)
224            $name = '';
225
226            while ($offset < strlen($data) && $data[$offset] !== "\x00") {
227                $name .= $data[$offset];
228                $offset++;
229            }
230            $offset++; // Skip null
231
232            if ($offset + 8 > strlen($data)) {
233                break;
234            }
235
236            // Score (int32 little-endian)
237            $unpackedScore = unpack('l', substr($data, $offset, 4));
238
239            if (!is_array($unpackedScore) || !isset($unpackedScore[1])) {
240                break;
241            }
242            $score = $unpackedScore[1];
243            $offset += 4;
244
245            // Time connected (float32 little-endian)
246            $unpackedTime = unpack('f', substr($data, $offset, 4));
247
248            if (!is_array($unpackedTime) || !isset($unpackedTime[1])) {
249                break;
250            }
251            $time = $unpackedTime[1];
252            $offset += 4;
253
254            $players[] = [
255                'index' => $index,
256                'name'  => $name,
257                'score' => $score,
258                'time'  => $time,
259            ];
260        }
261
262        return $players;
263    }
264}