Skip to content

Commit 9c459c7

Browse files
committed
Add initializer tests and complete GetServerInfo function.
1 parent 40697c4 commit 9c459c7

13 files changed

Lines changed: 320 additions & 12 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System;
2+
using System.Net;
3+
using Xunit;
4+
5+
namespace SteamQueryNet.Tests
6+
{
7+
public class ServerQueryTests
8+
{
9+
private const string IP_ADDRESS = "54.37.111.216";
10+
private const string HOST_NAME = "surf.wasdzone.com";
11+
private const int PORT = 27015;
12+
13+
[Theory]
14+
[InlineData(IP_ADDRESS)]
15+
[InlineData(HOST_NAME)]
16+
public void ShouldInitializeWithProperHost(string host)
17+
{
18+
var squery = new ServerQuery(host, PORT);
19+
}
20+
21+
[Theory]
22+
[InlineData(IPEndPoint.MaxPort + 1)]
23+
[InlineData(IPEndPoint.MinPort - 1)]
24+
public void ShouldNotInitializeWithAPortOutOfRange(int port)
25+
{
26+
Assert.Throws<ArgumentException>(() =>
27+
{
28+
var squery = new ServerQuery(IP_ADDRESS, port);
29+
});
30+
}
31+
32+
[Theory]
33+
[InlineData("256.256.256.256")]
34+
[InlineData("invalidHost")]
35+
public void ShouldNotInitializeWithAnInvalidHost(string invalidHost)
36+
{
37+
Assert.Throws<ArgumentException>(() =>
38+
{
39+
var squery = new ServerQuery(invalidHost, PORT);
40+
});
41+
}
42+
}
43+
}

SteamQueryNet/SteamQueryNet.Tests/SteamQueryNet.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,8 @@
1313
<DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
1414
</ItemGroup>
1515

16+
<ItemGroup>
17+
<ProjectReference Include="..\SteamQueryNet\SteamQueryNet.csproj" />
18+
</ItemGroup>
19+
1620
</Project>
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
namespace SteamQueryNet.Enums
22
{
3-
public enum Environment
3+
public enum ServerEnvironment : byte
44
{
5-
Linux = 'l',
6-
Windows = 'w',
7-
Mac = 'm'
5+
Linux = (byte)'l',
6+
Windows = (byte)'w',
7+
Mac = (byte)'m'
88
}
99
}
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
namespace SteamQueryNet.Enums
22
{
3-
public enum ServerType
3+
public enum ServerType : byte
44
{
5-
Dedicated = 'd',
6-
NonDedicated = 'l',
7-
SourceTVRelay = 'p'
5+
Dedicated = (byte)'d',
6+
NonDedicated = (byte)'l',
7+
SourceTVRelay = (byte)'p'
88
}
99
}

