diff --git a/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs b/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs index caa8163..947b399 100644 --- a/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs +++ b/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs @@ -7,7 +7,9 @@ using System.Text; namespace Phantom.Agent.Minecraft.Server; public static class ServerStatusProtocol { - public static async Task<int> GetOnlinePlayerCount(ushort serverPort, CancellationToken cancellationToken) { + public readonly record struct PlayerCounts(int Online, int Maximum); + + public static async Task<PlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) { using var tcpClient = new TcpClient(); await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken); var tcpStream = tcpClient.GetStream(); @@ -17,7 +19,7 @@ public static class ServerStatusProtocol { await tcpStream.FlushAsync(cancellationToken); short messageLength = await ReadStreamHeader(tcpStream, cancellationToken); - return await ReadOnlinePlayerCount(tcpStream, messageLength * 2, cancellationToken); + return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken); } private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) { @@ -40,35 +42,53 @@ public static class ServerStatusProtocol { } } - private static async Task<int> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) { + private static async Task<PlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) { var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength); try { await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken); - - // Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§). - const byte SeparatorSecondByte = 0xA7; - - static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) { - return index > 0 && buffer[index - 1] == 0x00; - } - - int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte); - int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1); - if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) { - throw new ProtocolException("Could not find message separators in response from server."); - } - - string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1))); - if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) { - throw new ProtocolException("Could not parse online player count in response from server: " + onlinePlayerCountStr); - } - - return onlinePlayerCount; + return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(0, messageLength)); } finally { ArrayPool<byte>.Shared.Return(messageBuffer); } } + /// <summary> + /// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE. + /// </summary> + private static readonly byte[] Separator = { 0x00, 0xA7 }; + + private static PlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) { + int lastSeparator = messageBuffer.LastIndexOf(Separator); + int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator); + + if (lastSeparator == -1 || middleSeparator == -1) { + throw new ProtocolException("Could not find message separators in response from server."); + } + + var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator]; + var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..]; + + // Player counts are integers, whose maximum string length is 10 characters. + Span<char> integerStringBuffer = stackalloc char[10]; + + return new PlayerCounts( + DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"), + DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum") + ); + } + + private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) { + if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) { + throw new ProtocolException("Could not decode " + countType + " player count in response from server."); + } + + if (!int.TryParse(tempCharBuffer, out int playerCount)) { + throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString()); + } + + return playerCount; + } + public sealed class ProtocolException : Exception { internal ProtocolException(string message) : base(message) {} } diff --git a/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs b/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs index 4a81129..e7289b6 100644 --- a/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs +++ b/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs @@ -70,9 +70,9 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask { private async Task<int?> TryGetOnlinePlayerCount() { try { - int newOnlinePlayerCount = await ServerStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken); - Logger.Debug("Detected {OnlinePlayerCount} online player(s).", newOnlinePlayerCount); - return newOnlinePlayerCount; + var (online, maximum) = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken); + Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", online, maximum); + return online; } catch (ServerStatusProtocol.ProtocolException e) { Logger.Error(e.Message); return null;