Minecraft Server Query adalah protokol sederhana yang memungkinkan Anda mendapatkan informasi terkini tentang status server dengan mengirimkan beberapa paket UDP sederhana.
The wiki memiliki penjelasan rinci tentang protokol ini dengan contoh-contoh implementasi dalam berbagai bahasa. Namun, saya tersadar betapa minimnya implementasi .Net saat ini. Setelah mencari beberapa saat, saya menemukan beberapa repositori. Solusi yang diusulkan entah berisi kesalahan sepele, atau memiliki fungsionalitas yang berkurang, meskipun, tampaknya, lebih banyak untuk memotong sesuatu.
Jadi keputusan dibuat untuk menulis implementasi saya sendiri.
Katakan padaku siapa kamu ...
Pertama, mari kita lihat apa protokol Query Minecraft itu sendiri. Menurut wiki , kami memiliki 3 jenis paket permintaan dan, karenanya, 3 jenis paket respons:
Jabat tangan
BasicStatus
FullStatus
Jenis paket pertama digunakan untuk mendapatkan ChallengeToken yang diperlukan untuk membentuk dua paket lainnya. Ini mengikat alamat IP pengirim selama 30 detik . Beban semantik dari dua sisanya jelas dari namanya.
Perlu dicatat bahwa meskipun dua permintaan terakhir berbeda satu sama lain hanya dengan penyelarasan di ujung paket, respons yang dikirim berbeda dalam cara penyajian data. Misalnya, seperti inilah jawaban BasicStatus
– FullStatus
, , short, big-endian. SessionId, - , SessionId & 0x0F0F0F0F == SessionId.
.
,
, , . API 3 .
, ChallengeToken. 3 , , : . , , 30 ? "" .
, , , .
public static async Task<ServerState> DoSomething(IPAddress host, int port) {
var mcQuery = new McQuery(host, port);
mcQuery.InitSocket();
await mcQuery.GetHandshake();
return await mcQuery.GetFullStatus();
}
. ( ).
, , . Request.
public class Request
{
//
private static readonly byte[] Magic = { 0xfe, 0xfd };
private static readonly byte[] Challenge = { 0x09 };
private static readonly byte[] Status = { 0x00 };
public byte[] Data { get; private set; }
private Request(){}
public byte RequestType => Data[2];
public static Request GetHandshakeRequest(SessionId sessionId)
{
var request = new Request();
//
var data = new List<byte>();
data.AddRange(Magic);
data.AddRange(Challenge);
data.AddRange(sessionId.GetBytes());
request.Data = data.ToArray();
return request;
}
public static Request GetBasicStatusRequest(SessionId sessionId, byte[] challengeToken)
{
if (challengeToken == null)
{
throw new ChallengeTokenIsNullException();
}
var request = new Request();
var data = new List<byte>();
data.AddRange(Magic);
data.AddRange(Status);
data.AddRange(sessionId.GetBytes());
data.AddRange(challengeToken);
request.Data = data.ToArray();
return request;
}
public static Request GetFullStatusRequest(SessionId sessionId, byte[] challengeToken)
{
if (challengeToken == null)
{
throw new ChallengeTokenIsNullException();
}
var request = new Request();
var data = new List<byte>();
data.AddRange(Magic);
data.AddRange(Status);
data.AddRange(sessionId.GetBytes());
data.AddRange(challengeToken);
data.AddRange(new byte[] {0x00, 0x00, 0x00, 0x00}); // Padding
request.Data = data.ToArray();
return request;
}
}
. . SessionId, , .
public class SessionId
{
private readonly byte[] _sessionId;
public SessionId (byte[] sessionId)
{
_sessionId = sessionId;
}
// SessionId
public static SessionId GenerateRandomId()
{
var sessionId = new byte[4];
new Random().NextBytes(sessionId);
sessionId = sessionId.Select(@byte => (byte)(@byte & 0x0F)).ToArray();
return new SessionId(sessionId);
}
public string GetString()
{
return BitConverter.ToString(_sessionId);
}
public byte[] GetBytes()
{
var sessionId = new byte[4];
Buffer.BlockCopy(_sessionId, 0, sessionId, 0, 4);
return sessionId;
}
}
, , . Response, "" .
public static class Response
{
public static byte ParseType(byte[] data)
{
return data[0];
}
//
public static SessionId ParseSessionId(byte[] data)
{
if (data.Length < 1) throw new IncorrectPackageDataException(data);
var sessionIdBytes = new byte[4];
Buffer.BlockCopy(data, 1, sessionIdBytes, 0, 4);
return new SessionId(sessionIdBytes);
}
public static byte[] ParseHandshake(byte[] data)
{
if (data.Length < 5) throw new IncorrectPackageDataException(data);
var response = BitConverter.GetBytes(int.Parse(Encoding.ASCII.GetString(data, 5, data.Length - 6)));
if (BitConverter.IsLittleEndian)
{
response = response.Reverse().ToArray();
}
return response;
}
public static ServerBasicState ParseBasicState(byte[] data)
{
if (data.Length <= 5)
throw new IncorrectPackageDataException(data);
var statusValues = new Queue<string>();
short port = -1;
data = data.Skip(5).ToArray(); // Skip Type + SessionId
var stream = new MemoryStream(data);
var sb = new StringBuilder();
int currentByte;
int counter = 0;
while ((currentByte = stream.ReadByte()) != -1)
{
if (counter > 6) break;
//
if (counter == 5)
{
byte[] portBuffer = {(byte) currentByte, (byte) stream.ReadByte()};
if (!BitConverter.IsLittleEndian)
portBuffer = portBuffer.Reverse().ToArray();
port = BitConverter.ToInt16(portBuffer); // Little-endian short
counter++;
continue;
}
// -
if (currentByte == 0x00)
{
string fieldValue = sb.ToString();
statusValues.Enqueue(fieldValue);
sb.Clear();
counter++;
}
else sb.Append((char) currentByte);
}
var serverInfo = new ServerBasicState
{
Motd = statusValues.Dequeue(),
GameType = statusValues.Dequeue(),
Map = statusValues.Dequeue(),
NumPlayers = int.Parse(statusValues.Dequeue()),
MaxPlayers = int.Parse(statusValues.Dequeue()),
HostPort = port,
HostIp = statusValues.Dequeue(),
};
return serverInfo;
}
// "" ,
// ,
public static ServerFullState ParseFullState(byte[] data)
{
var statusKeyValues = new Dictionary<string, string>();
var players = new List<string>();
var buffer = new byte[256];
Stream stream = new MemoryStream(data);
stream.Read(buffer, 0, 5); // Read Type + SessionID
stream.Read(buffer, 0, 11); // Padding: 11 bytes constant
var constant1 = new byte[] {0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00};
for (int i = 0; i < constant1.Length; i++)
Debug.Assert(constant1[i] == buffer[i], "Byte mismatch at " + i + " Val :" + buffer[i]);
var sb = new StringBuilder();
string lastKey = string.Empty;
int currentByte;
while ((currentByte = stream.ReadByte()) != -1)
{
if (currentByte == 0x00)
{
if (!string.IsNullOrEmpty(lastKey))
{
statusKeyValues.Add(lastKey, sb.ToString());
lastKey = string.Empty;
}
else
{
lastKey = sb.ToString();
if (string.IsNullOrEmpty(lastKey)) break;
}
sb.Clear();
}
else sb.Append((char) currentByte);
}
stream.Read(buffer, 0, 10); // Padding: 10 bytes constant
var constant2 = new byte[] {0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00};
for (int i = 0; i < constant2.Length; i++)
Debug.Assert(constant2[i] == buffer[i], "Byte mismatch at " + i + " Val :" + buffer[i]);
while ((currentByte = stream.ReadByte()) != -1)
{
if (currentByte == 0x00)
{
var player = sb.ToString();
if (string.IsNullOrEmpty(player)) break;
players.Add(player);
sb.Clear();
}
else sb.Append((char) currentByte);
}
ServerFullState fullState = new()
{
Motd = statusKeyValues["hostname"],
GameType = statusKeyValues["gametype"],
GameId = statusKeyValues["game_id"],
Version = statusKeyValues["version"],
Plugins = statusKeyValues["plugins"],
Map = statusKeyValues["map"],
NumPlayers = int.Parse(statusKeyValues["numplayers"]),
MaxPlayers = int.Parse(statusKeyValues["maxplayers"]),
PlayerList = players.ToArray(),
HostIp = statusKeyValues["hostip"],
HostPort = int.Parse(statusKeyValues["hostport"]),
};
return fullState;
}
}
, , .
, . . . 5 FullStatus, ChallengeToken . 2 : .
FullStatus. / /etc (5 ) .
.
public StatusWatcher(string serverName, string host, int queryPort)
{
ServerName = serverName;
_mcQuery = new McQuery(Dns.GetHostAddresses(host)[0], queryPort);
_mcQuery.InitSocket();
}
public async Task Unwatch()
{
await UpdateChallengeTokenTimer.DisposeAsync();
await UpdateServerStatusTimer.DisposeAsync();
}
public async void Watch()
{
// challengetoken 30
UpdateChallengeTokenTimer = new Timer(async obj =>
{
if (!IsOnline) return;
if(Debug)
Console.WriteLine($"[INFO] [{ServerName}] Send handshake request");
try
{
var challengeToken = await _mcQuery.GetHandshake();
// , ,
IsOnline = true;
lock (_retryCounterLock)
{
RetryCounter = 0;
}
if(Debug)
Console.WriteLine($"[INFO] [{ServerName}] ChallengeToken is set up: " + BitConverter.ToString(challengeToken));
}
// - ,
catch (Exception ex)
{
if (ex is SocketException || ex is McQueryException || ex is ChallengeTokenIsNullException)
{
if(Debug)
Console.WriteLine($"[WARNING] [{ServerName}] [UpdateChallengeTokenTimer] Server doesn't response. Try to reconnect: {RetryCounter}");
if(ex is McQueryException)
Console.Error.WriteLine(ex);
lock (_retryCounterLock)
{
RetryCounter++;
if (RetryCounter >= RetryMaxCount)
{
RetryCounter = 0;
WaitForServerAlive(); //
}
}
}
else
{
throw;
}
}
}, null, 0, GettingChallengeTokenInterval);
//
UpdateServerStatusTimer = new Timer(async obj =>
{
if (!IsOnline) return;
if(Debug)
Console.WriteLine($"[INFO] [{ServerName}] Send full status request");
try
{
var response = await _mcQuery.GetFullStatus();
IsOnline = true;
lock (_retryCounterLock)
{
RetryCounter = 0;
}
if(Debug)
Console.WriteLine($"[INFO] [{ServerName}] Full status is received");
OnFullStatusUpdated?.Invoke(this, new ServerStateEventArgs(ServerName, response));
}
//
catch (Exception ex)
{
if (ex is SocketException || ex is McQueryException || ex is ChallengeTokenIsNullException)
{
if(Debug)
Console.WriteLine($"[WARNING] [{ServerName}] [UpdateServerStatusTimer] Server doesn't response. Try to reconnect: {RetryCounter}");
if(ex is McQueryException)
Console.Error.WriteLine(ex);
lock (_retryCounterLock)
{
RetryCounter++;
if (RetryCounter >= RetryMaxCount)
{
RetryCounter = 0;
WaitForServerAlive();
}
}
}
else
{
throw;
}
}
}, null, 500, GettingStatusInterval);
}
Satu-satunya hal yang harus dilakukan adalah mengimplementasikan menunggu pemulihan koneksi. Untuk melakukan ini, kami hanya perlu memastikan bahwa kami telah menerima setidaknya beberapa jenis respons dari server. Untuk melakukan ini, kita bisa menggunakan permintaan handshake yang sama, yang tidak memerlukan ChallengeToken yang valid.
public async void WaitForServerAlive()
{
if(Debug)
Console.WriteLine($"[WARNING] [{ServerName}] Server is unavailable. Waiting for reconnection...");
//
IsOnline = false;
await Unwatch();
_mcQuery.InitSocket(); //
Timer waitTimer = null;
waitTimer = new Timer(async obj => {
try
{
await _mcQuery.GetHandshake();
// ,
IsOnline = true;
Watch();
lock (_retryCounterLock)
{
RetryCounter = 0;
}
waitTimer.Dispose();
}
// 5 ()
catch (SocketException)
{
if(Debug)
Console.WriteLine($"[WARNING] [{ServerName}] [WaitForServerAlive] Server doesn't response. Try to reconnect: {RetryCounter}");
lock (_retryCounterLock)
{
RetryCounter++;
if (RetryCounter >= RetryMaxCount)
{
if(Debug)
Console.WriteLine($"[WARNING] [{ServerName}] [WaitForServerAlive] Recreate socket");
RetryCounter = 0;
_mcQuery.InitSocket();
}
}
}
}, null, 500, 5000);
}