Wire up 1.20.6 structured components to Item and fix GetItemSlot serialization

In 1.20.6+, items use structured components instead of NBT for metadata.
Previously, ReadNextItemSlot parsed the components but never stored them
on the Item instance, leaving DisplayName/Lores/Damage/Enchantments all
empty. GetItemSlot also still used the pre-1.20.6 format (bool + VarInt +
byte + NBT), causing the server to reject any item operation packets.

Changes:

Item.cs:
- Add List<StructuredComponent>? Components field to hold the raw
  component list for round-trip serialization
- DisplayName property: read from CustomNameComponent (with
  ItemNameComponent as fallback) when Components is present
- Lores property: read from LoreNameComponent1206 when Components is
  present
- Damage property: read from DamageComponent when Components is present
- Add EnchantmentList property: read from EnchantmentsComponent (covers
  both normal and StoredEnchantmentsComponent for enchanted books)
- ToFullString(): use EnchantmentList with EnchantmentMapping for display
  when available, fall back to NBT path for older versions
- Add CloneWithCount() method that preserves both NBT and Components

DataTypes.cs - ReadNextItemSlot:
- Assign parsed strcturedComponentsToAdd to item.Components

DataTypes.cs - GetItemSlot:
- Add 1.20.6+ branch: write VarInt(count) + VarInt(itemId) + component
  counts + serialized components (using each component's TypeId and
  Serialize() method)
- Empty slot sends VarInt(0) per the 1.20.6 protocol spec

StructuredComponent.cs:
- Add int TypeId property (default -1) to store the registry type ID
  assigned during parsing, enabling round-trip serialization

StructuredComponentRegistry.cs:
- Set component.TypeId = id after instantiation in ParseComponent()

McClient.cs:
- Replace manual Item constructor calls (new Item(type, count, nbt))
  with Item.CloneWithCount() to preserve Components during inventory
  operations like slot moves, stack splits, and right-click placement

Made-with: Cursor
This commit is contained in:
BruceChen 2026-03-19 00:26:24 +08:00
parent bb18399523
commit 8eac21b4a4
5 changed files with 135 additions and 32 deletions

View file

