diff --git a/MinecraftClient.Tests/ChatTypeHolderTests.cs b/MinecraftClient.Tests/ChatTypeHolderTests.cs new file mode 100644 index 00000000..eddfb43b --- /dev/null +++ b/MinecraftClient.Tests/ChatTypeHolderTests.cs @@ -0,0 +1,159 @@ +using MinecraftClient.Protocol.Handlers; +using MinecraftClient.Protocol.Message; + +namespace MinecraftClient.Tests; + +public sealed class ChatTypeHolderTests +{ + [Fact] + public void ReferenceHolderUsesOneBasedWireIdIn121AndNewer() + { + var dataTypes = new DataTypes(Protocol18Handler.MC_1_21_Version); + var packetData = new Queue(DataTypes.GetVarInt(2)); + + int chatTypeId = ChatParser.ReadChatTypeHolder( + dataTypes, + packetData, + Protocol18Handler.MC_1_21_Version, + out var directDecoration); + + Assert.Equal(1, chatTypeId); + Assert.Null(directDecoration); + Assert.Empty(packetData); + } + + [Fact] + public void RegistryIdRemainsUnchangedBefore121() + { + var dataTypes = new DataTypes(Protocol18Handler.MC_1_20_6_Version); + var packetData = new Queue(DataTypes.GetVarInt(2)); + + int chatTypeId = ChatParser.ReadChatTypeHolder( + dataTypes, + packetData, + Protocol18Handler.MC_1_20_6_Version, + out var directDecoration); + + Assert.Equal(2, chatTypeId); + Assert.Null(directDecoration); + Assert.Empty(packetData); + } + + [Fact] + public void DirectHolderConsumesChatAndNarrationDecorations() + { + var dataTypes = new DataTypes(Protocol18Handler.MC_1_21_Version); + var packetBytes = new List(); + packetBytes.AddRange(DataTypes.GetVarInt(0)); + AddDecoration(packetBytes, dataTypes, "chat.type.text", 0, 2); + AddDecoration(packetBytes, dataTypes, "chat.type.text.narrate", 0, 2); + var packetData = new Queue(packetBytes); + + int chatTypeId = ChatParser.ReadChatTypeHolder( + dataTypes, + packetData, + Protocol18Handler.MC_1_21_Version, + out var directDecoration); + + Assert.Equal(-1, chatTypeId); + Assert.NotNull(directDecoration); + Assert.Equal("chat.type.text", directDecoration.TranslationKey); + Assert.Equal( + [ChatParser.ChatTypeParameter.Sender, ChatParser.ChatTypeParameter.Content], + directDecoration.Parameters); + Assert.Empty(packetData); + } + + [Fact] + public void RegistryDecorationControlsParameterSelectionAndOrdering() + { + Dictionary? originalChatTypes = ChatParser.ChatId2Type; + try + { + ChatParser.ClearChatTypeDecorations(); + ChatParser.ChatId2Type = []; + var chatTypeData = new Dictionary + { + ["chat"] = new Dictionary + { + ["translation_key"] = "commands.message.display.outgoing", + ["parameters"] = new object[] { "target", "content" } + } + }; + ChatParser.ReadChatType(42, "example:custom_chat", chatTypeData); + var message = new ChatMessage( + "hello", + false, + 42, + Guid.Empty, + null, + "Alice", + "Bob", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + null, + false); + + string rendered = ChatParser.ParseSignedChat(message); + + Assert.Equal("You whisper to Bob: hello", rendered); + } + finally + { + ChatParser.ClearChatTypeDecorations(); + ChatParser.ChatId2Type = originalChatTypes; + } + } + + [Fact] + public void UnknownTranslationKeyIsUsedAsVanillaFormatPattern() + { + Dictionary? originalChatTypes = ChatParser.ChatId2Type; + try + { + ChatParser.ClearChatTypeDecorations(); + ChatParser.ChatId2Type = []; + var chatTypeData = new Dictionary + { + ["chat"] = new Dictionary + { + ["translation_key"] = "%s", + ["parameters"] = new object[] { "sender", "content" } + } + }; + ChatParser.ReadChatType(42, "ordinary:custom_chat", chatTypeData); + var message = new ChatMessage( + "hello", + false, + 42, + Guid.Empty, + null, + "Alice » hello", + null, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + null, + false); + + string rendered = ChatParser.ParseSignedChat(message); + + Assert.Equal("Alice » hello", rendered); + } + finally + { + ChatParser.ClearChatTypeDecorations(); + ChatParser.ChatId2Type = originalChatTypes; + } + } + + private static void AddDecoration( + List packetBytes, + DataTypes dataTypes, + string translationKey, + params int[] parameters) + { + packetBytes.AddRange(dataTypes.GetString(translationKey)); + packetBytes.AddRange(DataTypes.GetVarInt(parameters.Length)); + foreach (int parameter in parameters) + packetBytes.AddRange(DataTypes.GetVarInt(parameter)); + packetBytes.AddRange(dataTypes.GetNbtTag(new Dictionary())); + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 11d860cd..f868b1f9 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -3796,6 +3796,8 @@ namespace MinecraftClient { UpdateKeepAlive(); + Log.Debug(string.Format(Translations.protocol_chat_raw_message, message.content)); + List links = new(); string messageText; diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 86ad7364..eb45fa06 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -287,6 +287,7 @@ namespace MinecraftClient.Protocol.Handlers }, _ => ChatParser.ChatId2Type }; + ChatParser.ClearChatTypeDecorations(); } /// @@ -576,7 +577,6 @@ namespace MinecraftClient.Protocol.Handlers var isEnchantment = registryId == "minecraft:enchantment"; var isDialog = registryId == "minecraft:dialog"; - var availableChats = isChat ? new Dictionary() : null; var dimensionIdMap = isDimension ? new Dictionary() : null; var attributeIdMap = isAttribute ? new Dictionary() : null; var enchantmentIdMap = isEnchantment ? new Dictionary() : null; @@ -591,7 +591,7 @@ namespace MinecraftClient.Protocol.Handlers nbtData = dataTypes.ReadNextNbt(packetData); if (isChat) - availableChats!.Add(i, entryId); + ChatParser.ReadChatType(i, entryId, nbtData); else if (isDimension) { dimensionIdMap!.Add(i, entryId); @@ -611,9 +611,7 @@ namespace MinecraftClient.Protocol.Handlers handler.OnDialogRegistryData(i, entryId, dialogNbtParser.Parse(nbtData)); } - if (isChat) - ChatParser.ReadChatType(availableChats!); - else if (isDimension) + if (isDimension) { World.SetDimensionIdMap(dimensionIdMap!); if (!handler.GetTerrainEnabled() || !World.HasAnyDimension()) @@ -1232,7 +1230,8 @@ namespace MinecraftClient.Protocol.Handlers // Network Target // net.minecraft.network.message.MessageType.Serialized#write - var chatTypeId = dataTypes.ReadNextVarInt(packetData); + var chatTypeId = ChatParser.ReadChatTypeHolder( + dataTypes, packetData, protocolVersion, out var directChatTypeDecoration); var chatName = dataTypes.ReadNextChat(packetData); var targetName = dataTypes.ReadNextBool(packetData) ? dataTypes.ReadNextChat(packetData) @@ -1281,7 +1280,10 @@ namespace MinecraftClient.Protocol.Handlers } ChatMessage chat = new(message, false, chatTypeId, senderUuid, unsignedChatContent, - senderDisplayName, senderTeamName, timestamp, messageSignature, verifyResult); + senderDisplayName, senderTeamName, timestamp, messageSignature, verifyResult) + { + chatTypeDecoration = directChatTypeDecoration + }; lock (MessageSigningLock) Acknowledge(chat); handler.OnTextReceived(chat); @@ -1345,14 +1347,17 @@ namespace MinecraftClient.Protocol.Handlers break; case PacketTypesIn.ProfilelessChatMessage: var message_ = dataTypes.ReadNextChat(packetData); - var messageType_ = dataTypes.ReadNextVarInt(packetData); + var messageType_ = ChatParser.ReadChatTypeHolder( + dataTypes, packetData, protocolVersion, out var directProfilelessChatTypeDecoration); var messageName = dataTypes.ReadNextChat(packetData); var targetName_ = dataTypes.ReadNextBool(packetData) ? dataTypes.ReadNextChat(packetData) : null; - ChatMessage profilelessChat = new(message_, targetName_ ?? messageName, false, messageType_, + ChatMessage profilelessChat = new(message_, messageName, false, messageType_, Guid.Empty, true); profilelessChat.isSenderJson = false; + profilelessChat.teamName = targetName_; + profilelessChat.chatTypeDecoration = directProfilelessChatTypeDecoration; handler.OnTextReceived(profilelessChat); break; case PacketTypesIn.CombatEvent: diff --git a/MinecraftClient/Protocol/Message/ChatMessage.cs b/MinecraftClient/Protocol/Message/ChatMessage.cs index 832fa19b..762bab5f 100644 --- a/MinecraftClient/Protocol/Message/ChatMessage.cs +++ b/MinecraftClient/Protocol/Message/ChatMessage.cs @@ -35,6 +35,8 @@ namespace MinecraftClient.Protocol.Message public bool? isSignatureLegal; + internal ChatParser.ChatTypeDecoration? chatTypeDecoration; + public ChatMessage(string content, bool isJson, int chatType, Guid senderUUID, string? unsignedContent, string displayName, string? teamName, long timestamp, byte[]? signature, bool isSignatureLegal) { isSignedChat = true; diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index 02819d4d..269340ce 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -11,6 +11,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; +using MinecraftClient.Protocol.Handlers; using Tomlet; using Tomlet.Models; using static MinecraftClient.Settings; @@ -36,33 +37,49 @@ namespace MinecraftClient.Protocol.Message public static Dictionary? ChatId2Type; + internal enum ChatTypeParameter + { + Sender, + Target, + Content + } + + internal sealed record ChatTypeDecoration(string TranslationKey, ChatTypeParameter[] Parameters); + + private static readonly Dictionary ChatId2Decoration = new(); + + internal static void ClearChatTypeDecorations() + { + ChatId2Decoration.Clear(); + } + // Used to store Chat Types in 1.20.6+ - public static void ReadChatType(Dictionary data) + public static void ReadChatType(int chatId, string chatName, Dictionary? chatTypeData) { var chatTypeDictionary = ChatId2Type ?? new Dictionary(); - foreach (var (chatId, chatName) in data) + chatTypeDictionary[chatId] = chatName switch { - chatTypeDictionary[chatId] = chatName switch - { - "minecraft:chat" => MessageType.CHAT, - "minecraft:emote_command" => MessageType.EMOTE_COMMAND, - "minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING, - "minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING, - "minecraft:say_command" => MessageType.SAY_COMMAND, - "minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING, - "minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING, - _ => MessageType.CHAT, - }; - } + "minecraft:chat" => MessageType.CHAT, + "minecraft:emote_command" => MessageType.EMOTE_COMMAND, + "minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING, + "minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING, + "minecraft:say_command" => MessageType.SAY_COMMAND, + "minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING, + "minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING, + _ => MessageType.CHAT, + }; ChatId2Type = chatTypeDictionary; + + if (TryReadChatTypeDecoration(chatTypeData, out var decoration)) + ChatId2Decoration[chatId] = decoration; + else + ChatId2Decoration.Remove(chatId); } public static void ReadChatType(Dictionary registryCodec) { - Dictionary chatTypeDictionary = ChatId2Type ?? new(); - // Check if the chat type registry is in the correct format if (!registryCodec.ContainsKey("minecraft:chat_type")) { @@ -84,25 +101,80 @@ namespace MinecraftClient.Protocol.Message } var chatTypeListNbt = (object[])(((Dictionary)registryCodec["minecraft:chat_type"])["value"]); - foreach (var (chatName, chatId) in from Dictionary chatTypeNbt in chatTypeListNbt - let chatName = (string)chatTypeNbt["name"] - let chatId = (int)chatTypeNbt["id"] - select (chatName, chatId)) + foreach (Dictionary chatTypeNbt in chatTypeListNbt) { - chatTypeDictionary[chatId] = chatName switch + string chatName = (string)chatTypeNbt["name"]; + int chatId = (int)chatTypeNbt["id"]; + var chatTypeData = chatTypeNbt.TryGetValue("element", out var element) + ? element as Dictionary + : null; + ReadChatType(chatId, chatName, chatTypeData); + } + } + + internal static int ReadChatTypeHolder( + DataTypes dataTypes, + Queue packetData, + int protocolVersion, + out ChatTypeDecoration? directDecoration) + { + int encodedId = dataTypes.ReadNextVarInt(packetData); + directDecoration = null; + + if (protocolVersion < Protocol18Handler.MC_1_21_Version) + return encodedId; + + if (encodedId > 0) + return encodedId - 1; + + directDecoration = ReadNetworkChatTypeDecoration(dataTypes, packetData); + _ = ReadNetworkChatTypeDecoration(dataTypes, packetData); // Narration decoration + return -1; + } + + private static ChatTypeDecoration ReadNetworkChatTypeDecoration( + DataTypes dataTypes, + Queue packetData) + { + string translationKey = dataTypes.ReadNextString(packetData); + int parameterCount = dataTypes.ReadNextVarInt(packetData); + var parameters = new ChatTypeParameter[parameterCount]; + + for (int i = 0; i < parameterCount; i++) + parameters[i] = (ChatTypeParameter)dataTypes.ReadNextVarInt(packetData); + + _ = dataTypes.ReadNextNbtTag(packetData); // Style + return new ChatTypeDecoration(translationKey, parameters); + } + + private static bool TryReadChatTypeDecoration( + Dictionary? chatTypeData, + [NotNullWhen(true)] out ChatTypeDecoration? decoration) + { + decoration = null; + if (chatTypeData is null + || !chatTypeData.TryGetValue("chat", out var chat) + || chat is not Dictionary chatDecoration + || !chatDecoration.TryGetValue("translation_key", out var translationKey) + || translationKey is not string translationKeyText + || !chatDecoration.TryGetValue("parameters", out var parameters) + || parameters is not object[] parameterList) + return false; + + var parsedParameters = new ChatTypeParameter[parameterList.Length]; + for (int i = 0; i < parameterList.Length; i++) + { + parsedParameters[i] = parameterList[i] switch { - "minecraft:chat" => MessageType.CHAT, - "minecraft:emote_command" => MessageType.EMOTE_COMMAND, - "minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING, - "minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING, - "minecraft:say_command" => MessageType.SAY_COMMAND, - "minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING, - "minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING, - _ => MessageType.CHAT, + "sender" => ChatTypeParameter.Sender, + "target" => ChatTypeParameter.Target, + "content" => ChatTypeParameter.Content, + _ => ChatTypeParameter.Sender }; } - ChatId2Type = chatTypeDictionary; + decoration = new ChatTypeDecoration(translationKeyText, parsedParameters); + return true; } /// @@ -147,6 +219,26 @@ namespace MinecraftClient.Protocol.Message string text; List usingData = new(); + ChatTypeDecoration? decoration = message.chatTypeDecoration; + if (decoration is null) + ChatId2Decoration.TryGetValue(message.chatTypeId, out decoration); + + if (decoration is not null) + { + foreach (var parameter in decoration.Parameters) + { + usingData.Add(parameter switch + { + ChatTypeParameter.Sender => sender, + ChatTypeParameter.Target => message.teamName ?? string.Empty, + ChatTypeParameter.Content => content, + _ => string.Empty + }); + } + + return TranslateString(decoration.TranslationKey, usingData); + } + MessageType chatType; if (message.chatTypeId == -1) chatType = MessageType.RAW_MSG; @@ -557,48 +649,47 @@ namespace MinecraftClient.Protocol.Message RulesInitialized = true; } - if (TryGetTranslationRule(rulename, out string? rule)) - { - int using_idx = 0; - StringBuilder result = new(); - for (int i = 0; i < rule.Length; i++) - { - if (rule[i] == '%' && i + 1 < rule.Length) - { - //Using string or int with %s or %d - if (rule[i + 1] == 's' || rule[i + 1] == 'd') - { - if (using_data.Count > using_idx) - { - result.Append(using_data[using_idx]); - using_idx++; - i += 1; - continue; - } - } + if (!TryGetTranslationRule(rulename, out string? rule)) + rule = rulename; - //Using specified string or int with %1$s, %2$s... - else if (char.IsDigit(rule[i + 1]) - && i + 3 < rule.Length && rule[i + 2] == '$' - && (rule[i + 3] == 's' || rule[i + 3] == 'd')) + int using_idx = 0; + StringBuilder result = new(); + for (int i = 0; i < rule.Length; i++) + { + if (rule[i] == '%' && i + 1 < rule.Length) + { + //Using string or int with %s or %d + if (rule[i + 1] == 's' || rule[i + 1] == 'd') + { + if (using_data.Count > using_idx) { - int specified_idx = rule[i + 1] - '1'; - if (using_data.Count > specified_idx) - { - result.Append(using_data[specified_idx]); - using_idx++; - i += 3; - continue; - } + result.Append(using_data[using_idx]); + using_idx++; + i += 1; + continue; } } - result.Append(rule[i]); + //Using specified string or int with %1$s, %2$s... + else if (char.IsDigit(rule[i + 1]) + && i + 3 < rule.Length && rule[i + 2] == '$' + && (rule[i + 3] == 's' || rule[i + 3] == 'd')) + { + int specified_idx = rule[i + 1] - '1'; + if (using_data.Count > specified_idx) + { + result.Append(using_data[specified_idx]); + using_idx++; + i += 3; + continue; + } + } } - return result.ToString(); + result.Append(rule[i]); } - else return "[" + rulename + "] " + string.Join(" ", using_data); + + return result.ToString(); } private static bool TryGetTranslationRule(string rulename, [NotNullWhen(true)] out string? result) diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 9ad05c2f..4568ebd9 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -8296,5 +8296,9 @@ namespace MinecraftClient { get { return ResourceManager.GetString("dialog.render.help_hint", resourceCulture); } } + internal static string protocol_chat_raw_message { + get { return ResourceManager.GetString("protocol.chat.raw_message", resourceCulture); } + } + } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 3586a8be..117e5806 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -3127,4 +3127,7 @@ see item details. Use /dialog help for a list of commands. + + Raw server chat message: {0} +