SteamQueryNet/SteamQueryNet/Enums/ShipGameMode.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace SteamQueryNet.Enums
22
{
3-
public enum ShipGameMode
3+
public enum ShipGameMode : byte
44
{
55
Hunt = 0,
66
Elimination = 1,

SteamQueryNet/SteamQueryNet/Enums/VAC.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace SteamQueryNet.Enums
22
{
3-
public enum VAC
3+
public enum VAC : byte
44
{
55
Unsecured = 0,
66
Secured = 1

SteamQueryNet/SteamQueryNet/Enums/Visibility.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace SteamQueryNet.Enums
22
{
3-
public enum Visibility
3+
public enum Visibility : byte
44
{
55
Public = 0,
66
Private = 1

SteamQueryNet/SteamQueryNet/Models/ServerInfo.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using SteamQueryNet.Enums;
22
using SteamQueryNet.Models.TheShip;
3+
using SteamQueryNet.Utils;
34

45
namespace SteamQueryNet.Models
56
{
@@ -58,7 +59,7 @@ public sealed class ServerInfo
5859
/// <summary>
5960
/// Indicates the operating system of the server.
6061
/// </summary>
61-
public Environment Environment { get; set; }
62+
public ServerEnvironment Environment { get; set; }
6263

6364
/// <summary>
6465
/// Indicates whether the server requires a password.
@@ -73,6 +74,7 @@ public sealed class ServerInfo
7374
/// <summary>
7475
/// This property only exist in a response if the server is running The Ship.
7576
/// </summary>
77+
[ParseCustom]
7678
public ShipGameInfo ShipGameInfo { get; set; }
7779

7880
/// <summary>
@@ -88,32 +90,45 @@ public sealed class ServerInfo
8890
/// <summary>
8991
/// The server's game port number.
9092
/// </summary>
93+
[EDF]
9194
public short Port { get; set; }
9295

9396
/// <summary>
9497
/// Server's SteamID.
9598
/// </summary>
99+
[EDF]
96100
public long SteamID { get; set; }
97101

98102
/// <summary>
99103
/// Spectator port number for SourceTV.
100104
/// </summary>
105+
[EDF]
101106
public short SourceTVPort { get; set; }
102107

103108
/// <summary>
104109
/// Name of the spectator server for SourceTV.
105110
/// </summary>
111+
[EDF]
106112
public string SourceTVServerName { get; set; }
107113

108114
/// <summary>
109115
/// Tags that describe the game according to the server (for future use.)
110116
/// </summary>
117+
[EDF]
111118
public string Keywords { get; set; }
112119

113120
/// <summary>
114121
/// The server's 64-bit GameID. If this is present, a more accurate AppID is present in the low 24 bits.
115122
/// The earlier AppID could have been truncated as it was forced into 16-bit storage.
116123
/// </summary>
124+
[EDF]
117125
public long GameID { get; set; }
126+
127+
/// <summary>
128+
/// Calculated roundtrip time of the server.
129+
/// Warning: this value will be calculated by SteamQueryNet instead of steam itself.
130+
/// </summary>
131+
[NotParsable]
132+
public long Ping { get; set; }
118133
}
119134
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
using SteamQueryNet.Models;
2+
using SteamQueryNet.Utils;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Net;
7+
using System.Net.NetworkInformation;
8+
using System.Net.Sockets;
9+
using System.Reflection;
10+
using System.Runtime.InteropServices;
11+
using System.Text;
12+
13+
namespace SteamQueryNet
14+
{
15+
// This is not really required but imma be a good guy and create this for them people that wants to mock the ServerQuery.
16+
public interface IServerQuery
17+
{
18+
void RenewChallenge();
19+
ServerInfo GetServerInfo();
20+
}
21+
22+
public class ServerQuery : IServerQuery, IDisposable
23+
{
24+
private readonly int _port;
25+
private readonly UdpClient _client = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
26+
private IPEndPoint _ipEndpoint;
27+
28+
/// <summary>
29+
/// Reflects the udp client connection state.
30+
/// </summary>
31+
public bool IsConnected
32+
{
33+
get
34+
{
35+
return _client.Client.Connected;
36+
}
37+
}
38+
39+
/// <summary>
40+
/// Amount of time in miliseconds to terminate send operation if the server won't respond.
41+
/// </summary>
42+
public int SendTimeout { get; set; }
43+
44+
/// <summary>
45+
/// Amount of time in miliseconds to terminate receive operation if the server won't respond.
46+
/// </summary>
47+
public int ReceiveTimeout { get; set; }
48+
49+
/// <summary>
50+
/// Creates a new ServerQuery instance for Steam Server Query Operations.
51+
/// </summary>
52+
/// <param name="serverAddress">IPAddress or HostName of the server that queries will be sent.</param>
53+
/// <param name="port">Port of the server that queries will be sent.</param>
54+
public ServerQuery(string serverAddress, int port)
55+
{
56+
// Check the port range
57+
if (_port < IPEndPoint.MinPort || _port > IPEndPoint.MaxPort)
58+
{
59+
throw new ArgumentException($"Port should be between {IPEndPoint.MinPort} and {IPEndPoint.MaxPort}");
60+
}
61+
62+
_port = port;
63+
// Try to parse the serverAddress as IP first
64+
if (IPAddress.TryParse(serverAddress, out IPAddress parsedIpAddress))
65+
{
66+
// Yep its an IP.
67+
_ipEndpoint = new IPEndPoint(parsedIpAddress, _port);
68+
}
69+
else
70+
{
71+
// Nope it might be a hostname.
72+
try
73+
{
74+
IPAddress[] addresslist = Dns.GetHostAddresses(serverAddress);
75+
if (addresslist.Length > 0)
76+
{
77+
// We get the first address.
78+
_ipEndpoint = new IPEndPoint(addresslist[0], _port);
79+
}
80+
else
81+
{
82+
throw new ArgumentException($"Invalid host address {serverAddress}");
83+
}
84+
}
85+
catch (SocketException ex)
86+
{
87+
throw new ArgumentException("Could not reach the hostname.", ex);
88+
}
89+
}
90+
91+
_client.Client.SendTimeout = SendTimeout;
92+
_client.Client.ReceiveTimeout = ReceiveTimeout;
93+
_client.Connect(_ipEndpoint);
94+
}
95+
96+
public void Dispose()
97+
{
98+
_client.Close();
99+
_client.Dispose();
100+
}
101+
102+
public ServerInfo GetServerInfo()
103+
{
104+
var sInfo = new ServerInfo();
105+
const string requestPayload = "Source Engine Query\0";
106+
try
107+
{
108+
sInfo.Ping = new Ping().Send(_ipEndpoint.Address).RoundtripTime;
109+
var request = BuildRequest(RequestHeaders.A2S_INFO, Encoding.UTF8.GetBytes(requestPayload));
110+
_client.Send(request, request.Length);
111+
byte[] response = _client.Receive(ref _ipEndpoint);
112+
if (response.Length > 0)
113+
{
114+
IEnumerable<byte> lastSource = ExtractData(sInfo, response, nameof(sInfo.EDF));
115+
116+
// Handle EDF's. This part looks hideous but i will get back to this after i get done with everything. Right now this works.
117+
if ((sInfo.EDF & 0x80) > 0)
118+
{
119+
(object result, int size) = ExtractMarshalType(lastSource, sInfo.Port.GetType());
120+
sInfo.Port = (short)result;
121+
lastSource = lastSource.Skip(size);
122+
}
123+
if ((sInfo.EDF & 0x10) > 0)
124+
{
125+
(object result, int size) = ExtractMarshalType(lastSource, sInfo.SteamID.GetType());
126+
sInfo.SteamID = (long)result;
127+
lastSource = lastSource.Skip(size);
128+
}
129+
if ((sInfo.EDF & 0x40) > 0)
130+
{
131+
(object result, int size) = ExtractMarshalType(lastSource, sInfo.SourceTVPort.GetType());
132+
sInfo.SourceTVPort = (short)result;
133+
lastSource = lastSource.Skip(size);
134+
135+
IEnumerable<byte> takenBytes = lastSource.TakeWhile(x => x != 0);
136+
sInfo.SourceTVServerName = Encoding.UTF8.GetString(takenBytes.ToArray());
137+
lastSource = lastSource.Skip(takenBytes.Count() + 1);
138+
139+
}
140+
if ((sInfo.EDF & 0x20) > 0)
141+
{
142+
IEnumerable<byte> takenBytes = lastSource.TakeWhile(x => x != 0);
143+
sInfo.Keywords = Encoding.UTF8.GetString(takenBytes.ToArray());
144+
lastSource = lastSource.Skip(takenBytes.Count() + 1);
145+
}
146+
if ((sInfo.EDF & 0x01) > 0)
147+
{
148+
(object result, int size) = ExtractMarshalType(lastSource, sInfo.GameID.GetType());
149+
sInfo.GameID = (long)result;
150+
lastSource = lastSource.Skip(size);
151+
}
152+
}
153+
}
154+
catch (Exception)
155+
{
156+
// TODO: Log it.
157+
throw;
158+
}
159+
160+
return sInfo;
161+
}
162+
163+
public void RenewChallenge()
164+
{
165+
throw new NotImplementedException();
166+
}
167+
168+
private byte[] BuildRequest(byte headerCode, byte[] extraParams = null)
169+
{
170+
var request = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, headerCode };
171+
return extraParams != null ? request.Concat(extraParams).ToArray() : request;
172+
}
173+
174+
private IEnumerable<byte> ExtractData<TObject>(TObject objectRef, byte[] dataSource, string stopAt = "")
175+
where TObject : class
176+
{
177+
IEnumerable<byte> takenBytes = new List<byte>();
178+
IEnumerable<byte> strippedSource = dataSource.Skip(5);
179+
IEnumerable<PropertyInfo> propsOfObject = typeof(TObject).GetProperties()
180+
.Where(x => x.CustomAttributes.Count(y => y.AttributeType == typeof(ParseCustomAttribute)
181+
|| y.AttributeType == typeof(NotParsableAttribute)) == 0);
182+
183+
foreach (PropertyInfo property in propsOfObject)
184+
{
185+
if (property.PropertyType == typeof(string))
186+
{
187+
takenBytes = strippedSource.TakeWhile(x => x != 0);
188+
property.SetValue(objectRef, Encoding.UTF8.GetString(takenBytes.ToArray()));
189+
strippedSource = strippedSource.Skip(takenBytes.Count() + 1); // +1 for null termination
190+
}
191+
else
192+
{
193+
Type typeOfProperty = property.PropertyType.IsEnum ? property.PropertyType.GetEnumUnderlyingType() : property.PropertyType;
194+
(object result, int size) = ExtractMarshalType(strippedSource, typeOfProperty);
195+
property.SetValue(objectRef, property.PropertyType.IsEnum ? Enum.Parse(property.PropertyType, result.ToString()) : result);
196+
strippedSource = strippedSource.Skip(size);
197+
}
198+
199+
if (property.Name == stopAt)
200+
{
201+
break;
202+
}
203+
}
204+
205+
return strippedSource;
206+
}
207+
208+
private (object, int) ExtractMarshalType(IEnumerable<byte> source, Type type)
209+
{
210+
int sizeOfType = Marshal.SizeOf(type);
211+
IEnumerable<byte> takenBytes = source.Take(sizeOfType);
212+
unsafe
213+
{
214+
fixed (byte* sourcePtr = takenBytes.ToArray())
215+
{
216+
return (Marshal.PtrToStructure(new IntPtr(sourcePtr), type), sizeOfType);
217+
}
218+
}
219+
}
220+
}
221+
}

0 commit comments

Comments
 (0)