From 871305bd722048e5d232e11733fb878b11b35263 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Mon, 30 Mar 2026 01:35:38 +0800 Subject: [PATCH] Add server status display and protocol version upgrade handling - Introduced a new `ServerStatusInfo` class to encapsulate server status data including MOTD, player counts, and version information. - Implemented `ServerStatusDisplay` to format and display server status information in both classic and TUI modes. - Added protocol version upgrade logic in `ProtocolHandler` to determine the highest supported protocol version for multi-version servers. - Updated translations to support new server status labels and messages. - Created `ServerStatusPanelBuilder` for TUI to visually represent server status with player information and connection details. --- AGENTS.md | 1 + .../Protocol/Handlers/Protocol18.cs | 588 ++++++++++-------- MinecraftClient/Protocol/ProtocolHandler.cs | 56 ++ .../Protocol/ServerStatusDisplay.cs | 118 ++++ MinecraftClient/Protocol/ServerStatusInfo.cs | 30 + .../Translations/Translations.Designer.cs | 60 ++ .../Resources/Translations/Translations.resx | 34 +- MinecraftClient/Tui/MainTuiView.cs | 13 + MinecraftClient/Tui/McColorParser.cs | 27 +- .../Tui/ServerStatusPanelBuilder.cs | 296 +++++++++ 10 files changed, 954 insertions(+), 269 deletions(-) create mode 100644 MinecraftClient/Protocol/ServerStatusDisplay.cs create mode 100644 MinecraftClient/Protocol/ServerStatusInfo.cs create mode 100644 MinecraftClient/Tui/ServerStatusPanelBuilder.cs diff --git a/AGENTS.md b/AGENTS.md index 40f216f7..fec9d8f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - Minecraft Console Client (MCC) is a cross-platform text/TUI client for Minecraft Java Edition. - Primary scope: connect to servers, send chat and commands, receive text, automate gameplay/admin tasks, and extend behavior through built-in bots or runtime C# scripts. - Secondary scope: protocol/version adaptation tooling, docs site, legacy GUI wrapper, and debug tooling. +- Decompiled server source for both the old and new MC versions in `$MCC_REPO/MinecraftOfficial/-decompiled/` ## Build / Run - Init submodules first: `git submodule update --init --recursive` diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index aa004b5e..5329cdfb 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -449,7 +449,7 @@ namespace MinecraftClient.Protocol.Handlers McClient.Instance?.GetCookie(cookieName, out cookieData); SendCookieResponse(cookieName, cookieData); break; - + // Ignore other packets at this stage default: return true; @@ -467,7 +467,7 @@ namespace MinecraftClient.Protocol.Handlers McClient.Instance?.GetCookie(cookieName, out cookieData); SendCookieResponse(cookieName, cookieData); break; - + case ConfigurationPacketTypesIn.Disconnect: handler.OnConnectionLost(ChatBot.DisconnectReason.InGameKick, dataTypes.ReadNextChat(packetData)); @@ -509,7 +509,7 @@ namespace MinecraftClient.Protocol.Handlers var dimensionIdMap = isDimension ? new Dictionary() : null; var attributeIdMap = isAttribute ? new Dictionary() : null; var enchantmentIdMap = isEnchantment ? new Dictionary() : null; - + for (var i = 0; i < entryCount; i++) { var entryId = dataTypes.ReadNextString(packetData); @@ -537,7 +537,7 @@ namespace MinecraftClient.Protocol.Handlers else if (isEnchantment) enchantmentIdMap!.Add(i, entryId); } - + if (isChat) ChatParser.ReadChatType(availableChats!); else if (isDimension) @@ -553,7 +553,7 @@ namespace MinecraftClient.Protocol.Handlers } break; - + case ConfigurationPacketTypesIn.RemoveResourcePack: if (dataTypes.ReadNextBool(packetData)) // Has UUID dataTypes.ReadNextUUID(packetData); // UUID @@ -562,24 +562,24 @@ namespace MinecraftClient.Protocol.Handlers case ConfigurationPacketTypesIn.ResourcePack: HandleResourcePackPacket(packetData); break; - + case ConfigurationPacketTypesIn.StoreCookie: var name = dataTypes.ReadNextString(packetData); var data = dataTypes.ReadNextByteArray(packetData); McClient.Instance?.SetCookie(name, data); break; - + case ConfigurationPacketTypesIn.Transfer: var host = dataTypes.ReadNextString(packetData); var port = dataTypes.ReadNextVarInt(packetData); - + McClient.Instance?.Transfer(host, port); break; - + case ConfigurationPacketTypesIn.KnownDataPacks: var knownPacksCount = dataTypes.ReadNextVarInt(packetData); List<(string, string, string)> knownDataPacks = new(); - + for (var i = 0; i < knownPacksCount; i++) { var nameSpace = dataTypes.ReadNextString(packetData); @@ -645,7 +645,7 @@ namespace MinecraftClient.Protocol.Handlers currentState == CurrentState.Login, innerException.GetType()), innerException); - + SentrySdk.AddBreadcrumb(new Breadcrumb("S -> C Packet", "network", new Dictionary() { { "Packet ID", packetId.ToString() }, @@ -786,26 +786,26 @@ namespace MinecraftClient.Protocol.Handlers switch (protocolVersion) { case >= MC_1_16_Version: - { - switch (protocolVersion) { - case >= MC_1_19_Version: - dimensionTypeName = - dataTypes.ReadNextString(packetData); // Dimension Type: Identifier - break; - case >= MC_1_16_2_Version: - dimensionType = - dataTypes.ReadNextNbt( - packetData); // Dimension Type: NBT Tag Compound - break; - default: - dataTypes.ReadNextString(packetData); - break; - } + switch (protocolVersion) + { + case >= MC_1_19_Version: + dimensionTypeName = + dataTypes.ReadNextString(packetData); // Dimension Type: Identifier + break; + case >= MC_1_16_2_Version: + dimensionType = + dataTypes.ReadNextNbt( + packetData); // Dimension Type: NBT Tag Compound + break; + default: + dataTypes.ReadNextString(packetData); + break; + } - currentDimension = 0; - break; - } + currentDimension = 0; + break; + } case >= MC_1_9_1_Version: currentDimension = dataTypes.ReadNextInt(packetData); break; @@ -820,27 +820,27 @@ namespace MinecraftClient.Protocol.Handlers dataTypes.ReadNextByte(packetData); // Difficulty - 1.13 and below break; case >= MC_1_16_Version: - { - var dimensionName = - dataTypes.ReadNextString( - packetData); // Dimension Name (World Name) - 1.16 and above - - if (handler.GetTerrainEnabled()) { - switch (protocolVersion) - { - case >= MC_1_16_2_Version and <= MC_1_18_2_Version: - World.StoreOneDimension(dimensionName, dimensionType!); - World.SetDimension(dimensionName); - break; - default: - World.SetDimension(dimensionTypeName!); - break; - } - } + var dimensionName = + dataTypes.ReadNextString( + packetData); // Dimension Name (World Name) - 1.16 and above - break; - } + if (handler.GetTerrainEnabled()) + { + switch (protocolVersion) + { + case >= MC_1_16_2_Version and <= MC_1_18_2_Version: + World.StoreOneDimension(dimensionName, dimensionType!); + World.SetDimension(dimensionName); + break; + default: + World.SetDimension(dimensionTypeName!); + break; + } + } + + break; + } } } @@ -1354,7 +1354,7 @@ namespace MinecraftClient.Protocol.Handlers case PacketTypesIn.Respawn: string? dimensionTypeNameRespawn = null; Dictionary? dimensionTypeRespawn = null; - + if (protocolVersion >= MC_1_16_Version) { switch (protocolVersion) @@ -1386,27 +1386,27 @@ namespace MinecraftClient.Protocol.Handlers switch (protocolVersion) { case >= MC_1_16_Version: - { - var dimensionName = - dataTypes.ReadNextString( - packetData); // Dimension Name (World Name) - 1.16 and above - - if (handler.GetTerrainEnabled()) { - switch (protocolVersion) - { - case >= MC_1_16_2_Version and <= MC_1_18_2_Version: - World.StoreOneDimension(dimensionName, dimensionTypeRespawn!); - World.SetDimension(dimensionName); - break; - default: - World.SetDimension(dimensionTypeNameRespawn!); - break; - } - } + var dimensionName = + dataTypes.ReadNextString( + packetData); // Dimension Name (World Name) - 1.16 and above - break; - } + if (handler.GetTerrainEnabled()) + { + switch (protocolVersion) + { + case >= MC_1_16_2_Version and <= MC_1_18_2_Version: + World.StoreOneDimension(dimensionName, dimensionTypeRespawn!); + World.SetDimension(dimensionName); + break; + default: + World.SetDimension(dimensionTypeNameRespawn!); + break; + } + } + + break; + } case < MC_1_14_Version: dataTypes.ReadNextByte(packetData); // Difficulty - 1.13 and below break; @@ -1453,77 +1453,77 @@ namespace MinecraftClient.Protocol.Handlers handler.OnRespawn(); break; case PacketTypesIn.PlayerPositionAndLook: - { - int teleportId; - Location location; - float yaw, pitch; - int locMask; + { + int teleportId; + Location location; + float yaw, pitch; + int locMask; - if (protocolVersion >= MC_1_21_2_Version) - { - teleportId = dataTypes.ReadNextVarInt(packetData); - location = new Location( - dataTypes.ReadNextDouble(packetData), // X - dataTypes.ReadNextDouble(packetData), // Y - dataTypes.ReadNextDouble(packetData) // Z - ); - dataTypes.ReadNextDouble(packetData); // Delta X - dataTypes.ReadNextDouble(packetData); // Delta Y - dataTypes.ReadNextDouble(packetData); // Delta Z - yaw = dataTypes.ReadNextFloat(packetData); - pitch = dataTypes.ReadNextFloat(packetData); - locMask = dataTypes.ReadNextInt(packetData); // Int flags (was Byte before 1.21.2) - } - else - { - location = new Location( - dataTypes.ReadNextDouble(packetData), // X - dataTypes.ReadNextDouble(packetData), // Y - dataTypes.ReadNextDouble(packetData) // Z - ); - yaw = dataTypes.ReadNextFloat(packetData); - pitch = dataTypes.ReadNextFloat(packetData); - locMask = dataTypes.ReadNextByte(packetData); - teleportId = protocolVersion >= MC_1_9_Version - ? dataTypes.ReadNextVarInt(packetData) : -1; - } - - if (handler.GetTerrainEnabled() || handler.GetEntityHandlingEnabled()) - { - if (protocolVersion >= MC_1_8_Version) + if (protocolVersion >= MC_1_21_2_Version) { - var currentLocation = handler.GetCurrentLocation(); - location.X = (locMask & 1 << 0) != 0 ? currentLocation.X + location.X : location.X; - location.Y = (locMask & 1 << 1) != 0 ? currentLocation.Y + location.Y : location.Y; - location.Z = (locMask & 1 << 2) != 0 ? currentLocation.Z + location.Z : location.Z; + teleportId = dataTypes.ReadNextVarInt(packetData); + location = new Location( + dataTypes.ReadNextDouble(packetData), // X + dataTypes.ReadNextDouble(packetData), // Y + dataTypes.ReadNextDouble(packetData) // Z + ); + dataTypes.ReadNextDouble(packetData); // Delta X + dataTypes.ReadNextDouble(packetData); // Delta Y + dataTypes.ReadNextDouble(packetData); // Delta Z + yaw = dataTypes.ReadNextFloat(packetData); + pitch = dataTypes.ReadNextFloat(packetData); + locMask = dataTypes.ReadNextInt(packetData); // Int flags (was Byte before 1.21.2) } - } - - if (teleportId >= 0) - { - LastYaw = yaw; - LastPitch = pitch; - handler.UpdateLocation(location, yaw, pitch); - SendPacket(PacketTypesOut.TeleportConfirm, DataTypes.GetVarInt(teleportId)); - - if (Config.Main.Advanced.TemporaryFixBadpacket) + else { - SendLocationUpdate(location, true, false, yaw, pitch, true); + location = new Location( + dataTypes.ReadNextDouble(packetData), // X + dataTypes.ReadNextDouble(packetData), // Y + dataTypes.ReadNextDouble(packetData) // Z + ); + yaw = dataTypes.ReadNextFloat(packetData); + pitch = dataTypes.ReadNextFloat(packetData); + locMask = dataTypes.ReadNextByte(packetData); + teleportId = protocolVersion >= MC_1_9_Version + ? dataTypes.ReadNextVarInt(packetData) : -1; + } - if (teleportId == 1) + if (handler.GetTerrainEnabled() || handler.GetEntityHandlingEnabled()) + { + if (protocolVersion >= MC_1_8_Version) + { + var currentLocation = handler.GetCurrentLocation(); + location.X = (locMask & 1 << 0) != 0 ? currentLocation.X + location.X : location.X; + location.Y = (locMask & 1 << 1) != 0 ? currentLocation.Y + location.Y : location.Y; + location.Z = (locMask & 1 << 2) != 0 ? currentLocation.Z + location.Z : location.Z; + } + } + + if (teleportId >= 0) + { + LastYaw = yaw; + LastPitch = pitch; + handler.UpdateLocation(location, yaw, pitch); + SendPacket(PacketTypesOut.TeleportConfirm, DataTypes.GetVarInt(teleportId)); + + if (Config.Main.Advanced.TemporaryFixBadpacket) + { SendLocationUpdate(location, true, false, yaw, pitch, true); - } - } - else - { - handler.UpdateLocation(location, yaw, pitch); - LastYaw = yaw; - LastPitch = pitch; - } - if (protocolVersion is >= MC_1_17_Version and < MC_1_19_4_Version) - dataTypes.ReadNextBool(packetData); // Dismount Vehicle - 1.17 to 1.19.3 - } + if (teleportId == 1) + SendLocationUpdate(location, true, false, yaw, pitch, true); + } + } + else + { + handler.UpdateLocation(location, yaw, pitch); + LastYaw = yaw; + LastPitch = pitch; + } + + if (protocolVersion is >= MC_1_17_Version and < MC_1_19_4_Version) + dataTypes.ReadNextBool(packetData); // Dismount Vehicle - 1.17 to 1.19.3 + } break; case PacketTypesIn.ChunkData: if (handler.GetTerrainEnabled()) @@ -1679,26 +1679,26 @@ namespace MinecraftClient.Protocol.Handlers { // 1.8 - 1.13 case < MC_1_13_2_Version: - { - var directionAndType = dataTypes.ReadNextByte(packetData); - byte direction, type; - - // 1.12.2+ - if (protocolVersion >= MC_1_12_2_Version) { - direction = (byte)(directionAndType & 0xF); - type = (byte)(directionAndType >> 4 & 0xF); - } - else // 1.8 - 1.12 - { - direction = (byte)(directionAndType >> 4 & 0xF); - type = (byte)(directionAndType & 0xF); - } + var directionAndType = dataTypes.ReadNextByte(packetData); + byte direction, type; - mapIcon.Type = (MapIconType)type; - mapIcon.Direction = direction; - break; - } + // 1.12.2+ + if (protocolVersion >= MC_1_12_2_Version) + { + direction = (byte)(directionAndType & 0xF); + type = (byte)(directionAndType >> 4 & 0xF); + } + else // 1.8 - 1.12 + { + direction = (byte)(directionAndType >> 4 & 0xF); + type = (byte)(directionAndType & 0xF); + } + + mapIcon.Type = (MapIconType)type; + mapIcon.Direction = direction; + break; + } // 1.13.2+ case >= MC_1_13_2_Version: mapIcon.Type = (MapIconType)dataTypes.ReadNextVarInt(packetData); @@ -2290,7 +2290,7 @@ namespace MinecraftClient.Protocol.Handlers handler.OnPluginChannelMessage(channel, packetData.ToArray()); return pForge.HandlePluginMessage(channel, packetData, ref currentDimension); case PacketTypesIn.Disconnect: - handler.OnConnectionLost(ChatBot.DisconnectReason.InGameKick, + handler.OnConnectionLost(ChatBot.DisconnectReason.InGameKick, dataTypes.ReadNextChat(packetData)); return false; case PacketTypesIn.SetCompression: @@ -2427,17 +2427,17 @@ namespace MinecraftClient.Protocol.Handlers if (handler.GetEntityHandlingEnabled()) { var entity = dataTypes.ReadNextEntity(packetData, entityPalette, false); - + if (protocolVersion >= MC_1_20_2_Version) { if (entity.Type == EntityType.Player) handler.OnSpawnPlayer(entity.ID, entity.UUID, entity.Location, (byte)entity.Yaw, (byte)entity.Pitch); else handler.OnSpawnEntity(entity); - + break; } - + handler.OnSpawnEntity(entity); } @@ -2648,7 +2648,7 @@ namespace MinecraftClient.Protocol.Handlers var numberOfProperties = protocolVersion >= MC_1_17_Version ? dataTypes.ReadNextVarInt(packetData) : dataTypes.ReadNextInt(packetData); - + Dictionary keys = new(); for (var i = 0; i < numberOfProperties; i++) { @@ -3008,17 +3008,17 @@ namespace MinecraftClient.Protocol.Handlers McClient.Instance?.GetCookie(cookieName, out cookieData); SendCookieResponse(cookieName, cookieData); break; - + case PacketTypesIn.StoreCookie: var cookieName2 = dataTypes.ReadNextString(packetData); var cookieData2 = dataTypes.ReadNextByteArray(packetData); McClient.Instance?.SetCookie(cookieName2, cookieData2); break; - + case PacketTypesIn.Transfer: var host = dataTypes.ReadNextString(packetData); var port = dataTypes.ReadNextVarInt(packetData); - + McClient.Instance?.Transfer(host, port); break; @@ -3286,17 +3286,17 @@ namespace MinecraftClient.Protocol.Handlers switch (protocolVersion) { case >= MC_1_19_2_Version and < MC_1_20_2_Version: - { - if (uuid == Guid.Empty) - fullLoginPacket.AddRange(dataTypes.GetBool(false)); // Has UUID - else { - fullLoginPacket.AddRange(dataTypes.GetBool(true)); // Has UUID - fullLoginPacket.AddRange(DataTypes.GetUUID(uuid)); // UUID - } + if (uuid == Guid.Empty) + fullLoginPacket.AddRange(dataTypes.GetBool(false)); // Has UUID + else + { + fullLoginPacket.AddRange(dataTypes.GetBool(true)); // Has UUID + fullLoginPacket.AddRange(DataTypes.GetUUID(uuid)); // UUID + } - break; - } + break; + } case >= MC_1_20_2_Version: uuid = handler.GetUserUuid(); @@ -3324,42 +3324,42 @@ namespace MinecraftClient.Protocol.Handlers // Encryption request case 0x01: - { - isOnlineMode = true; - var serverId = dataTypes.ReadNextString(packetData); - var serverPublicKey = dataTypes.ReadNextByteArray(packetData); - var token = dataTypes.ReadNextByteArray(packetData); + { + isOnlineMode = true; + var serverId = dataTypes.ReadNextString(packetData); + var serverPublicKey = dataTypes.ReadNextByteArray(packetData); + var token = dataTypes.ReadNextByteArray(packetData); - var shouldAuthetnicate = false; + var shouldAuthetnicate = false; - if (protocolVersion >= MC_1_20_6_Version) - shouldAuthetnicate = dataTypes.ReadNextBool(packetData); - - return StartEncryption(handler.GetUserUuidStr(), handler.GetSessionID(), - Config.Main.General.AccountType, token, serverId, - serverPublicKey, playerKeyPair, session, shouldAuthetnicate); - } + if (protocolVersion >= MC_1_20_6_Version) + shouldAuthetnicate = dataTypes.ReadNextBool(packetData); + + return StartEncryption(handler.GetUserUuidStr(), handler.GetSessionID(), + Config.Main.General.AccountType, token, serverId, + serverPublicKey, playerKeyPair, session, shouldAuthetnicate); + } // Login successful case 0x02: - { - log.Info($"§8{Translations.mcc_server_offline}"); - currentState = protocolVersion < MC_1_20_2_Version - ? CurrentState.Play - : CurrentState.Configuration; - - if (protocolVersion >= MC_1_20_2_Version) - SendPacket(0x03, new List()); - - if (!pForge.CompleteForgeHandshake()) { - log.Error($"§8{Translations.error_forge}"); - return false; - } + log.Info($"§8{Translations.mcc_server_offline}"); + currentState = protocolVersion < MC_1_20_2_Version + ? CurrentState.Play + : CurrentState.Configuration; - StartUpdating(); - return true; //No need to check session or start encryption - } + if (protocolVersion >= MC_1_20_2_Version) + SendPacket(0x03, new List()); + + if (!pForge.CompleteForgeHandshake()) + { + log.Error($"§8{Translations.error_forge}"); + return false; + } + + StartUpdating(); + return true; //No need to check session or start encryption + } default: HandlePacket(packetId, packetData); break; @@ -3392,7 +3392,7 @@ namespace MinecraftClient.Protocol.Handlers if (session.SessionPreCheckTask.Result) // PreCheck Success needCheckSession = false; } - + // 1.20.6++ if (shouldAuthetnicate) needCheckSession = true; @@ -3465,51 +3465,51 @@ namespace MinecraftClient.Protocol.Handlers handler.OnConnectionLost(ChatBot.DisconnectReason.LoginRejected, ChatParser.ParseText(dataTypes.ReadNextString(packetData))); return false; - + //Login successful case 0x02: - { - var uuidReceived = protocolVersion >= MC_1_16_Version - ? dataTypes.ReadNextUUID(packetData) - : Guid.Parse(dataTypes.ReadNextString(packetData)); - var userName = dataTypes.ReadNextString(packetData); - Tuple[]? playerProperty = null; - if (protocolVersion >= MC_1_19_Version) { - var count = dataTypes.ReadNextVarInt(packetData); // Number Of Properties - playerProperty = new Tuple[count]; - for (var i = 0; i < count; ++i) + var uuidReceived = protocolVersion >= MC_1_16_Version + ? dataTypes.ReadNextUUID(packetData) + : Guid.Parse(dataTypes.ReadNextString(packetData)); + var userName = dataTypes.ReadNextString(packetData); + Tuple[]? playerProperty = null; + if (protocolVersion >= MC_1_19_Version) { - var name = dataTypes.ReadNextString(packetData); - var value = dataTypes.ReadNextString(packetData); - var isSigned = dataTypes.ReadNextBool(packetData); - var signature = isSigned ? dataTypes.ReadNextString(packetData) : string.Empty; - playerProperty[i] = new Tuple(name, value, signature); + var count = dataTypes.ReadNextVarInt(packetData); // Number Of Properties + playerProperty = new Tuple[count]; + for (var i = 0; i < count; ++i) + { + var name = dataTypes.ReadNextString(packetData); + var value = dataTypes.ReadNextString(packetData); + var isSigned = dataTypes.ReadNextBool(packetData); + var signature = isSigned ? dataTypes.ReadNextString(packetData) : string.Empty; + playerProperty[i] = new Tuple(name, value, signature); + } } + + // Strict Error Handling (removed in 1.21.2) + if (protocolVersion >= MC_1_20_6_Version && protocolVersion < MC_1_21_2_Version) + dataTypes.ReadNextBool(packetData); + + currentState = protocolVersion < MC_1_20_2_Version + ? CurrentState.Play + : CurrentState.Configuration; + + if (protocolVersion >= MC_1_20_2_Version) + SendPacket(0x03, new List()); + + handler.OnLoginSuccess(uuidReceived, userName, playerProperty); + + if (!pForge.CompleteForgeHandshake()) + { + log.Error($"§8{Translations.error_forge_encrypt}"); + return false; + } + + StartUpdating(); + return true; } - - // Strict Error Handling (removed in 1.21.2) - if (protocolVersion >= MC_1_20_6_Version && protocolVersion < MC_1_21_2_Version) - dataTypes.ReadNextBool(packetData); - - currentState = protocolVersion < MC_1_20_2_Version - ? CurrentState.Play - : CurrentState.Configuration; - - if (protocolVersion >= MC_1_20_2_Version) - SendPacket(0x03, new List()); - - handler.OnLoginSuccess(uuidReceived, userName, playerProperty); - - if (!pForge.CompleteForgeHandshake()) - { - log.Error($"§8{Translations.error_forge_encrypt}"); - return false; - } - - StartUpdating(); - return true; - } default: HandlePacket(packetId, packetData); break; @@ -3548,15 +3548,15 @@ namespace MinecraftClient.Protocol.Handlers dataTypes.GetString(BehindCursor.Replace(' ', (char)0x00))); break; case >= MC_1_8_Version: - { - tabCompletePacket = dataTypes.ConcatBytes(tabCompletePacket, dataTypes.GetString(BehindCursor)); + { + tabCompletePacket = dataTypes.ConcatBytes(tabCompletePacket, dataTypes.GetString(BehindCursor)); - if (protocolVersion >= MC_1_9_Version) - tabCompletePacket = dataTypes.ConcatBytes(tabCompletePacket, assumeCommand); + if (protocolVersion >= MC_1_9_Version) + tabCompletePacket = dataTypes.ConcatBytes(tabCompletePacket, assumeCommand); - tabCompletePacket = dataTypes.ConcatBytes(tabCompletePacket, hasPosition); - break; - } + tabCompletePacket = dataTypes.ConcatBytes(tabCompletePacket, hasPosition); + break; + } default: tabCompletePacket = dataTypes.ConcatBytes(dataTypes.GetString(BehindCursor)); break; @@ -3620,7 +3620,8 @@ namespace MinecraftClient.Protocol.Handlers if (dataTypes.ReadNextVarInt(packetData) != 0x00) return false; - var result = dataTypes.ReadNextString(packetData); // Get the Json data + // Get the Json data + var result = dataTypes.ReadNextString(packetData); if (Config.Logging.DebugMessages) { @@ -3651,7 +3652,44 @@ namespace MinecraftClient.Protocol.Handlers // Check for forge on the server. Protocol18Forge.ServerInfoCheckForge(jsonObj, ref forgeInfo); - // Complete the normal status exchange so the probe connection closes cleanly server-side. + int onlinePlayers = 0, maxPlayers = 0; + List samplePlayers = []; + + if (jsonObj["players"] is System.Text.Json.Nodes.JsonObject playersObj) + { + if (playersObj["online"] is { } onlineNode) + onlinePlayers = int.Parse(onlineNode.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture); + if (playersObj["max"] is { } maxNode) + maxPlayers = int.Parse(maxNode.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture); + if (playersObj["sample"] is System.Text.Json.Nodes.JsonArray sampleArray) + { + foreach (var entry in sampleArray) + { + if (entry is not System.Text.Json.Nodes.JsonObject playerObj) continue; + samplePlayers.Add(new ServerStatusInfo.SamplePlayer + { + Name = playerObj["name"]?.GetStringValue() ?? "", + Id = playerObj["id"]?.GetStringValue() ?? "" + }); + } + } + } + + string motdRaw = ""; + if (jsonObj["description"] is { } descNode) + motdRaw = descNode.ToJsonString(); + + string? faviconBase64 = null; + if (jsonObj["favicon"] is { } faviconNode) + { + var faviconStr = faviconNode.GetStringValue(); + const string prefix = "data:image/png;base64,"; + faviconBase64 = faviconStr.StartsWith(prefix, StringComparison.Ordinal) + ? faviconStr[prefix.Length..] + : faviconStr; + } + + long pingMs = -1; try { long pingPayload = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -3663,7 +3701,10 @@ namespace MinecraftClient.Protocol.Handlers { packetData = new Queue(socketWrapper.ReadDataRAW(packetLength)); if (dataTypes.ReadNextVarInt(packetData) == 0x01) - dataTypes.ReadNextLong(packetData); + { + long pongPayload = dataTypes.ReadNextLong(packetData); + pingMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - pingPayload; + } } } catch @@ -3671,9 +3712,28 @@ namespace MinecraftClient.Protocol.Handlers // Some servers may close the probe connection immediately after the status response. } + var statusInfo = new ServerStatusInfo + { + Host = host, + Port = port, + VersionName = version, + ProtocolVersion = protocolVersion, + OnlinePlayers = onlinePlayers, + MaxPlayers = maxPlayers, + SamplePlayers = samplePlayers, + MotdRaw = motdRaw, + FaviconBase64 = faviconBase64, + PingMs = pingMs + }; + + ProtocolHandler.TryUpgradeProtocolVersion(version, ref protocolVersion); + statusInfo.ResolvedProtocol = protocolVersion; + ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.mcc_server_protocol, version, protocolVersion + (forgeInfo is not null ? Translations.mcc_with_forge : ""))); + ServerStatusDisplay.Show(statusInfo); + return true; } finally @@ -3792,7 +3852,7 @@ namespace MinecraftClient.Protocol.Handlers SendMessageAcknowledgment(ConsumeAcknowledgment()); } } - + /// /// Send a chat command to the server, with or without signing based on the online mode and version. /// @@ -3917,7 +3977,7 @@ namespace MinecraftClient.Protocol.Handlers return false; } } - + /// /// Send a chat message to the server /// @@ -4541,7 +4601,7 @@ namespace MinecraftClient.Protocol.Handlers packet.AddRange(dataTypes.GetFloat(LastYaw)); packet.AddRange(dataTypes.GetFloat(LastPitch)); } - + SendPacket(PacketTypesOut.UseItem, packet); return true; } @@ -4604,12 +4664,12 @@ namespace MinecraftClient.Protocol.Handlers if (playerInventory?.Items is null) return false; - int[] slotWindowIds = [36, 37, 38, 39, 40, 41, 42, 43, 44]; + int[] slotWindowIds = [36, 37, 38, 39, 40, 41, 42, 43, 44]; var currentSlot = ((McClient)handler).GetCurrentSlot(); - + playerInventory.Items.TryGetValue(slotWindowIds[currentSlot], out var item); packet.AddRange(dataTypes.GetItemSlot(item, itemPalette)); - + packet.Add(0); // cursorX packet.Add(0); // cursorY packet.Add(0); // cursorZ @@ -4626,12 +4686,12 @@ namespace MinecraftClient.Protocol.Handlers packet.AddRange(DataTypes.GetVarInt(dataTypes.GetBlockFace(face))); break; } - + packet.AddRange(dataTypes.GetFloat(cursorX)); // cursorX packet.AddRange(dataTypes.GetFloat(cursorY)); // cursorY packet.AddRange(dataTypes.GetFloat(cursorZ)); // cursorZ - - if(protocolVersion >= MC_1_14_Version) + + if (protocolVersion >= MC_1_14_Version) packet.Add(0); // insideBlock = false if (protocolVersion >= MC_1_21_2_Version) @@ -4639,7 +4699,7 @@ namespace MinecraftClient.Protocol.Handlers if (protocolVersion >= MC_1_19_Version) packet.AddRange(DataTypes.GetVarInt(sequenceId)); - + SendPacket(PacketTypesOut.PlayerBlockPlacement, packet); return true; } @@ -5235,7 +5295,7 @@ namespace MinecraftClient.Protocol.Handlers return false; } } - + public bool SendCookieResponse(string name, byte[]? data) { try @@ -5244,7 +5304,7 @@ namespace MinecraftClient.Protocol.Handlers var hasPayload = data is not null; packet.AddRange(dataTypes.GetString(name)); // Identifier packet.AddRange(dataTypes.GetBool(hasPayload)); // Has payload - + if (hasPayload) packet.AddRange(dataTypes.GetArray(data!)); // Payload Data Array Size + Data Array @@ -5262,7 +5322,7 @@ namespace MinecraftClient.Protocol.Handlers SendPacket(PacketTypesOut.CookieResponse, packet); break; } - + McClient.Instance?.DeleteCookie(name); return true; } @@ -5293,17 +5353,17 @@ namespace MinecraftClient.Protocol.Handlers packet.AddRange(dataTypes.GetString(dataPack.Item3)); } - switch(currentState) + switch (currentState) { - case CurrentState.Configuration: + case CurrentState.Configuration: SendPacket(ConfigurationPacketTypesOut.KnownDataPacks, packet); break; - + case CurrentState.Play: SendPacket(PacketTypesOut.KnownDataPacks, packet); break; } - + return true; } catch (SocketException) @@ -5319,7 +5379,7 @@ namespace MinecraftClient.Protocol.Handlers return false; } } - + private byte[] GenerateSalt() { var salt = new byte[8]; diff --git a/MinecraftClient/Protocol/ProtocolHandler.cs b/MinecraftClient/Protocol/ProtocolHandler.cs index c000385e..a8568f38 100644 --- a/MinecraftClient/Protocol/ProtocolHandler.cs +++ b/MinecraftClient/Protocol/ProtocolHandler.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net.Http; using System.Net.Sockets; using System.Text; +using System.Text.RegularExpressions; using DnsClient; using MinecraftClient.Protocol.Handlers; using MinecraftClient.Protocol.Handlers.Forge; @@ -388,6 +389,61 @@ namespace MinecraftClient.Protocol } } + private static readonly Regex VersionTokenRegex = new(@"\d+\.\d+(?:\.\d+)?", RegexOptions.Compiled); + + private static readonly int[] SupportedProtocols18 = + [ + 4, 5, 47, 107, 108, 109, 110, 210, 315, 316, 335, 338, 340, 393, 401, 404, + 477, 480, 485, 490, 498, 573, 575, 578, 735, 736, 751, 753, 754, 755, 756, + 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771, + 772, 773, 774, 775 + ]; + + /// + /// For multi-version servers (e.g. "Requires MC 1.8 / 1.21"), try to find the + /// highest protocol version that both the server and MCC support. + /// Returns true if the protocol was upgraded, with the new value in + /// . + /// + public static bool TryUpgradeProtocolVersion(string versionName, ref int protocolVersion) + { + if (string.IsNullOrEmpty(versionName)) + return false; + + var matches = VersionTokenRegex.Matches(versionName); + if (matches.Count < 2) + return false; + + int bestProtocol = protocolVersion; + string bestVersion = ""; + + foreach (Match m in matches) + { + int proto = MCVer2ProtocolVersion(m.Value); + if (proto <= 0) + continue; + if (Array.IndexOf(SupportedProtocols18, proto) < 0) + continue; + if (proto > bestProtocol) + { + bestProtocol = proto; + bestVersion = m.Value; + } + } + + if (bestProtocol > protocolVersion && bestVersion.Length > 0) + { + ConsoleIO.WriteLineFormatted("§8" + string.Format( + Translations.mcc_server_info_version_upgrade, + ProtocolVersion2MCVer(protocolVersion), protocolVersion, + "§a" + bestVersion + "§8", bestProtocol)); + protocolVersion = bestProtocol; + return true; + } + + return false; + } + /// /// Convert a network protocol version number to human-readable Minecraft version number /// diff --git a/MinecraftClient/Protocol/ServerStatusDisplay.cs b/MinecraftClient/Protocol/ServerStatusDisplay.cs new file mode 100644 index 00000000..21e386ab --- /dev/null +++ b/MinecraftClient/Protocol/ServerStatusDisplay.cs @@ -0,0 +1,118 @@ +using System; +using System.Text; +using MinecraftClient.Protocol.Message; + +namespace MinecraftClient.Protocol +{ + internal static class ServerStatusDisplay + { + private const int MaxSamplePlayers = 10; + + internal static void Show(ServerStatusInfo info) + { + if (ConsoleIO.Backend is Tui.TuiConsoleBackend tuiBackend) + ShowTui(info, tuiBackend); + else + ShowClassic(info); + } + + private static void ShowClassic(ServerStatusInfo info) + { + var sb = new StringBuilder(); + + sb.AppendLine(); + sb.Append("§8§m"); + sb.Append(new string('-', 50)); + sb.AppendLine("§r"); + + if (!string.IsNullOrEmpty(info.MotdRaw)) + { + try + { + sb.AppendLine(ChatParser.ParseText(info.MotdRaw)); + } + catch + { + sb.AppendLine(info.MotdRaw); + } + } + + sb.Append("§f"); + sb.Append(Translations.mcc_server_info_label_server); + sb.Append(" §b"); + sb.Append(info.Host); + sb.Append("§7:§b"); + sb.AppendLine(info.Port.ToString()); + + sb.Append("§f"); + sb.Append(Translations.mcc_server_info_label_version); + sb.Append(" §b"); + sb.Append(info.VersionName); + sb.Append(" §7("); + sb.Append(string.Format(Translations.mcc_server_info_label_protocol, "§e" + info.ProtocolVersion + "§7")); + sb.AppendLine(")"); + + if (info.ResolvedProtocol != 0 && info.ResolvedProtocol != info.ProtocolVersion) + { + string resolvedMcVer = ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol); + sb.Append("§f"); + sb.Append(Translations.mcc_server_info_label_connecting_as); + sb.Append(" §a"); + sb.Append(resolvedMcVer); + sb.Append(" §7("); + sb.Append(string.Format(Translations.mcc_server_info_label_protocol, "§a" + info.ResolvedProtocol + "§7")); + sb.AppendLine(")"); + } + + if (info.PingMs >= 0) + { + sb.Append("§f"); + sb.Append(Translations.mcc_server_info_label_ping); + sb.Append(" §a"); + sb.AppendLine(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs)); + } + + sb.Append("§f"); + sb.Append(Translations.mcc_server_info_label_players); + sb.Append(" §a"); + sb.Append(info.OnlinePlayers); + sb.Append("§7/§c"); + sb.AppendLine(info.MaxPlayers.ToString()); + + if (info.SamplePlayers.Count > 0) + { + sb.Append("§f"); + sb.AppendLine(Translations.mcc_server_info_label_online); + + int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers); + for (int i = 0; i < shown; i++) + sb.AppendLine($" §a{info.SamplePlayers[i].Name}"); + + if (info.SamplePlayers.Count > shown) + sb.AppendLine($" §7{string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}"); + } + + sb.Append("§8§m"); + sb.Append(new string('-', 50)); + sb.Append("§r"); + + ConsoleIO.WriteLineFormatted(sb.ToString(), acceptnewlines: true); + } + + private static void ShowTui(ServerStatusInfo info, Tui.TuiConsoleBackend backend) + { + var view = backend.GetView(); + if (view is null) + { + ShowClassic(info); + return; + } + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var panel = Tui.ServerStatusPanelBuilder.Build(info); + view.AppendControlToLog(panel); + }); + } + } +} diff --git a/MinecraftClient/Protocol/ServerStatusInfo.cs b/MinecraftClient/Protocol/ServerStatusInfo.cs new file mode 100644 index 00000000..b864e64d --- /dev/null +++ b/MinecraftClient/Protocol/ServerStatusInfo.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace MinecraftClient.Protocol +{ + /// + /// Holds the structured result of a Minecraft server status (SLP) ping, + /// including MOTD, player counts, sample player list, version, and favicon. + /// + public sealed class ServerStatusInfo + { + public string Host { get; init; } = string.Empty; + public int Port { get; init; } + public string VersionName { get; init; } = string.Empty; + public int ProtocolVersion { get; init; } + public int ResolvedProtocol { get; set; } + public int OnlinePlayers { get; init; } + public int MaxPlayers { get; init; } + public List SamplePlayers { get; init; } = []; + public string MotdRaw { get; init; } = string.Empty; + public string? FaviconBase64 { get; init; } + public long PingMs { get; init; } + + public sealed class SamplePlayer + { + public string Name { get; init; } = string.Empty; + public string Id { get; init; } = string.Empty; + } + } +} diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 071bee44..3fc6e722 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -2269,6 +2269,66 @@ namespace MinecraftClient { } } + internal static string mcc_server_info_label_server { + get { + return ResourceManager.GetString("mcc.server_info.label_server", resourceCulture); + } + } + + internal static string mcc_server_info_label_version { + get { + return ResourceManager.GetString("mcc.server_info.label_version", resourceCulture); + } + } + + internal static string mcc_server_info_label_protocol { + get { + return ResourceManager.GetString("mcc.server_info.label_protocol", resourceCulture); + } + } + + internal static string mcc_server_info_label_players { + get { + return ResourceManager.GetString("mcc.server_info.label_players", resourceCulture); + } + } + + internal static string mcc_server_info_label_ping { + get { + return ResourceManager.GetString("mcc.server_info.label_ping", resourceCulture); + } + } + + internal static string mcc_server_info_label_ping_ms { + get { + return ResourceManager.GetString("mcc.server_info.label_ping_ms", resourceCulture); + } + } + + internal static string mcc_server_info_label_connecting_as { + get { + return ResourceManager.GetString("mcc.server_info.label_connecting_as", resourceCulture); + } + } + + internal static string mcc_server_info_label_online { + get { + return ResourceManager.GetString("mcc.server_info.label_online", resourceCulture); + } + } + + internal static string mcc_server_info_sample_more { + get { + return ResourceManager.GetString("mcc.server_info.sample_more", resourceCulture); + } + } + + internal static string mcc_server_info_version_upgrade { + get { + return ResourceManager.GetString("mcc.server_info.version_upgrade", resourceCulture); + } + } + /// /// Looks up a localized string similar to Converting session cache from disk: {0}. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index ad782375..c3acc0fc 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -830,6 +830,36 @@ Add the ID of this chat to "Authorized_Chat_Ids" field in the configuration file TestBot + + Server: + + + Version: + + + Protocol: {0} + + + Players: + + + Ping: + + + {0} ms + + + Connecting as: + + + Online: + + + ... +{0} + + + Server reported protocol {0} ({1}), upgraded to {2} ({3}) for best compatibility + Converting session cache from disk: {0} @@ -2015,10 +2045,10 @@ MCC is running with default settings. Server is in offline mode. - Server version : {0} (protocol v{1}) + Server version: {0} (protocol v{1}) - Server version : + Server version: Checking Session... diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs index 34ff06db..1106f763 100644 --- a/MinecraftClient/Tui/MainTuiView.cs +++ b/MinecraftClient/Tui/MainTuiView.cs @@ -1171,5 +1171,18 @@ namespace MinecraftClient.Tui _commandInput.Focus(); }, DispatcherPriority.Loaded); } + + #region Custom Control Append + + public void AppendControlToLog(Control control) + { + _logLines.Add(string.Empty); + _logControls.Add(control); + TrimLog(); + if (_autoScroll) + ScheduleScrollToEnd(); + } + + #endregion } } diff --git a/MinecraftClient/Tui/McColorParser.cs b/MinecraftClient/Tui/McColorParser.cs index c46b0adf..30f8f280 100644 --- a/MinecraftClient/Tui/McColorParser.cs +++ b/MinecraftClient/Tui/McColorParser.cs @@ -52,6 +52,8 @@ namespace MinecraftClient.Tui IBrush currentColor = Brushes.White; bool bold = false; bool italic = false; + bool underline = false; + bool strikethrough = false; int start = 0; for (int i = 0; i < text.Length; i++) @@ -59,7 +61,7 @@ namespace MinecraftClient.Tui if (text[i] == '§' && i + 1 < text.Length) { if (i > start) - AddRun(tb, text[start..i], currentColor, bold, italic); + AddRun(tb, text[start..i], currentColor, bold, italic, underline, strikethrough); char code = char.ToLower(text[i + 1]); @@ -68,6 +70,8 @@ namespace MinecraftClient.Tui currentColor = brush; bold = false; italic = false; + underline = false; + strikethrough = false; } else { @@ -75,10 +79,14 @@ namespace MinecraftClient.Tui { case 'l': bold = true; break; case 'o': italic = true; break; + case 'n': underline = true; break; + case 'm': strikethrough = true; break; case 'r': currentColor = Brushes.White; bold = false; italic = false; + underline = false; + strikethrough = false; break; } } @@ -89,7 +97,7 @@ namespace MinecraftClient.Tui } if (start < text.Length) - AddRun(tb, text[start..], currentColor, bold, italic); + AddRun(tb, text[start..], currentColor, bold, italic, underline, strikethrough); if (tb.Inlines?.Count == 0) { @@ -100,16 +108,29 @@ namespace MinecraftClient.Tui return tb; } - private static void AddRun(TextBlock tb, string text, IBrush color, bool bold, bool italic) + private static void AddRun(TextBlock tb, string text, IBrush color, + bool bold, bool italic, bool underline, bool strikethrough) { if (text.Length == 0) return; tb.Inlines ??= new InlineCollection(); + + TextDecorationCollection? decorations = null; + if (underline || strikethrough) + { + decorations = []; + if (underline) + decorations.Add(new TextDecoration { Location = TextDecorationLocation.Underline }); + if (strikethrough) + decorations.Add(new TextDecoration { Location = TextDecorationLocation.Strikethrough }); + } + tb.Inlines.Add(new Run(text) { Foreground = color, FontWeight = bold ? FontWeight.Bold : FontWeight.Normal, FontStyle = italic ? FontStyle.Italic : FontStyle.Normal, + TextDecorations = decorations, }); } } diff --git a/MinecraftClient/Tui/ServerStatusPanelBuilder.cs b/MinecraftClient/Tui/ServerStatusPanelBuilder.cs new file mode 100644 index 00000000..3b6e8f9f --- /dev/null +++ b/MinecraftClient/Tui/ServerStatusPanelBuilder.cs @@ -0,0 +1,296 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Layout; +using Avalonia.Media; + +namespace MinecraftClient.Tui +{ + internal static class ServerStatusPanelBuilder + { + private const int MaxSamplePlayers = 10; + private const int FaviconDisplaySize = 16; + + internal static Border Build(Protocol.ServerStatusInfo info) + { + var contentPanel = new DockPanel { Background = Brushes.Black }; + + if (info.FaviconBase64 is not null) + { + var iconGrid = BuildFaviconGrid(info.FaviconBase64, FaviconDisplaySize); + DockPanel.SetDock(iconGrid, Dock.Left); + contentPanel.Children.Add(iconGrid); + } + + var infoPanel = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(1, 0, 0, 0), + }; + + AddMotd(infoPanel, info); + AddAddress(infoPanel, info); + AddVersion(infoPanel, info); + AddConnectingAs(infoPanel, info); + AddPing(infoPanel, info); + AddPlayers(infoPanel, info); + AddSamplePlayers(infoPanel, info); + + contentPanel.Children.Add(infoPanel); + + return new Border + { + BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)), + BorderThickness = new Thickness(1), + Background = new SolidColorBrush(Color.FromArgb(240, 20, 20, 20)), + Padding = new Thickness(1, 0), + Child = contentPanel, + Margin = new Thickness(0, 1), + }; + } + + private static void AddMotd(StackPanel panel, Protocol.ServerStatusInfo info) + { + if (string.IsNullOrEmpty(info.MotdRaw)) + return; + + try + { + string motdFormatted = Protocol.Message.ChatParser.ParseText(info.MotdRaw); + foreach (string line in motdFormatted.Split('\n')) + panel.Children.Add(McColorParser.CreateColoredTextBlock(line, TextWrapping.NoWrap)); + } + catch + { + panel.Children.Add(new TextBlock + { + Text = info.MotdRaw, + Foreground = Brushes.White, + TextWrapping = TextWrapping.NoWrap, + }); + } + } + + private static void AddAddress(StackPanel panel, Protocol.ServerStatusInfo info) + { + var row = new TextBlock(); + row.Inlines!.Add(Label(Translations.mcc_server_info_label_server)); + row.Inlines.Add(Value(info.Host, McColors.Aqua)); + row.Inlines.Add(new Run($":{info.Port}") { Foreground = McColors.Gray }); + panel.Children.Add(row); + } + + private static void AddVersion(StackPanel panel, Protocol.ServerStatusInfo info) + { + var row = new TextBlock(); + row.Inlines!.Add(Label(Translations.mcc_server_info_label_version)); + row.Inlines.Add(Value(info.VersionName, McColors.Aqua)); + row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray }); + row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ProtocolVersion)) + { Foreground = McColors.Gray }); + row.Inlines.Add(new Run(")") { Foreground = McColors.Gray }); + panel.Children.Add(row); + } + + private static void AddConnectingAs(StackPanel panel, Protocol.ServerStatusInfo info) + { + if (info.ResolvedProtocol == 0 || info.ResolvedProtocol == info.ProtocolVersion) + return; + + string resolvedMcVer = Protocol.ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol); + var row = new TextBlock(); + row.Inlines!.Add(Label(Translations.mcc_server_info_label_connecting_as)); + row.Inlines.Add(Value(resolvedMcVer, McColors.Green)); + row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray }); + row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ResolvedProtocol)) + { Foreground = McColors.Gray }); + row.Inlines.Add(new Run(")") { Foreground = McColors.Gray }); + panel.Children.Add(row); + } + + private static void AddPing(StackPanel panel, Protocol.ServerStatusInfo info) + { + if (info.PingMs < 0) + return; + + var pingColor = info.PingMs < 100 + ? McColors.Green + : info.PingMs < 300 + ? McColors.Yellow + : McColors.Red; + + var row = new TextBlock(); + row.Inlines!.Add(Label(Translations.mcc_server_info_label_ping)); + row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs)) + { Foreground = pingColor }); + panel.Children.Add(row); + } + + private static void AddPlayers(StackPanel panel, Protocol.ServerStatusInfo info) + { + var row = new TextBlock(); + row.Inlines!.Add(Label(Translations.mcc_server_info_label_players)); + row.Inlines.Add(Value($"{info.OnlinePlayers}", McColors.Green)); + row.Inlines.Add(new Run("/") { Foreground = McColors.Gray }); + row.Inlines.Add(Value($"{info.MaxPlayers}", McColors.Red)); + panel.Children.Add(row); + } + + private static void AddSamplePlayers(StackPanel panel, Protocol.ServerStatusInfo info) + { + if (info.SamplePlayers.Count == 0) + return; + + panel.Children.Add(new TextBlock + { + Text = Translations.mcc_server_info_label_online, + Foreground = McColors.Gray, + Margin = new Thickness(0, 1, 0, 0), + }); + + int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers); + for (int i = 0; i < shown; i++) + { + panel.Children.Add(new TextBlock + { + Text = $" {info.SamplePlayers[i].Name}", + Foreground = McColors.Green, + }); + } + + if (info.SamplePlayers.Count > shown) + { + panel.Children.Add(new TextBlock + { + Text = $" {string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}", + Foreground = McColors.Gray, + }); + } + } + + private static Run Label(string text) => + new(text + " ") { Foreground = McColors.Gray }; + + private static Run Value(string text, IBrush color) => + new(text) { Foreground = color }; + + #region Favicon Rendering + + private static Grid BuildFaviconGrid(string base64Png, int displaySize) + { + byte[] pngBytes; + try + { + pngBytes = Convert.FromBase64String(base64Png); + } + catch + { + return new Grid(); + } + + int srcWidth, srcHeight; + byte[] rgba; + try + { + (srcWidth, srcHeight, rgba) = DecodePngToRgba(pngBytes); + } + catch + { + return new Grid(); + } + + int cellCols = displaySize; + int cellRows = displaySize / 2; + + var grid = new Grid(); + for (int c = 0; c < cellCols; c++) + grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto)); + for (int r = 0; r < cellRows; r++) + grid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto)); + + for (int row = 0; row < cellRows; row++) + { + for (int col = 0; col < cellCols; col++) + { + int topPixelY = row * 2; + int bottomPixelY = row * 2 + 1; + + var topColor = SamplePixel(rgba, srcWidth, srcHeight, col, topPixelY, cellCols, displaySize); + var bottomColor = SamplePixel(rgba, srcWidth, srcHeight, col, bottomPixelY, cellCols, displaySize); + + var cell = new TextBlock + { + Text = "\u2580", + Foreground = new SolidColorBrush(topColor), + Background = new SolidColorBrush(bottomColor), + Padding = new Thickness(0), + Margin = new Thickness(0), + }; + + Grid.SetRow(cell, row); + Grid.SetColumn(cell, col); + grid.Children.Add(cell); + } + } + + return grid; + } + + private static Color SamplePixel(byte[] rgba, int srcW, int srcH, int dstX, int dstY, int dstW, int dstH) + { + int srcX = dstX * srcW / dstW; + int srcY = dstY * srcH / dstH; + srcX = Math.Clamp(srcX, 0, srcW - 1); + srcY = Math.Clamp(srcY, 0, srcH - 1); + + int idx = (srcY * srcW + srcX) * 4; + if (idx + 3 >= rgba.Length) + return Color.FromRgb(0, 0, 0); + + byte r = rgba[idx]; + byte g = rgba[idx + 1]; + byte b = rgba[idx + 2]; + byte a = rgba[idx + 3]; + + return a < 128 ? Color.FromRgb(0, 0, 0) : Color.FromRgb(r, g, b); + } + + private static (int Width, int Height, byte[] Rgba) DecodePngToRgba(byte[] png) + { + using var image = new ImageMagick.MagickImage(png); + int w = (int)image.Width; + int h = (int)image.Height; + + using var pixels = image.GetPixelsUnsafe(); + var rgba = new byte[w * h * 4]; + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + var pixel = pixels.GetPixel(x, y)!; + int idx = (y * w + x) * 4; + var color = pixel.ToColor()!; + rgba[idx] = (byte)(color.R >> 8); + rgba[idx + 1] = (byte)(color.G >> 8); + rgba[idx + 2] = (byte)(color.B >> 8); + rgba[idx + 3] = (byte)(color.A >> 8); + } + } + + return (w, h, rgba); + } + + #endregion + + private static class McColors + { + public static readonly IBrush Gray = new SolidColorBrush(Color.FromRgb(170, 170, 170)); + public static readonly IBrush Aqua = new SolidColorBrush(Color.FromRgb(85, 255, 255)); + public static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(85, 255, 85)); + public static readonly IBrush Red = new SolidColorBrush(Color.FromRgb(255, 85, 85)); + public static readonly IBrush Yellow = new SolidColorBrush(Color.FromRgb(255, 255, 85)); + } + } +}