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