1: <?php
2: /**
3: * This file is part of GameQ.
4: *
5: * GameQ is free software; you can redistribute it and/or modify
6: * it under the terms of the GNU Lesser General Public License as published by
7: * the Free Software Foundation; either version 3 of the License, or
8: * (at your option) any later version.
9: *
10: * GameQ is distributed in the hope that it will be useful,
11: * but WITHOUT ANY WARRANTY; without even the implied warranty of
12: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13: * GNU Lesser General Public License for more details.
14: *
15: * You should have received a copy of the GNU Lesser General Public License
16: * along with this program. If not, see <http://www.gnu.org/licenses/>.
17: */
18:
19: namespace GameQ\Protocols;
20:
21: use GameQ\Buffer;
22: use GameQ\Exception\Protocol as Exception;
23: use GameQ\Protocol;
24: use GameQ\Result;
25:
26: /**
27: * Valve Source Engine Protocol Class (A2S)
28: *
29: * This class is used as the basis for all other source based servers
30: * that rely on the source protocol for game querying.
31: *
32: * @SuppressWarnings(PHPMD.NumberOfChildren)
33: *
34: * @author Austin Bischoff <austin@codebeard.com>
35: */
36: class Source extends Protocol
37: {
38: // Source engine type constants
39: const SOURCE_ENGINE = 0,
40: GOLDSOURCE_ENGINE = 1;
41:
42: /**
43: * Array of packets we want to look up.
44: * Each key should correspond to a defined method in this or a parent class
45: *
46: * @var array
47: */
48: protected $packets = [
49: self::PACKET_CHALLENGE => "\xFF\xFF\xFF\xFF\x56\x00\x00\x00\x00",
50: self::PACKET_DETAILS => "\xFF\xFF\xFF\xFFTSource Engine Query\x00%s",
51: self::PACKET_PLAYERS => "\xFF\xFF\xFF\xFF\x55%s",
52: self::PACKET_RULES => "\xFF\xFF\xFF\xFF\x56%s",
53: ];
54:
55: /**
56: * Use the response flag to figure out what method to run
57: *
58: * @var array
59: */
60: protected $responses = [
61: "\x49" => "processDetails", // I
62: "\x6d" => "processDetailsGoldSource", // m, goldsource
63: "\x44" => "processPlayers", // D
64: "\x45" => "processRules", // E
65: ];
66:
67: /**
68: * The query protocol used to make the call
69: *
70: * @var string
71: */
72: protected $protocol = 'source';
73:
74: /**
75: * String name of this protocol class
76: *
77: * @var string
78: */
79: protected $name = 'source';
80:
81: /**
82: * Longer string name of this protocol class
83: *
84: * @var string
85: */
86: protected $name_long = "Source Server";
87:
88: /**
89: * Define the Source engine type. By default it is assumed to be Source
90: *
91: * @var int
92: */
93: protected $source_engine = self::SOURCE_ENGINE;
94:
95: /**
96: * The client join link
97: *
98: * @var string
99: */
100: protected $join_link = "steam://connect/%s:%d/";
101:
102: /**
103: * Normalize settings for this protocol
104: *
105: * @var array
106: */
107: protected $normalize = [
108: // General
109: 'general' => [
110: // target => source
111: 'dedicated' => 'dedicated',
112: 'gametype' => 'game_descr',
113: 'hostname' => 'hostname',
114: 'mapname' => 'map',
115: 'maxplayers' => 'max_players',
116: 'mod' => 'game_dir',
117: 'numplayers' => 'num_players',
118: 'password' => 'password',
119: ],
120: // Individual
121: 'player' => [
122: 'name' => 'name',
123: 'score' => 'score',
124: 'time' => 'time',
125: ],
126: ];
127:
128: /**
129: * Parse the challenge response and apply it to all the packet types
130: *
131: * @param \GameQ\Buffer $challenge_buffer
132: * @return bool
133: * @throws \GameQ\Exception\Protocol
134: */
135: public function challengeParseAndApply(Buffer $challenge_buffer)
136: {
137: // Skip the header
138: $challenge_buffer->skip(4);
139:
140: if ($challenge_buffer->read() !== "\x41") {
141: return true;
142: }
143:
144: // Apply the challenge and return
145: return $this->challengeApply($challenge_buffer->read(4));
146: }
147:
148: /**
149: * Process the response
150: *
151: * @return array
152: * @throws \GameQ\Exception\Protocol
153: */
154: public function processResponse()
155: {
156: // Will hold the results when complete
157: $results = [];
158:
159: // Holds sorted response packets
160: $packets = [];
161:
162: // We need to pre-sort these for split packets so we can do extra work where needed
163: foreach ($this->packets_response as $response) {
164: $buffer = new Buffer($response);
165:
166: // Get the header of packet (long)
167: $header = $buffer->readInt32Signed();
168:
169: // Single packet
170: if ($header == -1) {
171: // We need to peek and see what kind of engine this is for later processing
172: if ($buffer->lookAhead(1) == "\x6d") {
173: $this->source_engine = self::GOLDSOURCE_ENGINE;
174: }
175:
176: $packets[] = $buffer->getBuffer();
177: continue;
178: } else {
179: // Split packet
180:
181: // Packet Id (long)
182: $packet_id = $buffer->readInt32Signed() + 10;
183:
184: // Add the buffer to the packet as another array
185: $packets[$packet_id][] = $buffer->getBuffer();
186: }
187: }
188:
189: // Free up memory
190: unset($response, $packet_id, $buffer, $header);
191:
192: // Now that we have the packets sorted we need to iterate and process them
193: foreach ($packets as $packet_id => $packet) {
194: // We first need to off load split packets to combine them
195: if (is_array($packet)) {
196: $buffer = new Buffer($this->processPackets($packet_id, $packet));
197: } else {
198: $buffer = new Buffer($packet);
199: }
200:
201: // Figure out what packet response this is for
202: $response_type = $buffer->read(1);
203:
204: // Figure out which packet response this is
205: if (!array_key_exists($response_type, $this->responses)) {
206: throw new Exception(__METHOD__ . " response type '{$response_type}' is not valid");
207: }
208:
209: // Now we need to call the proper method
210: $results = array_merge(
211: $results,
212: call_user_func_array([$this, $this->responses[$response_type]], [$buffer])
213: );
214:
215: unset($buffer);
216: }
217:
218: // Free up memory
219: unset($packets, $packet, $packet_id, $response_type);
220:
221: return $results;
222: }
223:
224: // Internal methods
225:
226: /**
227: * Process the split packets and decompress if necessary
228: *
229: * @SuppressWarnings(PHPMD.UnusedLocalVariable)
230: *
231: * @param $packet_id
232: * @param array $packets
233: * @return string
234: * @throws \GameQ\Exception\Protocol
235: */
236: protected function processPackets($packet_id, array $packets = [])
237: {
238: // Init array so we can order
239: $packs = [];
240:
241: // We have multiple packets so we need to get them and order them
242: foreach ($packets as $i => $packet) {
243: // Make a buffer so we can read this info
244: $buffer = new Buffer($packet);
245:
246: // Gold source
247: if ($this->source_engine == self::GOLDSOURCE_ENGINE) {
248: // Grab the packet number (byte)
249: $packet_number = $buffer->readInt8();
250:
251: // We need to burn the extra header (\xFF\xFF\xFF\xFF) on first loop
252: if ($i == 0) {
253: $buffer->read(4);
254: }
255:
256: // Now add the rest of the packet to the new array with the packet_number as the id so we can order it
257: $packs[$packet_number] = $buffer->getBuffer();
258: } else {
259: // Number of packets in this set (byte)
260: $buffer->readInt8();
261:
262: // The current packet number (byte)
263: $packet_number = $buffer->readInt8();
264:
265: // Check to see if this is compressed
266: // @todo: Check to make sure these decompress correctly, new changes may affect this loop.
267: if ($packet_id & 0x80000000) {
268: // Check to see if we have Bzip2 installed
269: if (!function_exists('bzdecompress')) {
270: // @codeCoverageIgnoreStart
271: throw new Exception(
272: 'Bzip2 is not installed. See http://www.php.net/manual/en/book.bzip2.php for more info.',
273: 0
274: );
275: // @codeCoverageIgnoreEnd
276: }
277:
278: // Get the length of the packet (long)
279: $packet_length = $buffer->readInt32Signed();
280:
281: // Checksum for the decompressed packet (long), burn it - doesnt work in split responses
282: $buffer->readInt32Signed();
283:
284: // Try to decompress
285: $result = bzdecompress($buffer->getBuffer());
286:
287: // Now verify the length
288: if (strlen($result) != $packet_length) {
289: // @codeCoverageIgnoreStart
290: throw new Exception(
291: "Checksum for compressed packet failed! Length expected: {$packet_length}, length
292: returned: " . strlen($result)
293: );
294: // @codeCoverageIgnoreEnd
295: }
296:
297: // We need to burn the extra header (\xFF\xFF\xFF\xFF) on first loop
298: if ($i == 0) {
299: $result = substr($result, 4);
300: }
301: } else {
302: // Get the packet length (short), burn it
303: $buffer->readInt16Signed();
304:
305: // We need to burn the extra header (\xFF\xFF\xFF\xFF) on first loop
306: if ($i == 0) {
307: $buffer->read(4);
308: }
309:
310: // Grab the rest of the buffer as a result
311: $result = $buffer->getBuffer();
312: }
313:
314: // Add this packet to the list
315: $packs[$packet_number] = $result;
316: }
317:
318: unset($buffer);
319: }
320:
321: // Free some memory
322: unset($packets, $packet);
323:
324: // Sort the packets by packet number
325: ksort($packs);
326:
327: // Now combine the packs into one and return
328: return implode("", $packs);
329: }
330:
331: /**
332: * Handles processing the details data into a usable format
333: *
334: * @param \GameQ\Buffer $buffer
335: * @return mixed
336: * @throws \GameQ\Exception\Protocol
337: */
338: protected function processDetails(Buffer $buffer)
339: {
340: // Set the result to a new result instance
341: $result = new Result();
342:
343: $result->add('protocol', $buffer->readInt8());
344: $result->add('hostname', $buffer->readString());
345: $result->add('map', $buffer->readString());
346: $result->add('game_dir', $buffer->readString());
347: $result->add('game_descr', $buffer->readString());
348: $result->add('steamappid', $buffer->readInt16());
349: $result->add('num_players', $buffer->readInt8());
350: $result->add('max_players', $buffer->readInt8());
351: $result->add('num_bots', $buffer->readInt8());
352: $result->add('dedicated', $buffer->read());
353: $result->add('os', $buffer->read());
354: $result->add('password', $buffer->readInt8());
355: $result->add('secure', $buffer->readInt8());
356:
357: // Special result for The Ship only (appid=2400)
358: if ($result->get('steamappid') == 2400) {
359: $result->add('game_mode', $buffer->readInt8());
360: $result->add('witness_count', $buffer->readInt8());
361: $result->add('witness_time', $buffer->readInt8());
362: }
363:
364: $result->add('version', $buffer->readString());
365:
366: // Because of php 5.4...
367: $edfCheck = $buffer->lookAhead(1);
368:
369: // Extra data flag
370: if (!empty($edfCheck)) {
371: $edf = $buffer->readInt8();
372:
373: if ($edf & 0x80) {
374: $result->add('port', $buffer->readInt16Signed());
375: }
376:
377: if ($edf & 0x10) {
378: $result->add('steam_id', $buffer->readInt64());
379: }
380:
381: if ($edf & 0x40) {
382: $result->add('sourcetv_port', $buffer->readInt16Signed());
383: $result->add('sourcetv_name', $buffer->readString());
384: }
385:
386: if ($edf & 0x20) {
387: $result->add('keywords', $buffer->readString());
388: }
389:
390: if ($edf & 0x01) {
391: $result->add('game_id', $buffer->readInt64());
392: }
393:
394: unset($edf);
395: }
396:
397: unset($buffer);
398:
399: return $result->fetch();
400: }
401:
402: /**
403: * Handles processing the server details from goldsource response
404: *
405: * @param \GameQ\Buffer $buffer
406: * @return array
407: * @throws \GameQ\Exception\Protocol
408: */
409: protected function processDetailsGoldSource(Buffer $buffer)
410: {
411: // Set the result to a new result instance
412: $result = new Result();
413:
414: $result->add('address', $buffer->readString());
415: $result->add('hostname', $buffer->readString());
416: $result->add('map', $buffer->readString());
417: $result->add('game_dir', $buffer->readString());
418: $result->add('game_descr', $buffer->readString());
419: $result->add('num_players', $buffer->readInt8());
420: $result->add('max_players', $buffer->readInt8());
421: $result->add('version', $buffer->readInt8());
422: $result->add('dedicated', $buffer->read());
423: $result->add('os', $buffer->read());
424: $result->add('password', $buffer->readInt8());
425:
426: // Mod section
427: $result->add('ismod', $buffer->readInt8());
428:
429: // We only run these if ismod is 1 (true)
430: if ($result->get('ismod') == 1) {
431: $result->add('mod_urlinfo', $buffer->readString());
432: $result->add('mod_urldl', $buffer->readString());
433: $buffer->skip();
434: $result->add('mod_version', $buffer->readInt32Signed());
435: $result->add('mod_size', $buffer->readInt32Signed());
436: $result->add('mod_type', $buffer->readInt8());
437: $result->add('mod_cldll', $buffer->readInt8());
438: }
439:
440: $result->add('secure', $buffer->readInt8());
441: $result->add('num_bots', $buffer->readInt8());
442:
443: unset($buffer);
444:
445: return $result->fetch();
446: }
447:
448: /**
449: * Handles processing the player data into a usable format
450: *
451: * @param \GameQ\Buffer $buffer
452: * @return mixed
453: * @throws \GameQ\Exception\Protocol
454: */
455: protected function processPlayers(Buffer $buffer)
456: {
457: // Set the result to a new result instance
458: $result = new Result();
459:
460: // Pull out the number of players
461: $num_players = $buffer->readInt8();
462:
463: // Player count
464: $result->add('num_players', $num_players);
465:
466: // No players so no need to look any further
467: if ($num_players == 0) {
468: return $result->fetch();
469: }
470:
471: // Players list
472: while ($buffer->getLength()) {
473: $result->addPlayer('id', $buffer->readInt8());
474: $result->addPlayer('name', $buffer->readString());
475: $result->addPlayer('score', $buffer->readInt32Signed());
476: $result->addPlayer('time', $buffer->readFloat32());
477: }
478:
479: unset($buffer);
480:
481: return $result->fetch();
482: }
483:
484: /**
485: * Handles processing the rules data into a usable format
486: *
487: * @param \GameQ\Buffer $buffer
488: * @return mixed
489: * @throws \GameQ\Exception\Protocol
490: */
491: protected function processRules(Buffer $buffer)
492: {
493: // Set the result to a new result instance
494: $result = new Result();
495:
496: // Count the number of rules
497: $num_rules = $buffer->readInt16Signed();
498:
499: // Add the count of the number of rules this server has
500: $result->add('num_rules', $num_rules);
501:
502: // Rules
503: while ($buffer->getLength()) {
504: $result->add($buffer->readString(), $buffer->readString());
505: }
506:
507: unset($buffer);
508:
509: return $result->fetch();
510: }
511: }
512: