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 is_array;
16: use function pack;
17: use function strlen;
18: use function substr;
19: use function time;
20: use function unpack;
21: use Clansuite\Capture\Protocol\ProtocolInterface;
22: use Clansuite\Capture\ServerAddress;
23: use Clansuite\Capture\ServerInfo;
24: use Clansuite\ServerQuery\CSQuery;
25: use Override;
26:
27: /**
28: * Queries Satisfactory game servers.
29: *
30: * Retrieves server information for the game Satisfactory, including player count,
31: * server settings, and game state by communicating with the game's query protocol.
32: * Enables monitoring of Satisfactory multiplayer servers.
33: */
34: class Satisfactory extends CSQuery implements ProtocolInterface
35: {
36: /**
37: * Protocol name.
38: */
39: public string $name = 'Satisfactory';
40:
41: /**
42: * List of supported games.
43: *
44: * @var array<string>
45: */
46: public array $supportedGames = ['Satisfactory'];
47:
48: /**
49: * Protocol identifier.
50: */
51: public string $protocol = 'Satisfactory';
52:
53: /**
54: * Game series.
55: *
56: * @var array<string>
57: */
58: public array $game_series_list = ['Satisfactory'];
59:
60: /**
61: * Default query port.
62: */
63: protected int $port_diff = 0; // Query port is 15777, game port 7777
64:
65: /**
66: * Constructor.
67: *
68: * Initializes the Satisfactory query instance.
69: *
70: * @param null|string $address The server address to query
71: * @param null|int $queryport The query port for the Satisfactory server (default 15777)
72: */
73: public function __construct(?string $address = null, ?int $queryport = null)
74: {
75: parent::__construct();
76: $this->address = $address;
77: $this->queryport = $queryport ?? 15777; // Default query port
78: }
79:
80: /**
81: * Queries the Satisfactory server and populates server information.
82: *
83: * Sends a query request and processes the response to extract server details,
84: * player information, and game settings.
85: *
86: * @param bool $getPlayers Whether to retrieve player information
87: * @param bool $getRules Whether to retrieve server rules/settings
88: *
89: * @return bool True on successful query, false on failure
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: // Create Poll Server State message
99: // ProtocolMagic: 0xF6D5 (little endian)
100: // MessageType: 0 (Poll Server State)
101: // ProtocolVersion: 1
102: // Payload: uint64 LE Cookie (use current time or random)
103: // Terminator: 0x1
104:
105: $cookie = time(); // Use current timestamp as cookie
106: $payload = pack('VV', $cookie & 0xFFFFFFFF, ($cookie >> 32) & 0xFFFFFFFF);
107:
108: $message = pack('v', 0xF6D5) . // ProtocolMagic LE
109: pack('C', 0) . // MessageType
110: pack('C', 1) . // ProtocolVersion
111: $payload . // Cookie
112: pack('C', 0x1); // Terminator
113:
114: $address = (string) $this->address;
115: $port = (int) $this->queryport;
116:
117: if (($result = $this->sendCommand($address, $port, $message)) === '' || ($result = $this->sendCommand($address, $port, $message)) === '0' || ($result = $this->sendCommand($address, $port, $message)) === false) {
118: $this->errstr = 'No reply received';
119:
120: return false;
121: }
122:
123: // Parse the response
124: if (!$this->parseResponse($result)) {
125: return false;
126: }
127:
128: $this->online = true;
129:
130: return true;
131: }
132:
133: // ProtocolInterface methods
134: /**
135: * query method.
136: *
137: * Performs a query on the specified Satisfactory server address.
138: *
139: * Updates internal state with server information and returns a ServerInfo object
140: * containing the query results.
141: *
142: * @param ServerAddress $addr The server address and port to query
143: *
144: * @return ServerInfo Server information including status, players, and settings
145: */
146: #[Override]
147: public function query(ServerAddress $addr): ServerInfo
148: {
149: $this->address = $addr->ip;
150: $this->queryport = $addr->port;
151: $this->query_server();
152:
153: return new ServerInfo(
154: address: $this->address,
155: queryport: $this->queryport,
156: online: $this->online,
157: gamename: $this->gamename,
158: gameversion: $this->gameversion,
159: servertitle: $this->servertitle,
160: mapname: $this->mapname,
161: gametype: $this->gametype,
162: numplayers: $this->numplayers,
163: maxplayers: $this->maxplayers,
164: rules: $this->rules,
165: players: $this->players,
166: errstr: $this->errstr,
167: );
168: }
169:
170: /**
171: * Returns the protocol name for Satisfactory.
172: *
173: * @return string The protocol identifier 'Satisfactory'
174: */
175: #[Override]
176: public function getProtocolName(): string
177: {
178: return $this->protocol;
179: }
180:
181: /**
182: * Extracts the Satisfactory server version from server information.
183: *
184: * @param ServerInfo $info The server information object
185: *
186: * @return string The server version string, or 'unknown' if not available
187: */
188: #[Override]
189: public function getVersion(ServerInfo $info): string
190: {
191: return $info->gameversion ?? 'unknown';
192: }
193:
194: /**
195: * Parses the raw response data from the Satisfactory server.
196: *
197: * Extracts server information, player data, and settings from the binary response.
198: *
199: * @param string $data The raw binary data received from the server
200: *
201: * @return bool True if parsing was successful, false otherwise
202: */
203: protected function parseResponse(string $data): bool
204: {
205: if (strlen($data) < 5) {
206: $this->errstr = 'Response too short';
207:
208: return false;
209: }
210:
211: // Check ProtocolMagic
212: $tmp = @unpack('v', substr($data, 0, 2));
213:
214: if (!is_array($tmp) || !isset($tmp[1])) {
215: $this->errstr = 'Invalid response unpack';
216:
217: return false;
218: }
219: $magic = $tmp[1];
220:
221: if ($magic !== 0xF6D5) {
222: $this->errstr = 'Invalid protocol magic';
223:
224: return false;
225: }
226:
227: $tmp = @unpack('C', substr($data, 2, 1));
228:
229: if (!is_array($tmp) || !isset($tmp[1])) {
230: $this->errstr = 'Invalid response unpack';
231:
232: return false;
233: }
234: $messageType = $tmp[1];
235:
236: if ($messageType !== 1) { // Server State Response
237: $this->errstr = 'Unexpected message type';
238:
239: return false;
240: }
241:
242: $tmp = @unpack('C', substr($data, 3, 1));
243:
244: if (!is_array($tmp) || !isset($tmp[1])) {
245: $this->errstr = 'Invalid response unpack';
246:
247: return false;
248: }
249: $protocolVersion = $tmp[1];
250:
251: if ($protocolVersion !== 1) {
252: $this->errstr = 'Unsupported protocol version';
253:
254: return false;
255: }
256:
257: $offset = 4;
258: $offset += 8;
259:
260: // ServerState (uint8)
261: $tmp = @unpack('C', substr($data, $offset, 1));
262:
263: if (!is_array($tmp) || !isset($tmp[1])) {
264: $this->errstr = 'Invalid response unpack';
265:
266: return false;
267: }
268: $serverState = $tmp[1];
269: $offset++;
270:
271: // ServerNetCL (uint32 LE)
272: $tmp = @unpack('V', substr($data, $offset, 4));
273:
274: if (!is_array($tmp) || !isset($tmp[1])) {
275: $this->errstr = 'Invalid response unpack';
276:
277: return false;
278: }
279: $serverNetCL = $tmp[1];
280: $offset += 4;
281: $offset += 8;
282:
283: // NumSubStates (uint8)
284: $tmp = @unpack('C', substr($data, $offset, 1));
285:
286: if (!is_array($tmp) || !isset($tmp[1])) {
287: $this->errstr = 'Invalid response unpack';
288:
289: return false;
290: }
291: $numSubStates = $tmp[1];
292: $offset++;
293:
294: // SubStates (array of ServerSubState)
295: for ($i = 0; $i < $numSubStates; $i++) {
296: // SubStateId (uint8)
297: $tmp = @unpack('C', substr($data, $offset, 1));
298:
299: if (!is_array($tmp) || !isset($tmp[1])) {
300: $this->errstr = 'Invalid response unpack (substate id)';
301:
302: return false;
303: }
304: $subStateId = $tmp[1];
305: $offset++;
306:
307: // SubStateVersion (uint16 LE)
308: $tmp = @unpack('v', substr($data, $offset, 2));
309:
310: if (!is_array($tmp) || !isset($tmp[1])) {
311: $this->errstr = 'Invalid response unpack (substate version)';
312:
313: return false;
314: }
315: $subStateVersion = $tmp[1];
316: $offset += 2;
317: }
318:
319: // ServerNameLength (uint16 LE)
320: $tmp = @unpack('v', substr($data, $offset, 2));
321:
322: if (!is_array($tmp) || !isset($tmp[1])) {
323: $this->errstr = 'Invalid response unpack';
324:
325: return false;
326: }
327: $serverNameLength = $tmp[1];
328: $offset += 2;
329:
330: // ServerName (UTF-8)
331: $serverName = substr($data, $offset, $serverNameLength);
332: $offset += $serverNameLength;
333:
334: // Terminator
335: if ($offset < strlen($data)) {
336: $tmp = @unpack('C', substr($data, $offset, 1));
337:
338: if (!is_array($tmp) || !isset($tmp[1])) {
339: $this->errstr = 'Invalid response unpack (terminator)';
340:
341: return false;
342: }
343: $terminator = $tmp[1];
344:
345: if ($terminator !== 0x1) {
346: $this->errstr = 'Invalid terminator';
347:
348: return false;
349: }
350: }
351:
352: // Now set the properties
353: $this->servertitle = $serverName;
354: $this->gamename = 'Satisfactory';
355: $this->gameversion = (string) $serverNetCL; // Use changelist as version
356:
357: // Map server state to online status
358: // 0: Offline, 1: Idle, 2: Loading, 3: Playing
359: if ($serverState === 3 || $serverState === 1) {
360: $this->online = true;
361: } else {
362: $this->online = false;
363: }
364:
365: // For now, set some defaults since the lightweight API doesn't provide player count etc.
366: $this->numplayers = 0; // Not provided in lightweight API
367: $this->maxplayers = 0; // Not provided
368: $this->mapname = ''; // Not provided
369: $this->gametype = ''; // Not provided
370: $this->password = -1; // Unknown
371:
372: return true;
373: }
374: }
375: