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\Util;
14:
15: use function fclose;
16: use function fread;
17: use function fsockopen;
18: use function fwrite;
19: use function is_array;
20: use function ord;
21: use function stream_get_meta_data;
22: use function stream_set_blocking;
23: use function stream_set_timeout;
24: use function strlen;
25: use function substr;
26: use function unpack;
27: use 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: */
34: class 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: }
265: