diff --git a/Facepunch.Steamworks.Test/Serverlist.cs b/Facepunch.Steamworks.Test/Serverlist.cs index 03992ec..b724e3d 100644 --- a/Facepunch.Steamworks.Test/Serverlist.cs +++ b/Facepunch.Steamworks.Test/Serverlist.cs @@ -285,5 +285,44 @@ public void CustomList() query.Dispose(); } } + + [TestMethod] + public void PlayerList() + { + using ( var client = new Facepunch.Steamworks.Client( 252490 ) ) + { + var filter = new Facepunch.Steamworks.ServerList.Filter(); + filter.Add( "appid", client.AppId.ToString() ); + filter.Add( "gamedir", "rust" ); + filter.Add( "secure", "1" ); + + using ( var query = client.ServerList.Internet( filter ) ) + { + + for ( int i = 0; i < 1000; i++ ) + { + client.Update(); + System.Threading.Thread.Sleep( 10 ); + + if ( query.Responded.Count > 0 && query.Responded.Any( x => x.Players > 5 ) ) + break; + + if ( query.Finished ) + break; + } + + query.Dispose(); + + var server = query.Responded.First( x => x.Players > 5 ); + server.UpdateRules(); + + foreach ( var rule in server.Rules ) + { + Console.WriteLine( rule.Key + " = " + rule.Value ); + } + + } + } + } } } diff --git a/Facepunch.Steamworks/Client.ServerList.Request.cs b/Facepunch.Steamworks/Client.ServerList.Request.cs index 8362c0f..47b6952 100644 --- a/Facepunch.Steamworks/Client.ServerList.Request.cs +++ b/Facepunch.Steamworks/Client.ServerList.Request.cs @@ -16,71 +16,6 @@ public class Request : IDisposable public Action OnUpdate; - public struct Server - { - public string Name { get; set; } - public int Ping { get; set; } - public string GameDir { get; set; } - public string Map { get; set; } - public string Description { get; set; } - public uint AppId { get; set; } - public int Players { get; set; } - public int MaxPlayers { get; set; } - public int BotPlayers { get; set; } - public bool Passworded { get; set; } - public bool Secure { get; set; } - public uint LastTimePlayed { get; set; } - public int Version { get; set; } - public string[] Tags { get; set; } - public ulong SteamId { get; set; } - - public uint Address { get; set; } - - public int ConnectionPort { get; set; } - - public int QueryPort { get; set; } - - public string AddressString - { - get - { - return string.Format( "{0}.{1}.{2}.{3}", ( Address >> 24 ) & 0xFFul, ( Address >> 16 ) & 0xFFul, ( Address >> 8 ) & 0xFFul, Address & 0xFFul ); - } - } - public string ConnectionAddress - { - get - { - return string.Format( "{0}.{1}.{2}.{3}:{4}", ( Address >> 24 ) & 0xFFul, ( Address >> 16 ) & 0xFFul, ( Address >> 8 ) & 0xFFul, Address & 0xFFul, ConnectionPort ); - } - } - - internal static Server FromSteam( gameserveritem_t item ) - { - return new Server() - { - Address = item.m_NetAdr.m_unIP, - ConnectionPort = item.m_NetAdr.m_usConnectionPort, - QueryPort = item.m_NetAdr.m_usQueryPort, - Name = item.m_szServerName, - Ping = item.m_nPing, - GameDir = item.m_szGameDir, - Map = item.m_szMap, - Description = item.m_szGameDescription, - AppId = item.m_nAppID, - Players = item.m_nPlayers, - MaxPlayers = item.m_nMaxPlayers, - BotPlayers = item.m_nBotPlayers, - Passworded = item.m_bPassword, - Secure = item.m_bSecure, - LastTimePlayed = item.m_ulTimeLastPlayed, - Version = item.m_nServerVersion, - Tags = item.m_szGameTags == null ? null : item.m_szGameTags.Split( ',' ), - SteamId = item.m_steamID - }; - } - } - /// /// A list of servers that responded. If you're only interested in servers that responded since you /// last updated, then simply clear this list. @@ -145,7 +80,6 @@ void UpdateCustomQuery() filter.Add( "gameaddr", server ); } - filter.Start(); Id = client.native.servers.RequestInternetServerList( client.AppId, filter.NativeArray, filter.Count, IntPtr.Zero ); filter.Free(); @@ -227,11 +161,11 @@ private void OnServer( gameserveritem_t info ) { if ( info.m_bHadSuccessfulResponse ) { - Responded.Add( Server.FromSteam( info ) ); + Responded.Add( Server.FromSteam( client, info ) ); } else { - Unresponsive.Add( Server.FromSteam( info ) ); + Unresponsive.Add( Server.FromSteam( client, info ) ); } } diff --git a/Facepunch.Steamworks/Client.ServerList.cs b/Facepunch.Steamworks/Client.ServerList.cs index b4e46a0..99c298d 100644 --- a/Facepunch.Steamworks/Client.ServerList.cs +++ b/Facepunch.Steamworks/Client.ServerList.cs @@ -164,27 +164,27 @@ public Request Favourites() return request; } - public void AddToHistory( Request.Server server ) + public void AddToHistory( Server server ) { // client.native.matchmaking } - public void RemoveFromHistory( Request.Server server ) + public void RemoveFromHistory( Server server ) { // } - public void AddToFavourite( Request.Server server ) + public void AddToFavourite( Server server ) { // client.native.matchmaking } - public void RemoveFromFavourite( Request.Server server ) + public void RemoveFromFavourite( Server server ) { // } - public bool IsFavourite( Request.Server server ) + public bool IsFavourite( Server server ) { return false; } diff --git a/Facepunch.Steamworks/Client/Server.cs b/Facepunch.Steamworks/Client/Server.cs new file mode 100644 index 0000000..3c5d7d2 --- /dev/null +++ b/Facepunch.Steamworks/Client/Server.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using Valve.Steamworks; + +namespace Facepunch.Steamworks +{ + public class Server + { + internal Client client; + + internal Server( Client c ) + { + client = c; + } + + public string Name { get; set; } + public int Ping { get; set; } + public string GameDir { get; set; } + public string Map { get; set; } + public string Description { get; set; } + public uint AppId { get; set; } + public int Players { get; set; } + public int MaxPlayers { get; set; } + public int BotPlayers { get; set; } + public bool Passworded { get; set; } + public bool Secure { get; set; } + public uint LastTimePlayed { get; set; } + public int Version { get; set; } + public string[] Tags { get; set; } + public ulong SteamId { get; set; } + + public uint Address { get; set; } + + public int ConnectionPort { get; set; } + + public int QueryPort { get; set; } + + public string AddressString + { + get + { + return string.Format( "{0}.{1}.{2}.{3}", ( Address >> 24 ) & 0xFFul, ( Address >> 16 ) & 0xFFul, ( Address >> 8 ) & 0xFFul, Address & 0xFFul ); + } + } + public string ConnectionAddress + { + get + { + return string.Format( "{0}.{1}.{2}.{3}:{4}", ( Address >> 24 ) & 0xFFul, ( Address >> 16 ) & 0xFFul, ( Address >> 8 ) & 0xFFul, Address & 0xFFul, ConnectionPort ); + } + } + + internal static Server FromSteam( Client c, gameserveritem_t item ) + { + return new Server( c ) + { + Address = item.m_NetAdr.m_unIP, + ConnectionPort = item.m_NetAdr.m_usConnectionPort, + QueryPort = item.m_NetAdr.m_usQueryPort, + Name = item.m_szServerName, + Ping = item.m_nPing, + GameDir = item.m_szGameDir, + Map = item.m_szMap, + Description = item.m_szGameDescription, + AppId = item.m_nAppID, + Players = item.m_nPlayers, + MaxPlayers = item.m_nMaxPlayers, + BotPlayers = item.m_nBotPlayers, + Passworded = item.m_bPassword, + Secure = item.m_bSecure, + LastTimePlayed = item.m_ulTimeLastPlayed, + Version = item.m_nServerVersion, + Tags = item.m_szGameTags == null ? null : item.m_szGameTags.Split( ',' ), + SteamId = item.m_steamID + }; + } + + public Dictionary Rules; + public Action OnServerRules; + + public void UpdateRules() + { + + // + // + // TEMPORARY, WE NEED TO WRITE OUR OWN VERSION OF THIS, DOESN'T WORK ON SPLIT PACKETS ETC + // + // + + using ( var q = new SourceServerQuery( AddressString, ConnectionPort ) ) + { + Rules = q.GetRules(); + } + + if ( OnServerRules != null && Rules != null ) + OnServerRules(); + } + } +} \ No newline at end of file diff --git a/Facepunch.Steamworks/Client/ServerQuery.cs b/Facepunch.Steamworks/Client/ServerQuery.cs new file mode 100644 index 0000000..55a7096 --- /dev/null +++ b/Facepunch.Steamworks/Client/ServerQuery.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Net; +using System.Net.Sockets; +using System.IO; + +internal class SourceServerQuery :IDisposable +{ + public class PlayersResponse + { + public short player_count; + public List players = new List(); + + public class Player + { + public String name { get; set; } + public int score { get; set; } + public float playtime { get; set; } + } + } + + private IPEndPoint endPoint; + + private Socket socket; + private UdpClient client; + + // send & receive timeouts + private int send_timeout = 2500; + private int receive_timeout = 2500; + + // raw response returned from the server + private byte[] raw_data; + + private int offset = 0; + + // constants + private readonly byte[] FFFFFFFF = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; + + public SourceServerQuery( String ip, int port ) + { + this.endPoint = new IPEndPoint( IPAddress.Parse( ip ), port ); + } + + /// + /// Get a list of currently in-game clients on the specified gameserver. + /// Please note: the playtime is stored as a float in seconds, you might want to convert it. + /// + /// See https://developer.valvesoftware.com/wiki/Server_queries#A2S_PLAYER for more Information + /// + /// A PLayersResponse Object containing the name, score and playtime of each player + public PlayersResponse GetPlayerList() + { + // open socket if not already open + this.GetSocket(); + // we don't need the header, so set pointer to where the payload begins + this.offset = 5; + + try + { + PlayersResponse pr = new PlayersResponse(); + + // since A2S_PLAYER requests require a valid challenge, get it first + byte[] challenge = this.GetChallenge(0x55, true); + + byte[] request = new byte[challenge.Length + this.FFFFFFFF.Length + 1]; + Array.Copy( this.FFFFFFFF, 0, request, 0, this.FFFFFFFF.Length ); + request[this.FFFFFFFF.Length] = 0x55; + Array.Copy( challenge, 0, request, this.FFFFFFFF.Length + 1, challenge.Length ); + + this.socket.Send( request ); + + // MODIFIED BY ZACKBOE + // Increased byte size from 1024 in order to receive more player data + // Previously returned a socket exception at >~ 51 players. + this.raw_data = new byte[2048]; + // END MODIFICATION + this.socket.Receive( this.raw_data ); + + byte player_count = this.ReadByte(); + + // fill up the list of players + for ( int i = 0; i < player_count; i++ ) + { + this.ReadByte(); + + PlayersResponse.Player p = new PlayersResponse.Player(); + + p.name = this.ReadString(); + p.score = this.ReadInt32(); + p.playtime = this.ReadFloat(); + + pr.players.Add( p ); + } + + pr.player_count = player_count; + + return pr; + } + catch ( SocketException e ) + { + return null; + } + } + + /// + /// Get a list of all publically available CVars ("rules") from the server. + /// Note: Due to a bug in the Source Engine, it might happen that some CVars/values are cut off. + /// + /// Example: mp_idlemaxtime = [nothing] + /// Only Valve can fix that. + /// + /// A RulesResponse Object containing a Name-Value pair of each CVar + public Dictionary GetRules() + { + // open udpclient if not already open + this.GetClient(); + + try + { + var d = new Dictionary(); + + // similar to A2S_PLAYER requests, A2S_RULES require a valid challenge + byte[] challenge = this.GetChallenge(0x56, false); + + byte[] request = new byte[challenge.Length + this.FFFFFFFF.Length + 1]; + Array.Copy( this.FFFFFFFF, 0, request, 0, this.FFFFFFFF.Length ); + request[this.FFFFFFFF.Length] = 0x56; + Array.Copy( challenge, 0, request, this.FFFFFFFF.Length + 1, challenge.Length ); + + this.client.Send( request, request.Length ); + + // + // Since A2S_RULES responses might be split up into several packages/compressed, we have to do a special handling of them + // + int bytesRead; + + // this will keep our assembled message + byte[] buffer = new byte[4096]; + + // send first request + + this.raw_data = this.client.Receive( ref this.endPoint ); + + bytesRead = this.raw_data.Length; + + // reset pointer + this.offset = 0; + + int is_split = this.ReadInt32(); + int requestid = this.ReadInt32(); + + this.offset = 4; + + // response is split up into several packets + if ( this.PacketIsSplit( is_split ) ) + { + bool isCompressed = false; + byte[] splitData; + int packetCount, packetNumber, requestId; + int packetsReceived = 1; + int packetChecksum = 0; + int packetSplit = 0; + short splitSize; + int uncompressedSize = 0; + List splitPackets = new List(); + + do + { + // unique request id + requestId = this.ReverseBytes( this.ReadInt32() ); + isCompressed = this.PacketIsCompressed( requestId ); + + packetCount = this.ReadByte(); + packetNumber = this.ReadByte() + 1; + // so we know how big our byte arrays have to be + splitSize = this.ReadInt16(); + splitSize -= 4; // fix + + if ( packetsReceived == 1 ) + { + for ( int i = 0; i < packetCount; i++ ) + { + splitPackets.Add( new byte[] { } ); + } + } + + // if the packets are compressed, get some data to decompress them + if ( isCompressed ) + { + uncompressedSize = ReverseBytes( this.ReadInt32() ); + packetChecksum = ReverseBytes( this.ReadInt32() ); + } + + // ommit header in first packet + if ( packetNumber == 1 ) this.ReadInt32(); + + splitData = new byte[splitSize]; + splitPackets[packetNumber - 1] = this.ReadBytes(); + + // fixes a case where the returned package might still contain a character after the last \0 terminator (truncated name => value) + // please note: this therefore also removes the value of said variable, but atleast the program won't crash + if ( splitPackets[packetNumber - 1].Length - 1 > 0 && splitPackets[packetNumber - 1][splitPackets[packetNumber - 1].Length - 1] != 0x00 ) + { + splitPackets[packetNumber - 1][splitPackets[packetNumber - 1].Length - 1] = 0x00; + } + + // reset pointer again, so we can copy over the contents + this.offset = 0; + + if ( packetsReceived < packetCount ) + { + + this.raw_data = this.client.Receive( ref this.endPoint ); + bytesRead = this.raw_data.Length; + + // continue with the next packets + packetSplit = this.ReadInt32(); + packetsReceived++; + } + else + { + // all packets received + bytesRead = 0; + } + } + while ( packetsReceived <= packetCount && bytesRead > 0 && packetSplit == -2 ); + + // decompress + if ( isCompressed ) + { + buffer = ReassemblePacket( splitPackets, true, uncompressedSize, packetChecksum ); + } + else + { + buffer = ReassemblePacket( splitPackets, false, 0, 0 ); + } + } + else + { + buffer = this.raw_data; + } + + // move our final result over to handle it + this.raw_data = buffer; + + // omitting header + this.offset += 1; + var count = this.ReadInt16(); + + for ( int i = 0; i < count; i++ ) + { + var k = this.ReadString(); + var v = this.ReadString(); + + if ( !d.ContainsKey( k ) ) + d.Add( k, v ); + } + + return d; + } + catch ( SocketException e ) + { + Console.WriteLine( e ); + return null; + } + } + + /// + /// Close all currently open socket/UdpClient connections + /// + public void Dispose() + { + if ( this.socket != null ) this.socket.Close(); + if ( this.client != null ) this.client.Close(); + } + + /// + /// Open up a new Socket-based connection to a server, if not already open. + /// + private void GetSocket() + { + if ( this.socket == null ) + { + this.socket = new Socket( + AddressFamily.InterNetwork, + SocketType.Dgram, + ProtocolType.Udp ); + + this.socket.SendTimeout = this.send_timeout; + this.socket.ReceiveTimeout = this.receive_timeout; + + this.socket.Connect( this.endPoint ); + } + } + + /// + /// Create a new UdpClient connection to a server (mostly used for multi-packet answers) + /// + private void GetClient() + { + if ( this.client == null ) + { + this.client = new UdpClient(); + this.client.Connect( this.endPoint ); + this.client.DontFragment = true; + + this.client.Client.SendTimeout = this.send_timeout; + this.client.Client.ReceiveTimeout = this.receive_timeout; + } + } + + /// + /// Reassmble a multi-packet response. + /// + /// The packets to assemble + /// true: packets are compressed; false: not + /// The size of the message after decompression (for comparison) + /// Validation of the result + /// A byte-array containing all packets assembled together/decompressed. + private byte[] ReassemblePacket( List splitPackets, bool isCompressed, int uncompressedSize, int packetChecksum ) + { + byte[] packetData, tmpData; + packetData = new byte[0]; + + foreach ( byte[] splitPacket in splitPackets ) + { + if ( splitPacket == null ) + { + throw new Exception(); + } + + tmpData = packetData; + packetData = new byte[tmpData.Length + splitPacket.Length]; + + MemoryStream memStream = new MemoryStream(packetData); + memStream.Write( tmpData, 0, tmpData.Length ); + memStream.Write( splitPacket, 0, splitPacket.Length ); + } + + if ( isCompressed ) + { + throw new System.NotImplementedException(); + } + + return packetData; + } + + /// + /// Invert the Byte-order Mark of an value, used for compatibility between Little <-> Large BOM + /// + /// The value to invert + /// BOM-inversed value (if needed), otherwise the original value + private int ReverseBytes( int value ) + { + byte[] bytes = BitConverter.GetBytes(value); + if ( BitConverter.IsLittleEndian ) + { + Array.Reverse( bytes ); + } + return BitConverter.ToInt32( bytes, 0 ); + } + + /// + /// Determine whetever or not a message is compressed. + /// Simply detects if the most significant bit is 1. + /// + /// The value to check + /// true, if message is compressed, false otherwise + private bool PacketIsCompressed( int value ) + { + return ( value & 0x8000 ) != 0; + } + + /// + /// Determine whetever or not a message is split up. + /// + /// The value to check + /// true, if message is split up, false otherwise + private bool PacketIsSplit( int paket ) + { + return ( paket == -2 ); + } + + /// + /// Request the 4-byte challenge id from the server, required for A2S_RULES and A2S_PLAYER. + /// + /// The type of message to request the challenge for (see constants) + /// Request method to use (performance reasons) + /// A Byte Array (4-bytes) containing the challenge + private Byte[] GetChallenge( byte type, bool socket = true ) + { + byte[] request = new byte[this.FFFFFFFF.Length + this.FFFFFFFF.Length + 1]; + Array.Copy( this.FFFFFFFF, 0, request, 0, this.FFFFFFFF.Length ); + request[FFFFFFFF.Length] = type; + Array.Copy( this.FFFFFFFF, 0, request, this.FFFFFFFF.Length + 1, this.FFFFFFFF.Length ); + + byte[] raw_response = new byte[24]; + byte[] challenge = new byte[4]; + + // using sockets + if ( socket ) + { + this.socket.Send( request ); + this.socket.Receive( raw_response ); + } + else + { + this.client.Send( request, request.Length ); + raw_response = this.client.Receive( ref this.endPoint ); + } + + Array.Copy( raw_response, 5, challenge, 0, 4 ); // change this valve modifies the protocol! + + return challenge; + } + + /// + /// Read a single byte value from our raw data. + /// + /// A single Byte at the next Offset Address + private Byte ReadByte() + { + byte[] b = new byte[1]; + Array.Copy( this.raw_data, this.offset, b, 0, 1 ); + + this.offset++; + return b[0]; + } + + /// + /// Read all remaining Bytes from our raw data. + /// Used for multi-packet responses. + /// + /// All remaining data + private Byte[] ReadBytes() + { + int size = (this.raw_data.Length - this.offset - 4); + if ( size < 1 ) return new Byte[] { }; + + byte[] b = new byte[size]; + Array.Copy( this.raw_data, this.offset, b, 0, this.raw_data.Length - this.offset - 4 ); + + this.offset += ( this.raw_data.Length - this.offset - 4 ); + return b; + } + + /// + /// Read a 32-Bit Integer value from the next offset address. + /// + /// The Int32 Value found at the offset address + private Int32 ReadInt32() + { + byte[] b = new byte[4]; + Array.Copy( this.raw_data, this.offset, b, 0, 4 ); + + this.offset += 4; + return BitConverter.ToInt32( b, 0 ); + } + + /// + /// Read a 16-Bit Integer (also called "short") value from the next offset address. + /// + /// The Int16 Value found at the offset address + private Int16 ReadInt16() + { + byte[] b = new byte[2]; + Array.Copy( this.raw_data, this.offset, b, 0, 2 ); + + this.offset += 2; + return BitConverter.ToInt16( b, 0 ); + } + + /// + /// Read a Float value from the next offset address. + /// + /// The Float Value found at the offset address + private float ReadFloat() + { + byte[] b = new byte[4]; + Array.Copy( this.raw_data, this.offset, b, 0, 4 ); + + this.offset += 4; + return BitConverter.ToSingle( b, 0 ); + } + + /// + /// Read a String until its end starting from the next offset address. + /// Reading stops once the method detects a 0x00 Character at the next position (\0 terminator) + /// + /// The String read + private String ReadString() + { + byte[] cache = new byte[1] { 0x01 }; + String output = ""; + + while ( cache[0] != 0x00 ) + { + if ( this.offset == this.raw_data.Length ) break; // fixes Valve's inability to code a proper query protocol + Array.Copy( this.raw_data, this.offset, cache, 0, 1 ); + this.offset++; + + if ( cache[0] != 0x00) + output += Encoding.UTF8.GetString( cache ); + } + + return output; + } +} \ No newline at end of file diff --git a/Facepunch.Steamworks/Server.cs b/Facepunch.Steamworks/Server.cs new file mode 100644 index 0000000..4fd4eb6 --- /dev/null +++ b/Facepunch.Steamworks/Server.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Valve.Steamworks; + +namespace Facepunch.Steamworks +{ + public partial class ServerList + { + public class Server + { + public string Name { get; set; } + public int Ping { get; set; } + public string GameDir { get; set; } + public string Map { get; set; } + public string Description { get; set; } + public uint AppId { get; set; } + public int Players { get; set; } + public int MaxPlayers { get; set; } + public int BotPlayers { get; set; } + public bool Passworded { get; set; } + public bool Secure { get; set; } + public uint LastTimePlayed { get; set; } + public int Version { get; set; } + public string[] Tags { get; set; } + public ulong SteamId { get; set; } + + public uint Address { get; set; } + + public int ConnectionPort { get; set; } + + public int QueryPort { get; set; } + + public string AddressString + { + get + { + return string.Format( "{0}.{1}.{2}.{3}", ( Address >> 24 ) & 0xFFul, ( Address >> 16 ) & 0xFFul, ( Address >> 8 ) & 0xFFul, Address & 0xFFul ); + } + } + public string ConnectionAddress + { + get + { + return string.Format( "{0}.{1}.{2}.{3}:{4}", ( Address >> 24 ) & 0xFFul, ( Address >> 16 ) & 0xFFul, ( Address >> 8 ) & 0xFFul, Address & 0xFFul, ConnectionPort ); + } + } + + internal static Server FromSteam( gameserveritem_t item ) + { + return new Server() + { + Address = item.m_NetAdr.m_unIP, + ConnectionPort = item.m_NetAdr.m_usConnectionPort, + QueryPort = item.m_NetAdr.m_usQueryPort, + Name = item.m_szServerName, + Ping = item.m_nPing, + GameDir = item.m_szGameDir, + Map = item.m_szMap, + Description = item.m_szGameDescription, + AppId = item.m_nAppID, + Players = item.m_nPlayers, + MaxPlayers = item.m_nMaxPlayers, + BotPlayers = item.m_nBotPlayers, + Passworded = item.m_bPassword, + Secure = item.m_bSecure, + LastTimePlayed = item.m_ulTimeLastPlayed, + Version = item.m_nServerVersion, + Tags = item.m_szGameTags == null ? null : item.m_szGameTags.Split( ',' ), + SteamId = item.m_steamID + }; + } + } + } +}