diff --git a/MinecraftClient/Commands/RecipeBook.cs b/MinecraftClient/Commands/RecipeBook.cs index 24a56a86..4cf0d873 100644 --- a/MinecraftClient/Commands/RecipeBook.cs +++ b/MinecraftClient/Commands/RecipeBook.cs @@ -59,14 +59,14 @@ namespace MinecraftClient.Commands if (!handler.GetInventoryEnabled()) return r.SetAndReturn(CmdResult.Status.FailNeedInventory); - string[] recipeIds = handler.GetUnlockedRecipes(); - if (recipeIds.Length == 0) + RecipeBookRecipeEntry[] recipes = handler.GetUnlockedRecipes(); + if (recipes.Length == 0) return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_recipes); StringBuilder response = new(); response.AppendLine(Translations.cmd_recipebook_list); - foreach (string recipeId in recipeIds) - response.AppendLine("- " + recipeId); + foreach (RecipeBookRecipeEntry recipe in recipes) + response.AppendLine("- " + recipe.DisplayText); handler.Log.Info(response.ToString().TrimEnd()); return r.SetAndReturn(CmdResult.Status.Done); @@ -87,7 +87,7 @@ namespace MinecraftClient.Commands if (handler.GetActiveRecipeBookInventory() is null) return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_active_inventory); - string normalizedRecipeId = McClient.NormalizeRecipeId(recipeId); + string normalizedRecipeId = McClient.NormalizeRecipeArgument(recipeId, handler.GetProtocolVersion()); string successMessage = string.Format(makeAll ? Translations.cmd_recipebook_craftall_sent : Translations.cmd_recipebook_craft_sent, normalizedRecipeId); return handler.SendPlaceRecipe(recipeId, makeAll) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 906eff74..24069342 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -49,7 +49,7 @@ namespace MinecraftClient private readonly List bots = new(); private static readonly List botsOnHold = new(); private static readonly Dictionary inventories = new(); - private readonly HashSet unlockedRecipes = new(StringComparer.Ordinal); + private readonly Dictionary unlockedRecipes = new(StringComparer.Ordinal); private readonly Dictionary> registeredBotPluginChannels = new(); private readonly List registeredServerPluginChannels = new(); @@ -1345,11 +1345,11 @@ namespace MinecraftClient /// Get all unlocked recipe book recipe identifiers. /// /// Unlocked recipe identifiers sorted alphabetically - public string[] GetUnlockedRecipes() + public RecipeBookRecipeEntry[] GetUnlockedRecipes() { lock (recipeBookLock) { - return [.. unlockedRecipes.OrderBy(static recipeId => recipeId, StringComparer.Ordinal)]; + return unlockedRecipes.Values.OrderBy(static recipe => recipe.CommandId, StringComparer.Ordinal).ToArray(); } } @@ -2728,7 +2728,7 @@ namespace MinecraftClient if (activeInventory is null) return false; - string normalizedRecipeId = NormalizeRecipeId(recipeId); + string normalizedRecipeId = NormalizeRecipeArgument(recipeId, protocolversion); if (normalizedRecipeId.Length == 0) return false; @@ -4111,17 +4111,18 @@ namespace MinecraftClient Log.Debug("CanSendMessage = " + canSendMessage); } - public void OnRecipeBookAdd(string[] recipeIds, bool replace) + public void OnRecipeBookAdd(RecipeBookRecipeEntry[] recipes, bool replace) { lock (recipeBookLock) { if (replace) unlockedRecipes.Clear(); - foreach (string recipeId in recipeIds) + foreach (RecipeBookRecipeEntry recipe in recipes) { - if (!string.IsNullOrWhiteSpace(recipeId)) - unlockedRecipes.Add(recipeId); + // Guard against malformed server packets that send empty display IDs. + if (!string.IsNullOrWhiteSpace(recipe.CommandId)) + unlockedRecipes[recipe.CommandId] = recipe; } } } @@ -4173,7 +4174,19 @@ namespace MinecraftClient } } - internal static string NormalizeRecipeId(string recipeId) + /// + /// Normalize a recipe argument for the target protocol version. + /// Legacy recipe-book packets use identifiers and default to the minecraft namespace. + /// 1.21.2+ recipe-book packets use numeric recipe display ids and should be left trimmed-only. + /// + internal static string NormalizeRecipeArgument(string recipeId, int protocolVersion) + { + return protocolVersion >= Protocol18Handler.MC_1_21_2_Version + ? recipeId.Trim() + : NormalizeRecipeId(recipeId); + } + + private static string NormalizeRecipeId(string recipeId) { string trimmedRecipeId = recipeId.Trim(); if (trimmedRecipeId.Length == 0) diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index e6518c7b..bc9537cd 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -3127,7 +3127,7 @@ namespace MinecraftClient.Protocol.Handlers break; case PacketTypesIn.RecipeBookRemove: if (protocolVersion >= MC_1_21_2_Version) - handler.OnRecipeBookRemove(ReadRecipeBookRecipeIds(packetData)); + handler.OnRecipeBookRemove(ReadRecipeBookDisplayIds(packetData)); break; case PacketTypesIn.RecipeBookSettings: break; @@ -3146,21 +3146,20 @@ namespace MinecraftClient.Protocol.Handlers return; string[] recipeIds = ReadRecipeBookRecipeIds(packetData); + RecipeBookRecipeEntry[] recipeEntries = recipeIds.Select(static recipeId => new RecipeBookRecipeEntry(recipeId, recipeId)).ToArray(); switch (action) { case 0: - handler.OnRecipeBookAdd(recipeIds, replace: true); + handler.OnRecipeBookAdd(recipeEntries, replace: true); // INIT packets also include a second "to be displayed" recipe list. // MCC only needs the unlocked recipe identifiers for listing/crafting. _ = ReadRecipeBookRecipeIds(packetData); break; case 1: - handler.OnRecipeBookAdd(recipeIds, replace: false); - break; case 3: // Action 3 is the silent-add variant, so MCC tracks it like a regular add. - handler.OnRecipeBookAdd(recipeIds, replace: false); + handler.OnRecipeBookAdd(recipeEntries, replace: false); break; case 2: handler.OnRecipeBookRemove(recipeIds); @@ -3171,20 +3170,18 @@ namespace MinecraftClient.Protocol.Handlers private void HandleRecipeBookAdd(Queue packetData) { int entryCount = dataTypes.ReadNextVarInt(packetData); - string[] recipeIds = new string[entryCount]; + RecipeBookRecipeEntry[] recipeEntries = new RecipeBookRecipeEntry[entryCount]; - // RecipeBookAdd contains one entry per recipe: - // recipe id, notification flag, then highlight flag. - // MCC only tracks the unlocked recipe identifiers for now. + // 1.21.2+ RecipeBookAdd contains one display entry per recipe: + // RecipeDisplayEntry (display id, recipe display, group, category, optional requirements), then flags. for (int i = 0; i < entryCount; i++) { - recipeIds[i] = dataTypes.ReadNextString(packetData); - _ = dataTypes.ReadNextBool(packetData); // notification - _ = dataTypes.ReadNextBool(packetData); // highlight + recipeEntries[i] = ReadRecipeBookDisplayEntry(packetData); + _ = dataTypes.ReadNextByte(packetData); // flags } bool replace = dataTypes.ReadNextBool(packetData); - handler.OnRecipeBookAdd(recipeIds, replace); + handler.OnRecipeBookAdd(recipeEntries, replace); } private string[] ReadRecipeBookRecipeIds(Queue packetData) @@ -3198,6 +3195,168 @@ namespace MinecraftClient.Protocol.Handlers return recipeIds; } + private string[] ReadRecipeBookDisplayIds(Queue packetData) + { + int recipeCount = dataTypes.ReadNextVarInt(packetData); + string[] recipeIds = new string[recipeCount]; + + for (int i = 0; i < recipeCount; i++) + recipeIds[i] = dataTypes.ReadNextVarInt(packetData).ToString(CultureInfo.InvariantCulture); + + return recipeIds; + } + + private RecipeBookRecipeEntry ReadRecipeBookDisplayEntry(Queue packetData) + { + int displayId = dataTypes.ReadNextVarInt(packetData); + string resultLabel = ReadRecipeDisplayResultLabel(packetData); + + _ = dataTypes.ReadNextVarInt(packetData); // Optional group, encoded as varint+1 or 0 + _ = dataTypes.ReadNextVarInt(packetData); // Recipe book category registry id + SkipOptionalCraftingRequirements(packetData); + + string commandId = displayId.ToString(CultureInfo.InvariantCulture); + string displayText = $"{commandId}: {resultLabel}"; + return new RecipeBookRecipeEntry(commandId, displayText); + } + + private string ReadRecipeDisplayResultLabel(Queue packetData) + { + int displayType = dataTypes.ReadNextVarInt(packetData); + return displayType switch + { + 0 => ReadShapelessRecipeDisplayResultLabel(packetData), + 1 => ReadShapedRecipeDisplayResultLabel(packetData), + 2 => ReadFurnaceRecipeDisplayResultLabel(packetData), + 3 => ReadStonecutterRecipeDisplayResultLabel(packetData), + 4 => ReadSmithingRecipeDisplayResultLabel(packetData), + _ => $"recipe_display_{displayType}", + }; + } + + private string ReadShapelessRecipeDisplayResultLabel(Queue packetData) + { + int ingredientCount = dataTypes.ReadNextVarInt(packetData); + for (int i = 0; i < ingredientCount; i++) + _ = ReadSlotDisplayLabel(packetData); + + string result = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // crafting station + return result; + } + + private string ReadShapedRecipeDisplayResultLabel(Queue packetData) + { + _ = dataTypes.ReadNextVarInt(packetData); // width + _ = dataTypes.ReadNextVarInt(packetData); // height + int ingredientCount = dataTypes.ReadNextVarInt(packetData); + for (int i = 0; i < ingredientCount; i++) + _ = ReadSlotDisplayLabel(packetData); + + string result = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // crafting station + return result; + } + + private string ReadFurnaceRecipeDisplayResultLabel(Queue packetData) + { + _ = ReadSlotDisplayLabel(packetData); // ingredient + _ = ReadSlotDisplayLabel(packetData); // fuel + string result = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // crafting station + _ = dataTypes.ReadNextVarInt(packetData); // duration + _ = dataTypes.ReadNextFloat(packetData); // experience + return result; + } + + private string ReadStonecutterRecipeDisplayResultLabel(Queue packetData) + { + _ = ReadSlotDisplayLabel(packetData); // input + string result = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // crafting station + return result; + } + + private string ReadSmithingRecipeDisplayResultLabel(Queue packetData) + { + _ = ReadSlotDisplayLabel(packetData); // template + _ = ReadSlotDisplayLabel(packetData); // base + _ = ReadSlotDisplayLabel(packetData); // addition + string result = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // crafting station + return result; + } + + private string ReadSlotDisplayLabel(Queue packetData) + { + int slotDisplayType = dataTypes.ReadNextVarInt(packetData); + return slotDisplayType switch + { + 0 => "Empty", + 1 => "Any Fuel", + 2 => Item.GetTypeString(itemPalette.FromId(dataTypes.ReadNextVarInt(packetData))), + 3 => dataTypes.ReadNextItemSlot(packetData, itemPalette)?.GetTypeString() ?? "Empty", + 4 => "#" + dataTypes.ReadNextString(packetData), + 5 => ReadSmithingTrimSlotDisplayLabel(packetData), + 6 => ReadWithRemainderSlotDisplayLabel(packetData), + 7 => ReadCompositeSlotDisplayLabel(packetData), + _ => $"slot_display_{slotDisplayType}", + }; + } + + private string ReadSmithingTrimSlotDisplayLabel(Queue packetData) + { + string baseLabel = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // material + _ = dataTypes.ReadNextVarInt(packetData); // trim pattern registry id + return baseLabel; + } + + private string ReadWithRemainderSlotDisplayLabel(Queue packetData) + { + string inputLabel = ReadSlotDisplayLabel(packetData); + _ = ReadSlotDisplayLabel(packetData); // remainder + return inputLabel; + } + + private string ReadCompositeSlotDisplayLabel(Queue packetData) + { + int optionCount = dataTypes.ReadNextVarInt(packetData); + string label = "Composite"; + + for (int i = 0; i < optionCount; i++) + { + string optionLabel = ReadSlotDisplayLabel(packetData); + if (label == "Composite" && optionLabel is not "Empty" and not "Composite") + label = optionLabel; + } + + return label; + } + + private void SkipOptionalCraftingRequirements(Queue packetData) + { + if (!dataTypes.ReadNextBool(packetData)) + return; + + int ingredientCount = dataTypes.ReadNextVarInt(packetData); + for (int i = 0; i < ingredientCount; i++) + SkipItemHolderSet(packetData); + } + + private void SkipItemHolderSet(Queue packetData) + { + int entryCount = dataTypes.ReadNextVarInt(packetData) - 1; + if (entryCount == -1) + { + _ = dataTypes.ReadNextString(packetData); + return; + } + + for (int i = 0; i < entryCount; i++) + _ = dataTypes.ReadNextVarInt(packetData); + } + private bool SkipRecipeBookSettings(Queue packetData) { // MC 1.13 uses 4 booleans for the crafting/smelting recipe book states. @@ -5111,7 +5270,10 @@ namespace MinecraftClient.Protocol.Handlers return false; packet.AddRange(DataTypes.GetVarInt(windowId)); - packet.AddRange(dataTypes.GetString(recipeId)); + if (protocolVersion >= MC_1_21_2_Version) + packet.AddRange(DataTypes.GetVarInt(int.Parse(recipeId, CultureInfo.InvariantCulture))); + else + packet.AddRange(dataTypes.GetString(recipeId)); packet.AddRange(dataTypes.GetBool(makeAll)); SendPacket(PacketTypesOut.CraftRecipeRequest, packet); return true; diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index d6bd5eb7..81a4a056 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -520,9 +520,9 @@ namespace MinecraftClient.Protocol /// /// Called when recipe book recipes are added or replaced. /// - /// Recipe identifiers to add + /// Recipe entries to add /// True to replace the currently tracked recipe book entries - public void OnRecipeBookAdd(string[] recipeIds, bool replace); + public void OnRecipeBookAdd(RecipeBookRecipeEntry[] recipes, bool replace); /// /// Called when recipe book recipes are removed. diff --git a/MinecraftClient/RecipeBookRecipeEntry.cs b/MinecraftClient/RecipeBookRecipeEntry.cs new file mode 100644 index 00000000..a5648ba7 --- /dev/null +++ b/MinecraftClient/RecipeBookRecipeEntry.cs @@ -0,0 +1,4 @@ +namespace MinecraftClient +{ + public readonly record struct RecipeBookRecipeEntry(string CommandId, string DisplayText); +}