@ -1,8 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Core;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
using MinecraftClient.Protocol.Message;
namespace MinecraftClient.Inventory
@ -32,6 +34,11 @@ namespace MinecraftClient.Inventory
/// </summary>
public Dictionary<string, object>? NBT;
/// <summary>
/// 1.20.6+ structured components (raw list for round-trip serialization)
/// </summary>
public List<StructuredComponent>? Components;
/// <summary>
/// Create an item with ItemType, Count and Metadata
/// </summary>
@ -50,6 +57,14 @@ namespace MinecraftClient.Inventory
Data = data;
}
/// <summary>
/// Create a shallow clone with a specific count (preserves NBT and Components).
/// </summary>
public Item CloneWithCount(int count)
{
return new Item(Type, count, Data, NBT) { Components = Components };
}
/// <summary>
/// Check if the item slot is empty
/// </summary>
@ -60,12 +75,26 @@ namespace MinecraftClient.Inventory
}
/// <summary>
/// Retrieve item display name from NBT properties. NULL if no display name is defined.
/// Retrieve item display name. For 1.20.6+ reads from structured components
/// (CustomNameComponent, then ItemNameComponent as fallback); for older versions reads from NBT.
/// </summary>
public string? DisplayName
{
get
{
if (Components != null)
{
var customName = Components.OfType<CustomNameComponent>().FirstOrDefault();
if (customName != null && !string.IsNullOrEmpty(customName.CustomName))
return customName.CustomName;
var itemName = Components.OfType<ItemNameComponent>().FirstOrDefault();
if (itemName != null && !string.IsNullOrEmpty(itemName.ItemName))
return itemName.ItemName;
return null;
}
if (NBT != null && NBT.ContainsKey("display"))
{
if (NBT["display"] is Dictionary<string, object> displayProperties &&
@ -82,12 +111,21 @@ namespace MinecraftClient.Inventory
}
/// <summary>
/// Retrieve item lores from NBT properties. Returns null if no lores is defined.
/// Retrieve item lores. For 1.20.6+ reads from LoreNameComponent1206; for older versions reads from NBT.
/// </summary>
public string[]? Lores
{
get
{
if (Components != null)
{
var loreComponent = Components.OfType<LoreNameComponent1206>().FirstOrDefault();
if (loreComponent != null && loreComponent.Lines.Count > 0)
return loreComponent.Lines.ToArray();
return null;
}
List<string> lores = new();
if (NBT != null && NBT.ContainsKey("display"))
{
@ -107,12 +145,21 @@ namespace MinecraftClient.Inventory
}
/// <summary>
/// Retrieve item damage from NBT properties. Returns 0 if no damage is defined.
/// Retrieve item damage. For 1.20.6+ reads from DamageComponent; for older versions reads from NBT.
/// </summary>
public int Damage
{
get
{
if (Components != null)
{
var damageComponent = Components.OfType<DamageComponent>().FirstOrDefault();
if (damageComponent != null)
return damageComponent.Damage;
return 0;
}
if (NBT != null && NBT.ContainsKey("Damage"))
{
object damage = NBT["Damage"];
@ -127,6 +174,26 @@ namespace MinecraftClient.Inventory
}
}
/// <summary>
/// Retrieve enchantments from structured components (1.20.6+). Returns null for older versions.
/// Both normal enchantments (EnchantmentsComponent) and stored enchantments
/// (StoredEnchantmentsComponent, e.g. enchanted books) are checked.
/// </summary>
public List<Enchantment>? EnchantmentList
{
get
{
if (Components == null)
return null;
var enchComp = Components.OfType<EnchantmentsComponent>().FirstOrDefault();
if (enchComp != null && enchComp.Enchantments.Count > 0)
return enchComp.Enchantments;
return null;
}
}
public static string GetTypeString(ItemType type)
{
string type_str = type.ToString();
@ -152,8 +219,18 @@ namespace MinecraftClient.Inventory
try
{
if (NBT != null && (NBT.TryGetValue("Enchantments", out object? enchantments) ||
NBT.TryGetValue("StoredEnchantments", out enchantments)))
var enchList = EnchantmentList;
if (enchList != null)
{
foreach (var ench in enchList)
{
string name = EnchantmentMapping.GetEnchantmentName(ench.Type);
string level = EnchantmentMapping.ConvertLevelToRomanNumbers(ench.Level);
sb.AppendFormat(" | {0} {1}", name, level);
}
}
else if (NBT != null && (NBT.TryGetValue("Enchantments", out object? enchantments) ||
NBT.TryGetValue("StoredEnchantments", out enchantments)))
{
foreach (Dictionary<string, object> enchantment in (object[])enchantments)
{

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
@ -1549,7 +1549,7 @@ namespace MinecraftClient
/// <param name="changedSlots">Record changes</param>
private static void StoreInNewSlot(Container inventory, Item item, int slotId, int newSlotId, List<Tuple<short, Item?>> changedSlots)
{
Item newItem = new(item.Type, item.Count, item.NBT);
Item newItem = item.CloneWithCount(item.Count);
inventory.Items[newSlotId] = newItem;
inventory.Items.Remove(slotId);
@ -1672,7 +1672,7 @@ namespace MinecraftClient
{
// Drop 1 item count from cursor
Item itemTmp = playerInventory.Items[-1];
Item itemClone = new(itemTmp.Type, 1, itemTmp.NBT);
Item itemClone = itemTmp.CloneWithCount(1);
inventory.Items[slotId] = itemClone;
playerInventory.Items[-1].Count--;
}
@ -1701,14 +1701,14 @@ namespace MinecraftClient
{
// Can be evenly divided
Item itemTmp = inventory.Items[slotId];
playerInventory.Items[-1] = new Item(itemTmp.Type, itemTmp.Count / 2, itemTmp.NBT);
playerInventory.Items[-1] = itemTmp.CloneWithCount(itemTmp.Count / 2);
inventory.Items[slotId].Count = itemTmp.Count / 2;
}
else
{
// Cannot be evenly divided. item count on cursor is always larger than item on inventory
Item itemTmp = inventory.Items[slotId];
playerInventory.Items[-1] = new Item(itemTmp.Type, (itemTmp.Count + 1) / 2, itemTmp.NBT);
playerInventory.Items[-1] = itemTmp.CloneWithCount((itemTmp.Count + 1) / 2);
inventory.Items[slotId].Count = (itemTmp.Count - 1) / 2;
}
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
@ -450,15 +450,11 @@ namespace MinecraftClient.Protocol.Handlers
}
for (var i = 0; i < numberofComponentsToRemove; i++)
{
// TODO: Check what this does exactly
ReadNextVarInt(cache); // The type of component to remove
}
// TODO: Wire up the strctured components in the Item class (extract info, update fields, etc..)
// Use strcturedComponentsToAdd
// Look at: https://wiki.vg/index.php?title=Slot_Data&oldid=19350#Structured_components
ReadNextVarInt(cache);
if (strcturedComponentsToAdd.Count > 0)
item.Components = strcturedComponentsToAdd;
return item;
case >= Protocol18Handler.MC_1_13_Version:
{
@ -1561,17 +1557,44 @@ namespace MinecraftClient.Protocol.Handlers
/// <returns>Item slot representation</returns>
public byte[] GetItemSlot(Item? item, ItemPalette itemPalette)
{
// TODO: Wire up Structured components for 1.20.6
List<byte> slotData = new();
if (protocolversion > Protocol18Handler.MC_1_13_Version)
if (protocolversion >= Protocol18Handler.MC_1_20_6_Version)
{
// MC 1.13 and greater
if (item == null || item.IsEmpty)
slotData.AddRange(GetBool(false)); // No item
{
slotData.AddRange(GetVarInt(0));
}
else
{
slotData.AddRange(GetBool(true)); // Item is present
slotData.AddRange(GetVarInt(item.Count));
slotData.AddRange(GetVarInt(itemPalette.ToId(item.Type)));
if (item.Components != null && item.Components.Count > 0)
{
slotData.AddRange(GetVarInt(item.Components.Count));
slotData.AddRange(GetVarInt(0)); // components to remove
foreach (var component in item.Components)
{
slotData.AddRange(GetVarInt(component.TypeId));
var serialized = component.Serialize();
slotData.AddRange(serialized);
}
}
else
{
slotData.AddRange(GetVarInt(0)); // no components to add
slotData.AddRange(GetVarInt(0)); // no components to remove
}
}
}
else if (protocolversion > Protocol18Handler.MC_1_13_Version)
{
if (item == null || item.IsEmpty)
slotData.AddRange(GetBool(false));
else
{
slotData.AddRange(GetBool(true));
slotData.AddRange(GetVarInt(itemPalette.ToId(item.Type)));
slotData.Add((byte)item.Count);
slotData.AddRange(GetNbt(item.NBT));
@ -1579,13 +1602,10 @@ namespace MinecraftClient.Protocol.Handlers
}
else
{
// MC 1.12.2 and lower
if (item == null || item.IsEmpty)
slotData.AddRange(GetShort(-1));
else
{
// For 1.8 - 1.12.2 we combine Item Id and Item Data to a single value using: (id << 16) | data
// Thus to get an ID we do a right shift by 16 bits
slotData.AddRange(GetShort((short)(itemPalette.ToId(item.Type) >> 16)));
slotData.Add((byte)item.Count);
slotData.Add((byte)item.Data);

View file

@ -8,7 +8,12 @@ public abstract class StructuredComponent(DataTypes dataTypes, ItemPalette itemP
protected DataTypes DataTypes { get; private set; } = dataTypes;
protected SubComponentRegistry SubComponentRegistry { get; private set; } = subComponentRegistry;
protected ItemPalette ItemPalette { get; private set; } = itemPalette;
/// <summary>
/// The registry type ID assigned during parsing, used for round-trip serialization.
/// </summary>
public int TypeId { get; set; } = -1;
public abstract void Parse(Queue<byte> data);
public abstract Queue<byte> Serialize();
}

View file

@ -35,7 +35,8 @@ public abstract class StructuredComponentRegistry(DataTypes dataTypes, ItemPalet
var component =
Activator.CreateInstance(type, dataTypes, itemPalette, subComponentRegistry) as StructuredComponent
?? throw new InvalidOperationException($"Could not instantiate a parser for a structured component type {name}");
component.TypeId = id;
component.Parse(data);
return component;
}