From 5dae0876ce92659299a8fa2b0a12f830f52f3f10 Mon Sep 17 00:00:00 2001 From: sven-n Date: Sat, 28 Sep 2024 16:58:08 +0200 Subject: [PATCH] Implemented extended item serializer --- ...-25-AppearanceChangedExtended_by-server.md | 25 + ...05-PlayerShopItemListExtended_by-server.md | 46 ++ docs/Packets/ServerToClient.md | 2 + src/GameServer/RemoteView/IItemSerializer.cs | 511 +----------------- .../RemoteView/Inventory/ItemAppearPlugIn.cs | 4 +- .../ItemBoughtFromPlayerShopPlugIn.cs | 4 +- .../Inventory/ItemMoveFailedPlugIn.cs | 4 +- .../RemoteView/Inventory/ItemMovedPlugIn.cs | 6 +- .../Inventory/ItemUpgradedPlugIn.cs | 4 +- .../Inventory/NpcItemBoughtPlugIn.cs | 4 +- .../Inventory/UpdateInventoryListPlugIn.cs | 9 +- src/GameServer/RemoteView/ItemSerializer.cs | 240 ++++++++ .../RemoteView/ItemSerializer075.cs | 4 +- .../RemoteView/ItemSerializer095.cs | 4 +- .../RemoteView/ItemSerializerExtended.cs | 486 +++++++++++++++++ .../RemoteView/ItemSerializerHelper.cs | 329 +++++++++++ .../NPC/ShowMerchantStoreItemListPlugIn.cs | 9 +- .../ShowShopItemListExtendedPlugIn.cs | 78 +++ .../World/AppearanceChangedPlugIn.cs | 37 ++ .../World/ShowDroppedItemsPlugIn.cs | 8 +- .../ServerToClient/AddCharactersToScope.cs | 2 +- .../ServerToClient/ConnectionExtensions.cs | 42 ++ .../ServerToClient/ServerToClientPackets.cs | 367 +++++++++++++ .../ServerToClient/ServerToClientPackets.xml | 161 ++++++ .../ItemSerializerTests.cs | 17 +- 25 files changed, 1868 insertions(+), 535 deletions(-) create mode 100644 docs/Packets/C1-25-AppearanceChangedExtended_by-server.md create mode 100644 docs/Packets/C2-3F-05-PlayerShopItemListExtended_by-server.md create mode 100644 src/GameServer/RemoteView/ItemSerializer.cs create mode 100644 src/GameServer/RemoteView/ItemSerializerExtended.cs create mode 100644 src/GameServer/RemoteView/ItemSerializerHelper.cs create mode 100644 src/GameServer/RemoteView/PlayerShop/ShowShopItemListExtendedPlugIn.cs diff --git a/docs/Packets/C1-25-AppearanceChangedExtended_by-server.md b/docs/Packets/C1-25-AppearanceChangedExtended_by-server.md new file mode 100644 index 000000000..4fca2cbb3 --- /dev/null +++ b/docs/Packets/C1-25-AppearanceChangedExtended_by-server.md @@ -0,0 +1,25 @@ +# C1 25 - AppearanceChangedExtended (by server) + +## Is sent when + +The appearance of a player changed, all surrounding players are informed about it. + +## Causes the following actions on the client side + +The appearance of the player is updated. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 10 | Packet header - length of the packet | +| 2 | 1 | Byte | 0x25 | Packet header - packet type identifier | +| 3 | 2 | ShortLittleEndian | | ChangedPlayerId | +| 5 | 4 bit | Byte | | ItemSlot | +| 5 | 4 bit | Byte | | ItemGroup | +| 6 | 2 | ShortLittleEndian | | ItemNumber | +| 8 | 1 | Byte | | ItemLevel | +| 9 << 0 | 1 bit | Boolean | | IsExcellent | +| 9 << 1 | 1 bit | Boolean | | IsAncient | +| 9 << 2 | 1 bit | Boolean | | IsAncientSetComplete | \ No newline at end of file diff --git a/docs/Packets/C2-3F-05-PlayerShopItemListExtended_by-server.md b/docs/Packets/C2-3F-05-PlayerShopItemListExtended_by-server.md new file mode 100644 index 000000000..ffa03b7b2 --- /dev/null +++ b/docs/Packets/C2-3F-05-PlayerShopItemListExtended_by-server.md @@ -0,0 +1,46 @@ +# C2 3F 05 - PlayerShopItemListExtended (by server) + +## Is sent when + +After the player requested to open a shop of another player. + +## Causes the following actions on the client side + +The player shop dialog is shown with the provided item data. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | | Packet header - length of the packet | +| 3 | 1 | Byte | 0x3F | Packet header - packet type identifier | +| 4 | 1 | Byte | 0x05 | Packet header - sub packet type identifier | +| 4 | 1 | ActionKind | | Action | +| 5 | 1 | Boolean | true | Success | +| 6 | 2 | ShortBigEndian | | PlayerId | +| 8 | 10 | String | | PlayerName | +| 18 | 36 | String | | ShopName | +| 54 | 1 | Byte | | ItemCount | +| 55 | PlayerShopItemExtended.Length * ItemCount | Array of PlayerShopItemExtended | | Items | + +### PlayerShopItemExtended Structure + +Data of an item in a player shop, which allows for dynamic item sizes and trades for specific kind of items (e.g. jewels), too. + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | | ItemSlot | +| 4 | 4 | IntegerLittleEndian | | MoneyPrice | +| 8 | 2 | ShortLittleEndian | | PriceItemType; Contains the item group in the highest 4 bits, and the item number in the remaining ones. | +| 9 | 2 | ShortLittleEndian | | RequiredItemAmount | +| 11 | | Binary | | ItemData | + +### ActionKind Enum + +The kind of action which led to the list message. + +| Value | Name | Description | +|-------|------|-------------| +| 5 | ByRequest | The list was requested. | +| 19 | UpdateAfterItemChange | The list was changed, e.g. because an item was sold. | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index 42ebfc81a..cf9cb5d36 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -45,6 +45,7 @@ * [C3 24 - ItemMoved (by server)](C3-24-ItemMoved_by-server.md) * [C3 24 FF - ItemMoveRequestFailed (by server)](C3-24-FF-ItemMoveRequestFailed_by-server.md) * [C1 25 - AppearanceChanged (by server)](C1-25-AppearanceChanged_by-server.md) + * [C1 25 - AppearanceChangedExtended (by server)](C1-25-AppearanceChangedExtended_by-server.md) * [C1 26 FD - ItemConsumptionFailed (by server)](C1-26-FD-ItemConsumptionFailed_by-server.md) * [C1 26 FD - ItemConsumptionFailedExtended (by server)](C1-26-FD-ItemConsumptionFailedExtended_by-server.md) * [C1 26 FE - MaximumHealthAndShield (by server)](C1-26-FE-MaximumHealthAndShield_by-server.md) @@ -76,6 +77,7 @@ * [C2 3F 00 - PlayerShops (by server)](C2-3F-00-PlayerShops_by-server.md) * [C3 3F 01 - PlayerShopSetItemPriceResponse (by server)](C3-3F-01-PlayerShopSetItemPriceResponse_by-server.md) * [C2 3F 05 - PlayerShopItemList (by server)](C2-3F-05-PlayerShopItemList_by-server.md) + * [C2 3F 05 - PlayerShopItemListExtended (by server)](C2-3F-05-PlayerShopItemListExtended_by-server.md) * [C1 3F 08 - PlayerShopItemSoldToPlayer (by server)](C1-3F-08-PlayerShopItemSoldToPlayer_by-server.md) * [C1 3F 12 - ClosePlayerShopDialog (by server)](C1-3F-12-ClosePlayerShopDialog_by-server.md) * [C1 3F 2 - PlayerShopOpenSuccessful (by server)](C1-3F-2-PlayerShopOpenSuccessful_by-server.md) diff --git a/src/GameServer/RemoteView/IItemSerializer.cs b/src/GameServer/RemoteView/IItemSerializer.cs index 251a6ed61..2b3f8b6ff 100644 --- a/src/GameServer/RemoteView/IItemSerializer.cs +++ b/src/GameServer/RemoteView/IItemSerializer.cs @@ -4,16 +4,10 @@ namespace MUnique.OpenMU.GameServer.RemoteView; -using System.Runtime.InteropServices; -using MUnique.OpenMU.DataModel; using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Views; -using MUnique.OpenMU.Network.PlugIns; using MUnique.OpenMU.Persistence; -using MUnique.OpenMU.PlugIns; /// /// Serializes the items into a byte array. @@ -30,7 +24,8 @@ public interface IItemSerializer : IViewPlugIn /// /// The target span. /// The item. - void SerializeItem(Span target, Item item); + /// The size of the serialized item. + int SerializeItem(Span target, Item item); /// /// Deserializes the byte array into a new item instance. @@ -40,506 +35,4 @@ public interface IItemSerializer : IViewPlugIn /// The persistence context. Required to create new objects. /// The created item instance. Item DeserializeItem(Span source, GameConfiguration gameConfiguration, IContext persistenceContext); -} - -/// -/// This item serializer is used to serialize the item data to the data packets. -/// At the moment, each item is serialized into a 12-byte long part of an array: -/// Byte Order: ItemCode Options Dura Exe Ancient Kind/380Opt HarmonyOpt Socket1 Socket2 Socket3 Socket4 Socket5. -/// -[Guid("3607902F-C7A8-40D0-823A-186F3BF630C7")] -[PlugIn("Item Serializer", "The default item serializer. It's most likely only correct for season 6.")] -[MinimumClient(5, 0, ClientLanguage.Invariant)] -public class ItemSerializer : IItemSerializer -{ - private const byte LuckFlag = 4; - - private const byte SkillFlag = 128; - - private const byte LevelMask = 0x78; - - private const byte GuardianOptionFlag = 0x08; - - private const byte NoSocket = 0xFF; - - private const byte EmptySocket = 0xFE; - - private const int MaximumSockets = 5; - - private const byte AncientBonusLevelMask = 0b1100; - private const byte AncientDiscriminatorMask = 0b0011; - private const byte AncientMask = AncientBonusLevelMask | AncientDiscriminatorMask; - - private const byte BlackFenrirFlag = 0x01; - private const byte BlueFenrirFlag = 0x02; - private const byte GoldFenrirFlag = 0x04; - - private const byte MaximumSocketOptions = 50; - - /// - /// The socket seed index offsets, where the key is the numerical value of a - /// and the value is the first index of this corresponding elemental seed. - /// - /// - /// Webzen decided to put every possible socket option of each elemental seed type into one big list, - /// which may contain up to elements. - /// I couldn't figure out a pattern, but found these index offsets by trial and error. - /// Their list contains holes, so expect that index 9 doesn't define an option. - /// - private static readonly byte[] SocketOptionIndexOffsets = { 0, 10, 16, 21, 29, 36 }; - - /// - public int NeededSpace => 12; - - /// - public void SerializeItem(Span target, Item item) - { - item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); - target[0] = (byte)item.Definition.Number; - - var itemLevel = item.IsTrainablePet() ? 0 : item.Level; - target[1] = (byte)((itemLevel << 3) & LevelMask); - - var itemOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option); - if (itemOption != null) - { - // The item option level is splitted into 2 parts. Webzen... :-/ - target[1] += (byte)(itemOption.Level & 3); // setting the first 2 bits - target[3] = (byte)((itemOption.Level & 4) << 4); // The highest bit is placed into the 2nd bit of the exc byte (0x40). - - // Some items (wings) can have different options (3rd wings up to 3!) - // Alternate options are set at array[startIndex + 3] |= 0x20 and 0x10 - if (itemOption.ItemOption?.Number > 0) - { - target[3] |= (byte)((itemOption.ItemOption.Number & 0b11) << 4); - } - } - - target[2] = item.Durability(); - - target[3] |= GetExcellentByte(item); - - if ((item.Definition.Number & 0x100) == 0x100) - { - // Support for 512 items per Group - target[3] |= 0x80; - } - - target[3] |= GetFenrirByte(item); - - if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck)) - { - target[1] |= LuckFlag; - } - - if (item.HasSkill) - { - target[1] |= SkillFlag; - } - - var ancientSet = item.ItemSetGroups.FirstOrDefault(set => set.AncientSetDiscriminator != 0); - if (ancientSet != null) - { - target[4] |= (byte)(ancientSet.AncientSetDiscriminator & AncientDiscriminatorMask); - - // An ancient item may or may not have an ancient bonus option. Example without bonus: Gywen Pendant. - var ancientBonus = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.AncientBonus); - if (ancientBonus != null) - { - target[4] |= (byte)((ancientBonus.Level << 2) & AncientBonusLevelMask); - } - } - - target[5] = (byte)(item.Definition.Group << 4); - if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GuardianOption)) - { - target[5] |= GuardianOptionFlag; - } - - target[6] = (byte)(GetHarmonyByte(item) | GetSocketBonusByte(item)); - SetSocketBytes(target.Slice(7), item); - } - - /// - public Item DeserializeItem(Span array, GameConfiguration gameConfiguration, IContext persistenceContext) - { - var itemNumber = array[0] + ((array[0] & 0x80) << 1); - var itemGroup = (array[5] & 0xF0) >> 4; - var definition = gameConfiguration.Items.FirstOrDefault(def => def.Number == itemNumber && def.Group == itemGroup) - ?? throw new ArgumentException($"Couldn't find the item definition for the given byte array. Extracted item number and group: {itemNumber}, {itemGroup}"); - - var item = persistenceContext.CreateNew(); - item.Definition = definition; - item.Level = (byte)((array[1] & LevelMask) >> 3); - item.Durability = array[2]; - - if (item.Definition.PossibleItemOptions.Any(o => - o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Excellent))) - { - ReadExcellentOption(array[3], persistenceContext, item); - } - else if (item.Definition.PossibleItemOptions.Any(o => - o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Wing))) - { - ReadWingOption(array[3], persistenceContext, item); - } - else - { - // set nothing. - } - - ReadSkillFlag(array[1], item); - ReadLuckOption(array[1], persistenceContext, item); - ReadNormalOption(array, persistenceContext, item); - ReadAncientOption(array[4], persistenceContext, item); - ReadLevel380Option(array[5], persistenceContext, item); - if (item.Definition.PossibleItemOptions.Any(o => o.PossibleOptions.Any(p => p.OptionType == ItemOptionTypes.BlackFenrir))) - { - ReadFenrirOptions(array[3], persistenceContext, item); - } - - if (item.Definition.MaximumSockets == 0) - { - ReadHarmonyOption(array[6], persistenceContext, item); - } - else - { - ReadSocketBonus(array[6], persistenceContext, item); - } - - ReadSockets(array.Slice(7), persistenceContext, item); - return item; - } - - private static void ReadSkillFlag(byte optionByte, Item item) - { - if ((optionByte & SkillFlag) == 0) - { - return; - } - - if (item.Definition!.Skill is null) - { - throw new ArgumentException($"The skill flag was set, but a skill is not defined for the specified item ({item.Definition.Number}, {item.Definition.Group})"); - } - - item.HasSkill = true; - } - - private static void ReadLuckOption(byte optionByte, IContext persistenceContext, Item item) - { - if ((optionByte & LuckFlag) == 0) - { - return; - } - - var luckOption = item.Definition!.PossibleItemOptions - .SelectMany(o => o.PossibleOptions.Where(i => i.OptionType == ItemOptionTypes.Luck)) - .FirstOrDefault() - ?? throw new ArgumentException($"The luck flag was set, but luck option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = luckOption; - item.ItemOptions.Add(optionLink); - } - - private static void ReadWingOption(byte wingbyte, IContext persistenceContext, Item item) - { - var wingBits = wingbyte & 0x0F; - var wingOptionDefinition = item.Definition!.PossibleItemOptions.First(o => - o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Wing)); - foreach (var wingOption in wingOptionDefinition.PossibleOptions) - { - var optionMask = (byte)(1 << (wingOption.Number - 1)); - if ((wingBits & optionMask) == optionMask) - { - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = wingOption; - item.ItemOptions.Add(optionLink); - } - } - } - - private static void ReadExcellentOption(byte excByte, IContext persistenceContext, Item item) - { - var excellentBits = excByte & 0x3F; - var excellentOptionDefinition = item.Definition!.PossibleItemOptions.First(o => - o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Excellent)); - foreach (var excellentOption in excellentOptionDefinition.PossibleOptions) - { - var optionMask = (byte)(1 << (excellentOption.Number - 1)); - if ((excellentBits & optionMask) == optionMask) - { - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = excellentOption; - item.ItemOptions.Add(optionLink); - } - } - } - - private static void ReadNormalOption(Span array, IContext persistenceContext, Item item) - { - var optionLevel = (array[1] & 3) + ((array[3] >> 4) & 4); - if (optionLevel == 0) - { - return; - } - - var itemIsWing = item.Definition!.PossibleItemOptions.Any(o => o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Wing)); - var optionNumber = itemIsWing ? (array[3] >> 4) & 0b11 : 0; - var option = item.Definition.PossibleItemOptions.SelectMany(o => o.PossibleOptions.Where(i => i.OptionType == ItemOptionTypes.Option && i.Number == optionNumber)) - .FirstOrDefault() - ?? throw new ArgumentException($"The option with level {optionLevel} and number {optionNumber} is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = option; - optionLink.Level = optionLevel; - item.ItemOptions.Add(optionLink); - } - - private static void ReadAncientOption(byte ancientByte, IContext persistenceContext, Item item) - { - if ((ancientByte & AncientMask) == 0) - { - return; - } - - var bonusLevel = (ancientByte & AncientBonusLevelMask) >> 2; - var setDiscriminator = ancientByte & AncientDiscriminatorMask; - var ancientSets = item.Definition!.PossibleItemSetGroups - .Where(set => set.Options?.PossibleOptions.Any(o => o.OptionType == ItemOptionTypes.AncientOption) ?? false) - .SelectMany(i => i.Items).Where(i => i.ItemDefinition == item.Definition) - .Where(set => set.AncientSetDiscriminator == setDiscriminator).ToList(); - if (ancientSets.Count > 1) - { - throw new ArgumentException($"Ambiguous ancient set discriminator: {ancientSets.Count} sets with discriminator {setDiscriminator} found for item definition ({item.Definition.Number}, {item.Definition.Group})."); - } - - var itemOfSet = ancientSets.FirstOrDefault() - ?? throw new ArgumentException($"Couldn't find ancient set (discriminator {setDiscriminator}) for item ({item.Definition.Number}, {item.Definition.Group})."); - item.ItemSetGroups.Add(itemOfSet); - if (bonusLevel > 0) - { - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = itemOfSet.BonusOption; - optionLink.Level = bonusLevel; - item.ItemOptions.Add(optionLink); - } - } - - private static void ReadLevel380Option(byte option380Byte, IContext persistenceContext, Item item) - { - if ((option380Byte & GuardianOptionFlag) == 0) - { - return; - } - - if (!item.Definition!.PossibleItemOptions.Any(o => o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.GuardianOption))) - { - throw new ArgumentException($"The lvl380 option flag was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); - } - - var guardianOptions = item.Definition.PossibleItemOptions - .SelectMany(o => o.PossibleOptions.Where(i => i.OptionType == ItemOptionTypes.GuardianOption)); - foreach (var option in guardianOptions) - { - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = option; - item.ItemOptions.Add(optionLink); - } - } - - private static void ReadSockets(Span socketBytes, IContext persistenceContext, Item item) - { - if (item.Definition!.MaximumSockets == 0) - { - return; - } - - var numberOfSockets = 0; - for (int i = 0; i < item.Definition.MaximumSockets; i++) - { - var socketByte = socketBytes[i]; - if (socketByte == NoSocket) - { - continue; - } - - numberOfSockets++; - if (socketByte == EmptySocket) - { - continue; - } - - var sphereLevel = socketByte / MaximumSocketOptions; - var optionIndex = socketByte % MaximumSocketOptions; - var indexOffset = SocketOptionIndexOffsets.First(offset => offset <= optionIndex); - var elementType = Array.IndexOf(SocketOptionIndexOffsets, indexOffset); - var optionNumber = optionIndex - indexOffset; - - var socketOption = item.Definition.PossibleItemOptions - .SelectMany(o => o.PossibleOptions - .Where(p => p.OptionType == ItemOptionTypes.SocketOption - && p.SubOptionType == elementType - && p.Number == optionNumber)) - .FirstOrDefault() - ?? throw new ArgumentException($"The socket option {socketByte} was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = socketOption; - optionLink.Level = sphereLevel; - optionLink.Index = i; - item.ItemOptions.Add(optionLink); - } - - item.SocketCount = numberOfSockets; - } - - private static void ReadSocketBonus(byte socketBonusByte, IContext persistenceContext, Item item) - { - if (socketBonusByte == 0 || socketBonusByte == 0xFF) - { - return; - } - - var bonusOption = item.Definition!.PossibleItemOptions - .SelectMany(o => o.PossibleOptions - .Where(p => p.OptionType == ItemOptionTypes.SocketBonusOption && p.Number == socketBonusByte)) - .FirstOrDefault(); - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = bonusOption; - item.ItemOptions.Add(optionLink); - } - - private static void ReadHarmonyOption(byte harmonyByte, IContext persistenceContext, Item item) - { - if (harmonyByte == 0) - { - return; - } - - var level = harmonyByte & 0x0F; - var optionNumber = (harmonyByte & 0xF0) >> 4; - var harmonyOption = item.Definition!.PossibleItemOptions - .SelectMany(o => o.PossibleOptions.Where(p => - p.OptionType == ItemOptionTypes.HarmonyOption && p.Number == optionNumber)) - .FirstOrDefault() - ?? throw new ArgumentException( - $"The harmony option {optionNumber} was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = harmonyOption; - optionLink.Level = level; - item.ItemOptions.Add(optionLink); - } - - private static void ReadFenrirOptions(byte fenrirByte, IContext persistenceContext, Item item) - { - if (fenrirByte == 0) - { - return; - } - - AddFenrirOptionIfFlagSet(fenrirByte, BlackFenrirFlag, ItemOptionTypes.BlackFenrir, persistenceContext, item); - AddFenrirOptionIfFlagSet(fenrirByte, BlueFenrirFlag, ItemOptionTypes.BlueFenrir, persistenceContext, item); - AddFenrirOptionIfFlagSet(fenrirByte, GoldFenrirFlag, ItemOptionTypes.GoldFenrir, persistenceContext, item); - } - - private static void AddFenrirOptionIfFlagSet(byte fenrirByte, byte fenrirFlag, ItemOptionType fenrirOptionType, IContext persistenceContext, Item item) - { - var isFlagSet = (fenrirByte & fenrirFlag) == fenrirFlag; - if (!isFlagSet) - { - return; - } - - var blackOption = item.Definition!.PossibleItemOptions.FirstOrDefault(o => o.PossibleOptions.Any(p => p.OptionType == fenrirOptionType)) - ?? throw new ArgumentException($"The fenrir flag {fenrirFlag} in {fenrirByte} was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); - var optionLink = persistenceContext.CreateNew(); - optionLink.ItemOption = blackOption.PossibleOptions.First(); - item.ItemOptions.Add(optionLink); - } - - private static byte GetFenrirByte(Item item) - { - byte result = 0; - if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.BlackFenrir)) - { - result |= BlackFenrirFlag; - } - - if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.BlueFenrir)) - { - result |= BlueFenrirFlag; - } - - if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GoldFenrir)) - { - result |= GoldFenrirFlag; - } - - return result; - } - - private static void SetSocketBytes(Span target, Item item) - { - byte GetSocketByte(int socketSlot) - { - var optionLink = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.SocketOption && o.Index == socketSlot); - if (optionLink is null) - { - return EmptySocket; - } - - var sphereLevel = optionLink.Level; - var elementType = optionLink.ItemOption!.SubOptionType; - var elementOption = optionLink.ItemOption.Number; - var optionIndex = SocketOptionIndexOffsets[elementType] + elementOption; - - return (byte)((sphereLevel * MaximumSocketOptions) + optionIndex); - } - - for (int i = 0; i < MaximumSockets; i++) - { - target[i] = i < item.SocketCount ? GetSocketByte(i) : NoSocket; - } - } - - private static byte GetSocketBonusByte(Item item) - { - if (item.SocketCount == 0) - { - return 0; - } - - var bonusOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.SocketBonusOption); - if (bonusOption?.ItemOption != null) - { - return (byte)bonusOption.ItemOption.Number; - } - - return 0xFF; - } - - private static byte GetHarmonyByte(Item item) - { - byte result = 0; - var harmonyOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.HarmonyOption); - if (harmonyOption?.ItemOption is not null) - { - result = (byte)(harmonyOption.ItemOption.Number << 4); - result |= (byte)harmonyOption.Level; - } - - return result; - } - - private static byte GetExcellentByte(Item item) - { - byte result = 0; - var excellentOptions = item.ItemOptions.Where(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent || o.ItemOption?.OptionType == ItemOptionTypes.Wing); - - foreach (var option in excellentOptions) - { - result |= (byte)(1 << (option.ItemOption!.Number - 1)); - } - - return result; - } } \ No newline at end of file diff --git a/src/GameServer/RemoteView/Inventory/ItemAppearPlugIn.cs b/src/GameServer/RemoteView/Inventory/ItemAppearPlugIn.cs index b90905181..b7893359c 100644 --- a/src/GameServer/RemoteView/Inventory/ItemAppearPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/ItemAppearPlugIn.cs @@ -44,9 +44,9 @@ int Write() { InventorySlot = newItem.ItemSlot, }; - itemSerializer.SerializeItem(packet.ItemData, newItem); + var itemSize = itemSerializer.SerializeItem(packet.ItemData, newItem); - return size; + return ItemAddedToInventoryRef.GetRequiredSize(itemSize); } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/Inventory/ItemBoughtFromPlayerShopPlugIn.cs b/src/GameServer/RemoteView/Inventory/ItemBoughtFromPlayerShopPlugIn.cs index e333715a0..d8f1b73d4 100644 --- a/src/GameServer/RemoteView/Inventory/ItemBoughtFromPlayerShopPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/ItemBoughtFromPlayerShopPlugIn.cs @@ -44,9 +44,9 @@ int Write() { InventorySlot = item.ItemSlot, }; - itemSerializer.SerializeItem(packet.ItemData, item); + var itemSize = itemSerializer.SerializeItem(packet.ItemData, item); - return size; + return ItemBoughtRef.GetRequiredSize(itemSize); } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/Inventory/ItemMoveFailedPlugIn.cs b/src/GameServer/RemoteView/Inventory/ItemMoveFailedPlugIn.cs index b1ef43d76..2832bbfd5 100644 --- a/src/GameServer/RemoteView/Inventory/ItemMoveFailedPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/ItemMoveFailedPlugIn.cs @@ -43,7 +43,9 @@ int Write() var packet = new ItemMoveRequestFailedRef(span); if (item != null) { - itemSerializer.SerializeItem(packet.ItemData, item); + + var itemSize = itemSerializer.SerializeItem(packet.ItemData, item); + return ItemMoveRequestFailedRef.GetRequiredSize(itemSize); } return size; diff --git a/src/GameServer/RemoteView/Inventory/ItemMovedPlugIn.cs b/src/GameServer/RemoteView/Inventory/ItemMovedPlugIn.cs index 381f3f4ad..a6fe3c341 100644 --- a/src/GameServer/RemoteView/Inventory/ItemMovedPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/ItemMovedPlugIn.cs @@ -46,16 +46,16 @@ public async ValueTask ItemMovedAsync(Item item, byte toSlot, Storages storage) int Write() { - var size = ItemMovedRef.GetRequiredSize(ItemMovedRef.GetRequiredSize(itemSerializer.NeededSpace)); + var size = ItemMovedRef.GetRequiredSize(itemSerializer.NeededSpace); var span = connection.Output.GetSpan(size)[..size]; var message = new ItemMovedRef(span) { TargetStorageType = targetStorage, TargetSlot = toSlot, }; - itemSerializer.SerializeItem(message.ItemData, item); + var itemSize = itemSerializer.SerializeItem(message.ItemData, item); - return size; + return ItemMovedRef.GetRequiredSize(itemSize); } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/Inventory/ItemUpgradedPlugIn.cs b/src/GameServer/RemoteView/Inventory/ItemUpgradedPlugIn.cs index 008d69154..2da38a256 100644 --- a/src/GameServer/RemoteView/Inventory/ItemUpgradedPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/ItemUpgradedPlugIn.cs @@ -44,8 +44,8 @@ int Write() { InventorySlot = item.ItemSlot, }; - itemSerializer.SerializeItem(packet.ItemData, item); - return size; + var itemSize = itemSerializer.SerializeItem(packet.ItemData, item); + return InventoryItemUpgradedRef.GetRequiredSize(itemSize); } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/Inventory/NpcItemBoughtPlugIn.cs b/src/GameServer/RemoteView/Inventory/NpcItemBoughtPlugIn.cs index 10e95e859..067b45df2 100644 --- a/src/GameServer/RemoteView/Inventory/NpcItemBoughtPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/NpcItemBoughtPlugIn.cs @@ -45,8 +45,8 @@ int Write() { InventorySlot = newItem.ItemSlot, }; - itemSerializer.SerializeItem(packet.ItemData, newItem); - return size; + var itemSize = itemSerializer.SerializeItem(packet.ItemData, newItem); + return ItemBoughtRef.GetRequiredSize(itemSize); } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/Inventory/UpdateInventoryListPlugIn.cs b/src/GameServer/RemoteView/Inventory/UpdateInventoryListPlugIn.cs index 1801766b8..33bc09648 100644 --- a/src/GameServer/RemoteView/Inventory/UpdateInventoryListPlugIn.cs +++ b/src/GameServer/RemoteView/Inventory/UpdateInventoryListPlugIn.cs @@ -47,16 +47,19 @@ int Write() ItemCount = (byte)items.Count, }; + int headerSize = CharacterInventoryRef.GetRequiredSize(0, 0); + int actualSize = headerSize; int i = 0; foreach (var item in items) { - var storedItem = packet[i, lengthPerItem]; + var storedItem = new StoredItemRef(span[actualSize..]); storedItem.ItemSlot = item.ItemSlot; - itemSerializer.SerializeItem(storedItem.ItemData, item); + var itemSize = itemSerializer.SerializeItem(storedItem.ItemData, item); + actualSize += StoredItemRef.GetRequiredSize(itemSize); i++; } - return size; + return actualSize; } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/ItemSerializer.cs b/src/GameServer/RemoteView/ItemSerializer.cs new file mode 100644 index 000000000..7d56620c4 --- /dev/null +++ b/src/GameServer/RemoteView/ItemSerializer.cs @@ -0,0 +1,240 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace MUnique.OpenMU.GameServer.RemoteView; + +using System.Net.Sockets; +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; + +using static ItemSerializerHelper; + +/// +/// This item serializer is used to serialize the item data to the data packets. +/// At the moment, each item is serialized into a 12-byte long part of an array: +/// Byte Order: ItemCode Options Dura Exe Ancient Kind/380Opt HarmonyOpt Socket1 Socket2 Socket3 Socket4 Socket5. +/// +[Guid("3607902F-C7A8-40D0-823A-186F3BF630C7")] +[PlugIn("Item Serializer", "The default item serializer. It's most likely only correct for season 6.")] +[MinimumClient(5, 0, ClientLanguage.Invariant)] +public class ItemSerializer : IItemSerializer +{ + private const byte LuckFlag = 4; + + private const byte SkillFlag = 128; + + private const byte LevelMask = 0x78; + + private const byte GuardianOptionFlag = 0x08; + + private const byte AncientBonusLevelMask = 0b1100; + private const byte AncientDiscriminatorMask = 0b0011; + private const byte AncientMask = AncientBonusLevelMask | AncientDiscriminatorMask; + + /// + public int NeededSpace => 12; + + /// + public int SerializeItem(Span target, Item item) + { + item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); + target[0] = (byte)item.Definition.Number; + + var itemLevel = item.IsTrainablePet() ? 0 : item.Level; + target[1] = (byte)((itemLevel << 3) & LevelMask); + + var itemOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option); + if (itemOption != null) + { + // The item option level is splitted into 2 parts. Webzen... :-/ + target[1] += (byte)(itemOption.Level & 3); // setting the first 2 bits + target[3] = (byte)((itemOption.Level & 4) << 4); // The highest bit is placed into the 2nd bit of the exc byte (0x40). + + // Some items (wings) can have different options (3rd wings up to 3!) + // Alternate options are set at array[startIndex + 3] |= 0x20 and 0x10 + if (itemOption.ItemOption?.Number > 0) + { + target[3] |= (byte)((itemOption.ItemOption.Number & 0b11) << 4); + } + } + + target[2] = item.Durability(); + + target[3] |= GetExcellentByte(item); + + if ((item.Definition.Number & 0x100) == 0x100) + { + // Support for 512 items per Group + target[3] |= 0x80; + } + + target[3] |= GetFenrirByte(item); + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck)) + { + target[1] |= LuckFlag; + } + + if (item.HasSkill) + { + target[1] |= SkillFlag; + } + + var ancientSet = item.ItemSetGroups.FirstOrDefault(set => set.AncientSetDiscriminator != 0); + if (ancientSet != null) + { + target[4] |= (byte)(ancientSet.AncientSetDiscriminator & AncientDiscriminatorMask); + + // An ancient item may or may not have an ancient bonus option. Example without bonus: Gywen Pendant. + var ancientBonus = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.AncientBonus); + if (ancientBonus != null) + { + target[4] |= (byte)((ancientBonus.Level << 2) & AncientBonusLevelMask); + } + } + + target[5] = (byte)(item.Definition.Group << 4); + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GuardianOption)) + { + target[5] |= GuardianOptionFlag; + } + + target[6] = (byte)(GetHarmonyByte(item) | GetSocketBonusByte(item)); + SetSocketBytes(target.Slice(7), item); + + return this.NeededSpace; + } + + /// + public Item DeserializeItem(Span array, GameConfiguration gameConfiguration, IContext persistenceContext) + { + var itemNumber = array[0] + ((array[0] & 0x80) << 1); + var itemGroup = (array[5] & 0xF0) >> 4; + var definition = gameConfiguration.Items.FirstOrDefault(def => def.Number == itemNumber && def.Group == itemGroup) + ?? throw new ArgumentException($"Couldn't find the item definition for the given byte array. Extracted item number and group: {itemNumber}, {itemGroup}"); + + var item = persistenceContext.CreateNew(); + item.Definition = definition; + item.Level = (byte)((array[1] & LevelMask) >> 3); + item.Durability = array[2]; + + if (item.Definition.PossibleItemOptions.Any(o => + o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Excellent))) + { + ReadExcellentOption(array[3], persistenceContext, item); + } + else if (item.Definition.PossibleItemOptions.Any(o => + o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Wing))) + { + ReadWingOption(array[3], persistenceContext, item); + } + else + { + // set nothing. + } + + ReadSkillFlag(array[1], item); + ReadLuckOption(array[1], persistenceContext, item); + ReadNormalOption(array, persistenceContext, item); + ReadAncientOption(array[4], persistenceContext, item); + ReadLevel380Option(array[5], persistenceContext, item); + if (item.Definition.PossibleItemOptions.Any(o => o.PossibleOptions.Any(p => p.OptionType == ItemOptionTypes.BlackFenrir))) + { + ReadFenrirOptions(array[3], persistenceContext, item); + } + + if (item.Definition.MaximumSockets == 0) + { + AddHarmonyOption(array[6], persistenceContext, item); + } + else + { + ReadSocketBonus(array[6], persistenceContext, item); + } + + ReadSockets(array.Slice(7), persistenceContext, item); + return item; + } + + private static void ReadSkillFlag(byte optionByte, Item item) + { + if ((optionByte & SkillFlag) == 0) + { + return; + } + + if (item.Definition!.Skill is null) + { + throw new ArgumentException($"The skill flag was set, but a skill is not defined for the specified item ({item.Definition.Number}, {item.Definition.Group})"); + } + + item.HasSkill = true; + } + + private static void ReadLuckOption(byte optionByte, IContext persistenceContext, Item item) + { + if ((optionByte & LuckFlag) == 0) + { + return; + } + + AddLuckOption(persistenceContext, item); + } + + private static void ReadWingOption(byte wingbyte, IContext persistenceContext, Item item) + { + var wingBits = wingbyte & 0x0F; + ReadWingOptionBits(wingBits, persistenceContext, item); + } + + private static void ReadExcellentOption(byte excByte, IContext persistenceContext, Item item) + { + var excellentBits = excByte & 0x3F; + ReadExcellentOptionBits(excellentBits, persistenceContext, item); + } + + private static void ReadNormalOption(Span array, IContext persistenceContext, Item item) + { + var optionLevel = (array[1] & 3) + ((array[3] >> 4) & 4); + if (optionLevel == 0) + { + return; + } + + var itemIsWing = item.Definition!.PossibleItemOptions.Any(o => o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Wing)); + var optionNumber = itemIsWing ? (array[3] >> 4) & 0b11 : 0; + + AddNormalOption(optionNumber, optionLevel, persistenceContext, item); + } + + private static void ReadAncientOption(byte ancientByte, IContext persistenceContext, Item item) + { + if ((ancientByte & AncientMask) == 0) + { + return; + } + + var bonusLevel = (ancientByte & AncientBonusLevelMask) >> 2; + var setDiscriminator = ancientByte & AncientDiscriminatorMask; + AddAncientOption(setDiscriminator, bonusLevel, persistenceContext, item); + } + + private static void ReadLevel380Option(byte option380Byte, IContext persistenceContext, Item item) + { + if ((option380Byte & GuardianOptionFlag) == 0) + { + return; + } + + AddLevel380Option(persistenceContext, item); + } +} \ No newline at end of file diff --git a/src/GameServer/RemoteView/ItemSerializer075.cs b/src/GameServer/RemoteView/ItemSerializer075.cs index 6f8a69998..81e1e7815 100644 --- a/src/GameServer/RemoteView/ItemSerializer075.cs +++ b/src/GameServer/RemoteView/ItemSerializer075.cs @@ -33,7 +33,7 @@ public class ItemSerializer075 : IItemSerializer public int NeededSpace => 3; /// - public void SerializeItem(Span target, Item item) + public int SerializeItem(Span target, Item item) { item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); target[0] = (byte)(item.Definition.Number & 0x0F); @@ -58,6 +58,8 @@ public void SerializeItem(Span target, Item item) } target[2] = item.Durability(); + + return this.NeededSpace; } /// diff --git a/src/GameServer/RemoteView/ItemSerializer095.cs b/src/GameServer/RemoteView/ItemSerializer095.cs index c7318d3d1..3dd50eaa1 100644 --- a/src/GameServer/RemoteView/ItemSerializer095.cs +++ b/src/GameServer/RemoteView/ItemSerializer095.cs @@ -49,7 +49,7 @@ public class ItemSerializer095 : IItemSerializer public int NeededSpace => 4; /// - public void SerializeItem(Span target, Item item) + public int SerializeItem(Span target, Item item) { item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); @@ -83,6 +83,8 @@ public void SerializeItem(Span target, Item item) } target[2] = item.Durability(); + + return this.NeededSpace; } /// diff --git a/src/GameServer/RemoteView/ItemSerializerExtended.cs b/src/GameServer/RemoteView/ItemSerializerExtended.cs new file mode 100644 index 000000000..e634004d2 --- /dev/null +++ b/src/GameServer/RemoteView/ItemSerializerExtended.cs @@ -0,0 +1,486 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; + +using static ItemSerializerHelper; + +/// +/// This item serializer is used to serialize the item data to the data packets. +/// At the moment, each item is serialized into a dynamical length 5 to 15-byte +/// long part of an array. +/// +[Guid("9EBB4761-93D4-49DE-AC53-BD8744315439")] +[PlugIn("Item Serializer", "The extended item serializer. It's most likely only correct for season 6.")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +public class ItemSerializerExtended : IItemSerializer +{ + private const byte EmptySocket = 0xFE; + private const byte BlackFenrirFlag = 0x01; + private const byte BlueFenrirFlag = 0x02; + private const byte GoldFenrirFlag = 0x04; + private const byte MaximumSocketOptions = 50; + + /// + /// The socket seed index offsets, where the key is the numerical value of a + /// and the value is the first index of this corresponding elemental seed. + /// + /// + /// Webzen decided to put every possible socket option of each elemental seed type into one big list, + /// which may contain up to elements. + /// I couldn't figure out a pattern, but found these index offsets by trial and error. + /// Their list contains holes, so expect that index 9 doesn't define an option. + /// + private static readonly byte[] SocketOptionIndexOffsets = { 0, 10, 16, 21, 29, 36 }; + + /// + public int NeededSpace => 15; + + /// + public int SerializeItem(Span target, Item item) + { + item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); + var targetStruct = new ItemStruct(target); + targetStruct.Group = (byte)item.Definition.Group; + targetStruct.Number = (ushort)item.Definition.Number; + targetStruct.Level = item.IsTrainablePet() ? (byte)0 : (byte)item.Level; + targetStruct.Durability = item.Durability(); + targetStruct.Options = GetOptionFlags(item); + + if (item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option) is { } itemOption) + { + targetStruct.OptionLevel = (byte)(itemOption.Level & 0xF); + + // Some items (wings) can have different options (3rd wings up to 3!) + targetStruct.OptionType = (byte)((itemOption.ItemOption?.Number ?? 0) & 0xF); + + } + + if (targetStruct.Options.HasFlag(OptionFlags.HasExcellent)) + { + targetStruct.Excellent = GetExcellentByte(item); + targetStruct.Excellent |= GetFenrirByte(item); + } + + if (targetStruct.Options.HasFlag(OptionFlags.HasAncient) + && item.ItemSetGroups.FirstOrDefault(set => set.AncientSetDiscriminator != 0) is { } ancientSet) + { + targetStruct.AncientDiscriminator = (byte)ancientSet.AncientSetDiscriminator; + + // An ancient item may or may not have an ancient bonus option. Example without bonus: Gywen Pendant. + if (item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.AncientBonus) is { } ancientBonus) + { + targetStruct.AncientBonusLevel = (byte)ancientBonus.Level; + } + } + + if (targetStruct.Options.HasFlag(OptionFlags.HasHarmony)) + { + targetStruct.Harmony = GetHarmonyByte(item); + } + + if (targetStruct.Options.HasFlag(OptionFlags.HasSockets)) + { + targetStruct.SocketCount = (byte)item.SocketCount; + targetStruct.SocketBonus = GetSocketBonusByte(item); + SetSocketBytes(targetStruct.Sockets, item); + } + + return targetStruct.Length; + } + + /// + public Item DeserializeItem(Span array, GameConfiguration gameConfiguration, IContext persistenceContext) + { + var itemStruct = new ItemStruct(array); + var itemGroup = itemStruct.Group; + var itemNumber = itemStruct.Number; + var definition = gameConfiguration.Items.FirstOrDefault(def => def.Number == itemNumber && def.Group == itemGroup) + ?? throw new ArgumentException($"Couldn't find the item definition for the given byte array. Extracted item number and group: {itemNumber}, {itemGroup}"); + + var item = persistenceContext.CreateNew(); + item.Definition = definition; + item.Level = itemStruct.Level; + item.Durability = itemStruct.Durability; + item.HasSkill = itemStruct.Options.HasFlag(OptionFlags.HasSkill) && item.Definition?.Skill is not null; + + if (itemStruct.Options.HasFlag(OptionFlags.HasOption)) + { + AddNormalOption(itemStruct.OptionType, itemStruct.OptionLevel, persistenceContext, item); + } + + if (itemStruct.Options.HasFlag(OptionFlags.HasLuck)) + { + AddLuckOption(persistenceContext, item); + } + + if (itemStruct.Options.HasFlag(OptionFlags.HasExcellent)) + { + if (item.IsWing()) + { + ReadWingOptionBits(itemStruct.Excellent, persistenceContext, item); + } + else + { + ReadExcellentOptionBits(itemStruct.Excellent, persistenceContext, item); + } + } + + if (itemStruct.Options.HasFlag(OptionFlags.HasAncient)) + { + AddAncientOption(itemStruct.AncientDiscriminator, itemStruct.AncientBonusLevel, persistenceContext, item); + } + + if (itemStruct.Options.HasFlag(OptionFlags.HasHarmony)) + { + AddHarmonyOption(itemStruct.Harmony, persistenceContext, item); + } + + if (itemStruct.Options.HasFlag(OptionFlags.HasGuardian)) + { + AddLevel380Option(persistenceContext, item); + } + + if (itemStruct.Options.HasFlag(OptionFlags.HasSockets)) + { + ReadSocketBonus(itemStruct.SocketBonus, persistenceContext, item); + ReadSockets(itemStruct.Sockets, persistenceContext, item); + } + + return item; + } + + private OptionFlags GetOptionFlags(Item item) + { + OptionFlags result = default; + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck)) + { + result |= OptionFlags.HasLuck; + } + + if (item.HasSkill) + { + result |= OptionFlags.HasSkill; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Option)) + { + result |= OptionFlags.HasOption; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent + || o.ItemOption?.OptionType == ItemOptionTypes.Wing + || o.ItemOption?.OptionType == ItemOptionTypes.BlackFenrir + || o.ItemOption?.OptionType == ItemOptionTypes.BlueFenrir + || o.ItemOption?.OptionType == ItemOptionTypes.GoldFenrir)) + { + result |= OptionFlags.HasExcellent; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.HarmonyOption)) + { + result |= OptionFlags.HasHarmony; + } + + if (item.ItemSetGroups.Any(set => set.AncientSetDiscriminator != 0)) + { + result |= OptionFlags.HasAncient; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GuardianOption)) + { + result |= OptionFlags.HasGuardian; + } + + if (item.SocketCount > 0) + { + result |= OptionFlags.HasSockets; + } + + return result; + } + + private static byte GetExcellentByte(Item item) + { + byte result = 0; + var excellentOptions = item.ItemOptions.Where(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent || o.ItemOption?.OptionType == ItemOptionTypes.Wing); + + foreach (var option in excellentOptions) + { + result |= (byte)(1 << (option.ItemOption!.Number - 1)); + } + + return result; + } + + private static byte GetFenrirByte(Item item) + { + byte result = 0; + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.BlackFenrir)) + { + result |= BlackFenrirFlag; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.BlueFenrir)) + { + result |= BlueFenrirFlag; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GoldFenrir)) + { + result |= GoldFenrirFlag; + } + + return result; + } + + private static byte GetHarmonyByte(Item item) + { + byte result = 0; + var harmonyOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.HarmonyOption); + if (harmonyOption?.ItemOption is not null) + { + result = (byte)(harmonyOption.ItemOption.Number << 4); + result |= (byte)harmonyOption.Level; + } + + return result; + } + + private static byte GetSocketBonusByte(Item item) + { + if (item.SocketCount == 0) + { + return 0; + } + + var bonusOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.SocketBonusOption); + if (bonusOption?.ItemOption != null) + { + return (byte)bonusOption.ItemOption.Number; + } + + return 0xFF; + } + + private static void SetSocketBytes(Span target, Item item) + { + for (int i = 0; i < item.SocketCount; i++) + { + target[i] = GetSocketByte(i); + } + + byte GetSocketByte(int socketSlot) + { + var optionLink = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.SocketOption && o.Index == socketSlot); + if (optionLink is null) + { + return EmptySocket; + } + + var sphereLevel = optionLink.Level; + var elementType = optionLink.ItemOption!.SubOptionType; + var elementOption = optionLink.ItemOption.Number; + var optionIndex = SocketOptionIndexOffsets[elementType] + elementOption; + + return (byte)((sphereLevel * MaximumSocketOptions) + optionIndex); + } + } + + /// + /// Layout: + /// Group: 4 bit + /// Number: 12 bit + /// Level: 8 bit + /// Dura: 8 bit + /// OptFlags: 8 bit + /// HasOpt + /// HasLuck + /// HasSkill + /// HasExc + /// HasAnc + /// HasGuardian + /// HasHarmony + /// HasSockets + /// Optional, depending on Flags: + /// Opt_Lvl 4 bit + /// Opt_Typ 4 bit + /// Exc: 8 bit + /// Anc_Dis 4 bit + /// Anc_Bon 4 bit + /// Harmony 8 bit + /// Soc_Bon 4 bit + /// Soc_Cnt 4 bit + /// Sockets n * 8 bit + /// + /// Total: 5 ~ 15 bytes. + /// + private readonly ref struct ItemStruct(Span data) + { + private readonly Span _data = data; + + public byte Group + { + get => (byte)((this._data[0] >> 4) & 0xF); + set + { + value <<= 4; + value |= (byte)(this._data[0] & 0xF); + this._data[0] = value; + } + } + + public ushort Number + { + get => (ushort)(((this._data[0] & 0xF) << 8) + this._data[1]); + set + { + // Higher 4 bits of the first byte for the higher bits of the value + this._data[0] = (byte)((this._data[0] & 0xF0) | (((value & 0x0F00) >> 8) & 0xF)); + + // The lower bits in the second byte + this._data[1] = (byte)(value & 0xFF); + } + } + + public byte Level + { + get => this._data[2]; + set => this._data[2] = value; + } + + public byte Durability + { + get => this._data[3]; + set => this._data[3] = value; + } + + public OptionFlags Options + { + get => (OptionFlags)this._data[4]; + set => this._data[4] = (byte)value; + } + + public byte OptionByte + { + get => this.Options.HasFlag(OptionFlags.HasOption) ? this._data[5] : default; + set => this._data[5] = value; + } + + public byte OptionLevel + { + get => this.Options.HasFlag(OptionFlags.HasOption) ? (byte)(this._data[5] & 0xF) : default; + set => this._data[5] = (byte)((this._data[5] & 0xF0) | (value & 0xF)); + } + + public byte OptionType + { + get => this.Options.HasFlag(OptionFlags.HasOption) ? (byte)((this._data[5] & 0xF0) >> 4) : default; + set + { + value = (byte)((value & 0xF) << 4); + value |= (byte)(this._data[5] & 0xF); + this._data[5] = value; + } + } + + public byte Excellent + { + get => this.Options.HasFlag(OptionFlags.HasExcellent) ? this._data[this.ExcellentIndex] : default; + set => this._data[this.ExcellentIndex] = value; + } + + public byte AncientByte + { + get => this.Options.HasFlag(OptionFlags.HasAncient) ? this._data[this.AncientIndex] : default; + set => this._data[this.AncientIndex] = value; + } + + public byte AncientDiscriminator + { + get => this.Options.HasFlag(OptionFlags.HasAncient) ? (byte)(this._data[this.AncientIndex] & 0xF) : default; + set => this._data[this.AncientIndex] = (byte)((this._data[this.AncientIndex] & 0xF0) | (value & 0xF)); + } + + public byte AncientBonusLevel + { + get => this.Options.HasFlag(OptionFlags.HasAncient) ? (byte)((this._data[this.AncientIndex] & 0xF0) >> 4) : default; + set + { + value = (byte)((value & 0xF) << 4); + value |= (byte)(this._data[this.AncientIndex] & 0xF); + this._data[this.AncientIndex] = value; + } + } + + public byte Harmony + { + get => this.Options.HasFlag(OptionFlags.HasHarmony) ? this._data[this.HarmonyIndex] : default; + set => this._data[this.HarmonyIndex] = value; + } + + //public byte Guardian + //{ + // get => this.Options.HasFlag(OptionFlags.HasGuardian) ? this._data[this.GuardianIndex] : default; + // set => this._data[this.GuardianIndex] = value; + //} + + public byte SocketBonus + { + get => this.Options.HasFlag(OptionFlags.HasSockets) ? (byte)((this._data[this.SocketStartIndex] & 0xF0) >> 4) : default; + set + { + value = (byte)((value & 0xF) << 4); + value |= (byte)(this._data[this.SocketStartIndex] & 0xF); + this._data[this.SocketStartIndex] = value; + } + } + + public byte SocketCount + { + get => this.Options.HasFlag(OptionFlags.HasSockets) ? (byte)(this._data[this.SocketStartIndex] & 0xF) : default; + set => this._data[this.SocketStartIndex] = (byte)((this._data[this.SocketStartIndex] & 0xF0) | (value & 0xF)); + } + + public Span Sockets => this.Options.HasFlag(OptionFlags.HasSockets) + ? this._data.Slice(this.SocketStartIndex + 1, this.SocketCount) + : []; + + public int Length => this.SocketStartIndex + 1 + this.SocketCount; + + private int ExcellentIndex => this.Options.HasFlag(OptionFlags.HasOption) ? 6 : 5; + + private int AncientIndex => this.Options.HasFlag(OptionFlags.HasAncient) ? this.ExcellentIndex + 1 : this.ExcellentIndex; + + private int HarmonyIndex => this.Options.HasFlag(OptionFlags.HasHarmony) ? this.AncientIndex + 1 : this.AncientIndex; + + //private int GuardianIndex => this.Options.HasFlag(OptionFlags.HasGuardian) ? this.HarmonyIndex + 1 : this.HarmonyIndex; + + private int SocketStartIndex => this.Options.HasFlag(OptionFlags.HasSockets) ? this.HarmonyIndex + 1 : this.HarmonyIndex; + } + + [Flags] + private enum OptionFlags : byte + { + None = 0x00, + HasOption = 0x01, + HasLuck = 0x02, + HasSkill = 0x04, + HasExcellent = 0x08, + HasAncient = 0x10, + HasHarmony = 0x20, + HasGuardian = 0x40, + HasSockets = 0x80, + } +} \ No newline at end of file diff --git a/src/GameServer/RemoteView/ItemSerializerHelper.cs b/src/GameServer/RemoteView/ItemSerializerHelper.cs new file mode 100644 index 000000000..d42f3c0e4 --- /dev/null +++ b/src/GameServer/RemoteView/ItemSerializerHelper.cs @@ -0,0 +1,329 @@ +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Persistence; + +namespace MUnique.OpenMU.GameServer.RemoteView; + +public static class ItemSerializerHelper +{ + internal const byte EmptySocket = 0xFE; + internal const byte NoSocket = 0xFF; + internal const int MaximumSockets = 5; + internal const byte MaximumSocketOptions = 50; + + + + private const byte BlackFenrirFlag = 0x01; + private const byte BlueFenrirFlag = 0x02; + private const byte GoldFenrirFlag = 0x04; + + /// + /// The socket seed index offsets, where the key is the numerical value of a + /// and the value is the first index of this corresponding elemental seed. + /// + /// + /// Webzen decided to put every possible socket option of each elemental seed type into one big list, + /// which may contain up to elements. + /// I couldn't figure out a pattern, but found these index offsets by trial and error. + /// Their list contains holes, so expect that index 9 doesn't define an option. + /// + private static readonly byte[] SocketOptionIndexOffsets = { 0, 10, 16, 21, 29, 36 }; + + + public static byte GetExcellentByte(Item item) + { + byte result = 0; + var excellentOptions = item.ItemOptions.Where(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent || o.ItemOption?.OptionType == ItemOptionTypes.Wing); + + foreach (var option in excellentOptions) + { + result |= (byte)(1 << (option.ItemOption!.Number - 1)); + } + + return result; + } + + public static byte GetHarmonyByte(Item item) + { + byte result = 0; + var harmonyOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.HarmonyOption); + if (harmonyOption?.ItemOption is not null) + { + result = (byte)(harmonyOption.ItemOption.Number << 4); + result |= (byte)harmonyOption.Level; + } + + return result; + } + + public static byte GetSocketBonusByte(Item item) + { + if (item.SocketCount == 0) + { + return 0; + } + + var bonusOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.SocketBonusOption); + if (bonusOption?.ItemOption != null) + { + return (byte)bonusOption.ItemOption.Number; + } + + return 0xFF; + } + + public static void SetSocketBytes(Span target, Item item) + { + byte GetSocketByte(int socketSlot) + { + var optionLink = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.SocketOption && o.Index == socketSlot); + if (optionLink is null) + { + return EmptySocket; + } + + var sphereLevel = optionLink.Level; + var elementType = optionLink.ItemOption!.SubOptionType; + var elementOption = optionLink.ItemOption.Number; + var optionIndex = SocketOptionIndexOffsets[elementType] + elementOption; + + return (byte)((sphereLevel * MaximumSocketOptions) + optionIndex); + } + + for (int i = 0; i < MaximumSockets; i++) + { + target[i] = i < item.SocketCount ? GetSocketByte(i) : NoSocket; + } + } + + public static void ReadFenrirOptions(byte fenrirByte, IContext persistenceContext, Item item) + { + if (fenrirByte == 0) + { + return; + } + + AddFenrirOptionIfFlagSet(fenrirByte, BlackFenrirFlag, ItemOptionTypes.BlackFenrir, persistenceContext, item); + AddFenrirOptionIfFlagSet(fenrirByte, BlueFenrirFlag, ItemOptionTypes.BlueFenrir, persistenceContext, item); + AddFenrirOptionIfFlagSet(fenrirByte, GoldFenrirFlag, ItemOptionTypes.GoldFenrir, persistenceContext, item); + } + + public static void ReadSockets(Span socketBytes, IContext persistenceContext, Item item) + { + if (item.Definition!.MaximumSockets == 0) + { + return; + } + + var numberOfSockets = 0; + for (int i = 0; i < socketBytes.Length; i++) + { + var socketByte = socketBytes[i]; + if (socketByte == NoSocket) + { + continue; + } + + numberOfSockets++; + if (socketByte == EmptySocket) + { + continue; + } + + var sphereLevel = socketByte / MaximumSocketOptions; + var optionIndex = socketByte % MaximumSocketOptions; + var indexOffset = SocketOptionIndexOffsets.First(offset => offset <= optionIndex); + var elementType = Array.IndexOf(SocketOptionIndexOffsets, indexOffset); + var optionNumber = optionIndex - indexOffset; + + var socketOption = item.Definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions + .Where(p => p.OptionType == ItemOptionTypes.SocketOption + && p.SubOptionType == elementType + && p.Number == optionNumber)) + .FirstOrDefault() + ?? throw new ArgumentException($"The socket option {socketByte} was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = socketOption; + optionLink.Level = sphereLevel; + optionLink.Index = i; + item.ItemOptions.Add(optionLink); + } + + item.SocketCount = numberOfSockets; + } + + public static void ReadSocketBonus(byte socketBonusByte, IContext persistenceContext, Item item) + { + if (socketBonusByte == 0 || socketBonusByte == 0xFF) + { + return; + } + + var bonusOption = item.Definition!.PossibleItemOptions + .SelectMany(o => o.PossibleOptions + .Where(p => p.OptionType == ItemOptionTypes.SocketBonusOption && p.Number == socketBonusByte)) + .FirstOrDefault(); + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = bonusOption; + item.ItemOptions.Add(optionLink); + } + + public static void AddHarmonyOption(byte harmonyByte, IContext persistenceContext, Item item) + { + if (harmonyByte == 0) + { + return; + } + + var level = harmonyByte & 0x0F; + var optionNumber = (harmonyByte & 0xF0) >> 4; + var harmonyOption = item.Definition!.PossibleItemOptions + .SelectMany(o => o.PossibleOptions.Where(p => + p.OptionType == ItemOptionTypes.HarmonyOption && p.Number == optionNumber)) + .FirstOrDefault() + ?? throw new ArgumentException( + $"The harmony option {optionNumber} was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = harmonyOption; + optionLink.Level = level; + item.ItemOptions.Add(optionLink); + } + + + public static void AddLevel380Option(IContext persistenceContext, Item item) + { + if (!item.Definition!.PossibleItemOptions.Any(o => o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.GuardianOption))) + { + throw new ArgumentException($"The lvl380 option flag was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); + } + + var guardianOptions = item.Definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions.Where(i => i.OptionType == ItemOptionTypes.GuardianOption)); + foreach (var option in guardianOptions) + { + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = option; + item.ItemOptions.Add(optionLink); + } + } + + public static byte GetFenrirByte(Item item) + { + byte result = 0; + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.BlackFenrir)) + { + result |= BlackFenrirFlag; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.BlueFenrir)) + { + result |= BlueFenrirFlag; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GoldFenrir)) + { + result |= GoldFenrirFlag; + } + + return result; + } + + public static void AddLuckOption(IContext persistenceContext, Item item) + { + var luckOption = item.Definition!.PossibleItemOptions + .SelectMany(o => o.PossibleOptions.Where(i => i.OptionType == ItemOptionTypes.Luck)) + .FirstOrDefault() + ?? throw new ArgumentException($"The luck flag was set, but luck option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = luckOption; + item.ItemOptions.Add(optionLink); + } + + public static void ReadWingOptionBits(int wingBits, IContext persistenceContext, Item item) + { + var wingOptionDefinition = item.Definition!.PossibleItemOptions.First(o => + o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Wing)); + foreach (var wingOption in wingOptionDefinition.PossibleOptions) + { + var optionMask = 1 << (wingOption.Number - 1); + if ((wingBits & optionMask) == optionMask) + { + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = wingOption; + item.ItemOptions.Add(optionLink); + } + } + } + + public static void ReadExcellentOptionBits(int excellentBits, IContext persistenceContext, Item item) + { + var excellentOptionDefinition = item.Definition!.PossibleItemOptions.First(o => + o.PossibleOptions.Any(i => i.OptionType == ItemOptionTypes.Excellent)); + foreach (var excellentOption in excellentOptionDefinition.PossibleOptions) + { + var optionMask = 1 << (excellentOption.Number - 1); + if ((excellentBits & optionMask) == optionMask) + { + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = excellentOption; + item.ItemOptions.Add(optionLink); + } + } + } + + public static void AddNormalOption(int optionNumber, int optionLevel, IContext persistenceContext, Item item) + { + if (optionLevel == 0) + { + return; + } + + var option = item.Definition?.PossibleItemOptions.SelectMany(o => o.PossibleOptions.Where(i => i.OptionType == ItemOptionTypes.Option && i.Number == optionNumber)) + .FirstOrDefault() + ?? throw new ArgumentException($"The option with level {optionLevel} and number {optionNumber} is not defined as possible option in the item definition ({item.Definition?.Number}, {item.Definition?.Group})."); + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = option; + optionLink.Level = optionLevel; + item.ItemOptions.Add(optionLink); + } + + public static void AddAncientOption(int setDiscriminator, int bonusLevel, IContext persistenceContext, Item item) + { + var ancientSets = item.Definition!.PossibleItemSetGroups + .Where(set => set.Options?.PossibleOptions.Any(o => o.OptionType == ItemOptionTypes.AncientOption) ?? false) + .SelectMany(i => i.Items).Where(i => i.ItemDefinition == item.Definition) + .Where(set => set.AncientSetDiscriminator == setDiscriminator).ToList(); + if (ancientSets.Count > 1) + { + throw new ArgumentException($"Ambiguous ancient set discriminator: {ancientSets.Count} sets with discriminator {setDiscriminator} found for item definition ({item.Definition.Number}, {item.Definition.Group})."); + } + + var itemOfSet = ancientSets.FirstOrDefault() + ?? throw new ArgumentException($"Couldn't find ancient set (discriminator {setDiscriminator}) for item ({item.Definition.Number}, {item.Definition.Group})."); + item.ItemSetGroups.Add(itemOfSet); + if (bonusLevel > 0) + { + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = itemOfSet.BonusOption; + optionLink.Level = bonusLevel; + item.ItemOptions.Add(optionLink); + } + } + + private static void AddFenrirOptionIfFlagSet(byte fenrirByte, byte fenrirFlag, ItemOptionType fenrirOptionType, IContext persistenceContext, Item item) + { + var isFlagSet = (fenrirByte & fenrirFlag) == fenrirFlag; + if (!isFlagSet) + { + return; + } + + var blackOption = item.Definition!.PossibleItemOptions.FirstOrDefault(o => o.PossibleOptions.Any(p => p.OptionType == fenrirOptionType)) + ?? throw new ArgumentException($"The fenrir flag {fenrirFlag} in {fenrirByte} was set, but the option is not defined as possible option in the item definition ({item.Definition.Number}, {item.Definition.Group})."); + var optionLink = persistenceContext.CreateNew(); + optionLink.ItemOption = blackOption.PossibleOptions.First(); + item.ItemOptions.Add(optionLink); + } +} \ No newline at end of file diff --git a/src/GameServer/RemoteView/NPC/ShowMerchantStoreItemListPlugIn.cs b/src/GameServer/RemoteView/NPC/ShowMerchantStoreItemListPlugIn.cs index b371dbdb5..a6d2519f6 100644 --- a/src/GameServer/RemoteView/NPC/ShowMerchantStoreItemListPlugIn.cs +++ b/src/GameServer/RemoteView/NPC/ShowMerchantStoreItemListPlugIn.cs @@ -47,16 +47,19 @@ int Write() Type = Convert(storeKind), }; + int headerSize = StoreItemListRef.GetRequiredSize(0, 0); + int actualSize = headerSize; int i = 0; foreach (var item in storeItems) { - var storedItem = packet[i, sizePerItem]; + var storedItem = new StoredItemRef(span[actualSize..]); storedItem.ItemSlot = item.ItemSlot; - itemSerializer.SerializeItem(storedItem.ItemData, item); + var itemSize = itemSerializer.SerializeItem(storedItem.ItemData, item); + actualSize += StoredItemRef.GetRequiredSize(itemSize); i++; } - return size; + return actualSize; } await connection.SendAsync(Write).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/PlayerShop/ShowShopItemListExtendedPlugIn.cs b/src/GameServer/RemoteView/PlayerShop/ShowShopItemListExtendedPlugIn.cs new file mode 100644 index 000000000..d2f221a72 --- /dev/null +++ b/src/GameServer/RemoteView/PlayerShop/ShowShopItemListExtendedPlugIn.cs @@ -0,0 +1,78 @@ +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.PlayerShop; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +namespace MUnique.OpenMU.GameServer.RemoteView.PlayerShop; + +/// +/// The extended implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn(nameof(ShowShopItemListExtendedPlugIn), "The extended implementation of the IShowShopItemListPlugIn which is forwarding everything to the game client with specific data packets.")] +[Guid("D64E9027-4801-46EE-9FD0-2FC66C33FE32")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +public class ShowShopItemListExtendedPlugIn : IShowShopItemListPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public ShowShopItemListExtendedPlugIn(RemotePlayer player) => this._player = player; + + /// + /// + /// Maybe cache the result, because a lot of players could request the same list. However, this isn't critical. + /// + public async ValueTask ShowShopItemListAsync(Player requestedPlayer, bool isUpdate) + { + var connection = this._player.Connection; + if (connection is null || requestedPlayer.ShopStorage is null || requestedPlayer.SelectedCharacter is null) + { + return; + } + + var itemSerializer = this._player.ItemSerializer; + var playerId = requestedPlayer.GetId(this._player); + var items = requestedPlayer.ShopStorage.Items.ToList(); + int Write() + { + var size = PlayerShopItemListExtendedRef.GetRequiredSize(items.Count, PlayerShopItemExtendedRef.GetRequiredSize(itemSerializer.NeededSpace)); + var span = connection.Output.GetSpan(size)[..size]; + var packet = new PlayerShopItemListExtendedRef(span) + { + Action = isUpdate + ? PlayerShopItemListExtended.ActionKind.UpdateAfterItemChange + : PlayerShopItemListExtended.ActionKind.ByRequest, + ItemCount = (byte)items.Count, + PlayerId = playerId, + PlayerName = requestedPlayer.SelectedCharacter.Name, + ShopName = requestedPlayer.ShopStorage.StoreName, + }; + + int headerSize = PlayerShopItemListExtendedRef.GetRequiredSize(0, 0); + int actualSize = headerSize; + int i = 0; + foreach (var item in items) + { + var itemBlock = new PlayerShopItemExtendedRef(span[actualSize..]); + itemBlock.ItemSlot = item.ItemSlot; + itemBlock.MoneyPrice = (uint)(item.StorePrice ?? 0); + // todo: when we can define a price in items, set PriceItemType and RequiredItemAmount + + var itemSize = itemSerializer.SerializeItem(itemBlock.ItemData, item); + actualSize += PlayerShopItemExtendedRef.GetRequiredSize(itemSize); + i++; + } + + return actualSize; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/GameServer/RemoteView/World/AppearanceChangedPlugIn.cs b/src/GameServer/RemoteView/World/AppearanceChangedPlugIn.cs index d267cd4eb..a2b1846c2 100644 --- a/src/GameServer/RemoteView/World/AppearanceChangedPlugIn.cs +++ b/src/GameServer/RemoteView/World/AppearanceChangedPlugIn.cs @@ -74,4 +74,41 @@ int Write() await connection.SendAsync(Write).ConfigureAwait(false); } +} + +/// +/// The extended implementation of the which is forwarding appearance changes of other players to the game client with specific data packets. +/// +[PlugIn(nameof(AppearanceChangedExtendedPlugIn), "The extended implementation of the IAppearanceChangedPlugIn which is forwarding appearance changes of other players to the game client with specific data packets.")] +[Guid("A2F298E4-9F48-402A-B30D-9BC2BA8DEB2E")] +public class AppearanceChangedExtendedPlugIn : IAppearanceChangedPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public AppearanceChangedExtendedPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask AppearanceChangedAsync(Player changedPlayer, Item item) + { + var connection = this._player.Connection; + if (connection is null || changedPlayer.Inventory is not { } inventory) + { + return; + } + + await connection.SendAppearanceChangedExtendedAsync( + changedPlayer.GetId(this._player), + item.ItemSlot, + (byte)(item.Definition?.Group ?? 0xFF), + (ushort)(item.Definition?.Number ?? 0xFFFF), + item.Level, + item.IsExcellent(), + item.IsAncient(), + changedPlayer.SelectedCharacter?.HasFullAncientSetEquipped() is true) + .ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/GameServer/RemoteView/World/ShowDroppedItemsPlugIn.cs b/src/GameServer/RemoteView/World/ShowDroppedItemsPlugIn.cs index 417468658..cc410e5ff 100644 --- a/src/GameServer/RemoteView/World/ShowDroppedItemsPlugIn.cs +++ b/src/GameServer/RemoteView/World/ShowDroppedItemsPlugIn.cs @@ -47,10 +47,12 @@ int Write() ItemCount = (byte)itemCount, }; + int headerSize = ItemsDroppedRef.GetRequiredSize(0, 0); + int actualSize = headerSize; int i = 0; foreach (var item in droppedItems) { - var itemBlock = packet[i, droppedItemLength]; + var itemBlock = new ItemsDroppedRef.DroppedItemRef(span[actualSize..]); itemBlock.Id = item.Id; if (freshDrops) { @@ -59,8 +61,8 @@ int Write() itemBlock.PositionX = item.Position.X; itemBlock.PositionY = item.Position.Y; - itemSerializer.SerializeItem(itemBlock.ItemData, item.Item); - + var itemSize = itemSerializer.SerializeItem(itemBlock.ItemData, item.Item); + actualSize += ItemsDroppedRef.DroppedItemRef.GetRequiredSize(itemSize); i++; } diff --git a/src/Network/Packets/ServerToClient/AddCharactersToScope.cs b/src/Network/Packets/ServerToClient/AddCharactersToScope.cs index bf27d0df3..92ee80d21 100644 --- a/src/Network/Packets/ServerToClient/AddCharactersToScope.cs +++ b/src/Network/Packets/ServerToClient/AddCharactersToScope.cs @@ -57,4 +57,4 @@ private int GetIndexOfCharacter(int characterIndex, out int nextIndex) return currentIndex; } -} \ No newline at end of file +} diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index f713a8d0e..f09b1a056 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -520,6 +520,48 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// The changed player id. + /// The item slot. + /// The item group. + /// The item number. + /// The item level. + /// The is excellent. + /// The is ancient. + /// The is ancient set complete. + /// + /// Is sent by the server when: The appearance of a player changed, all surrounding players are informed about it. + /// Causes reaction on client side: The appearance of the player is updated. + /// + public static async ValueTask SendAppearanceChangedExtendedAsync(this IConnection? connection, ushort @changedPlayerId, byte @itemSlot, byte @itemGroup, ushort @itemNumber, byte @itemLevel, bool @isExcellent, bool @isAncient, bool @isAncientSetComplete) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = AppearanceChangedExtendedRef.Length; + var packet = new AppearanceChangedExtendedRef(connection.Output.GetSpan(length)[..length]); + packet.ChangedPlayerId = @changedPlayerId; + packet.ItemSlot = @itemSlot; + packet.ItemGroup = @itemGroup; + packet.ItemNumber = @itemNumber; + packet.ItemLevel = @itemLevel; + packet.IsExcellent = @isExcellent; + packet.IsAncient = @isAncient; + packet.IsAncientSetComplete = @isAncientSetComplete; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// /// Sends a to this connection. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 52e09188a..9a703aa94 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -106,6 +106,75 @@ public uint Price } +/// +/// Data of an item in a player shop, which allows for dynamic item sizes and trades for specific kind of items (e.g. jewels), too.. +/// +public readonly struct PlayerShopItemExtended +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public PlayerShopItemExtended(Memory data) + { + this._data = data; + } + + /// + /// Gets or sets the item slot. + /// + public byte ItemSlot + { + get => this._data.Span[0]; + set => this._data.Span[0] = value; + } + + /// + /// Gets or sets the money price. + /// + public uint MoneyPrice + { + get => ReadUInt32LittleEndian(this._data.Span[4..]); + set => WriteUInt32LittleEndian(this._data.Span[4..], value); + } + + /// + /// Gets or sets contains the item group in the highest 4 bits, and the item number in the remaining ones. + /// + public ushort PriceItemType + { + get => ReadUInt16LittleEndian(this._data.Span[8..]); + set => WriteUInt16LittleEndian(this._data.Span[8..], value); + } + + /// + /// Gets or sets the required item amount. + /// + public ushort RequiredItemAmount + { + get => ReadUInt16LittleEndian(this._data.Span[9..]); + set => WriteUInt16LittleEndian(this._data.Span[9..], value); + } + + /// + /// Gets or sets the item data. + /// + public Span ItemData + { + get => this._data.Slice(11).Span; + } + + /// + /// Calculates the size of the packet for the specified length of . + /// + /// The length in bytes of on which the required size depends. + + public static int GetRequiredSize(int itemDataLength) => itemDataLength + 11; +} + + /// /// Defines the information which identifies a quest.. /// @@ -3969,6 +4038,148 @@ public Span ItemData } +/// +/// Is sent by the server when: The appearance of a player changed, all surrounding players are informed about it. +/// Causes reaction on client side: The appearance of the player is updated. +/// +public readonly struct AppearanceChangedExtended +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public AppearanceChangedExtended(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private AppearanceChangedExtended(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0x25; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 10; + + /// + /// Gets the header of this packet. + /// + public C1Header Header => new (this._data); + + /// + /// Gets or sets the changed player id. + /// + public ushort ChangedPlayerId + { + get => ReadUInt16LittleEndian(this._data.Span[3..]); + set => WriteUInt16LittleEndian(this._data.Span[3..], value); + } + + /// + /// Gets or sets the item slot. + /// + public byte ItemSlot + { + get => this._data.Span[5..].GetByteValue(4, 0); + set => this._data.Span[5..].SetByteValue(value, 4, 0); + } + + /// + /// Gets or sets the item group. + /// + public byte ItemGroup + { + get => this._data.Span[5..].GetByteValue(4, 4); + set => this._data.Span[5..].SetByteValue(value, 4, 4); + } + + /// + /// Gets or sets the item number. + /// + public ushort ItemNumber + { + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); + } + + /// + /// Gets or sets the item level. + /// + public byte ItemLevel + { + get => this._data.Span[8]; + set => this._data.Span[8] = value; + } + + /// + /// Gets or sets the is excellent. + /// + public bool IsExcellent + { + get => this._data.Span[9..].GetBoolean(0); + set => this._data.Span[9..].SetBoolean(value, 0); + } + + /// + /// Gets or sets the is ancient. + /// + public bool IsAncient + { + get => this._data.Span[9..].GetBoolean(1); + set => this._data.Span[9..].SetBoolean(value, 1); + } + + /// + /// Gets or sets the is ancient set complete. + /// + public bool IsAncientSetComplete + { + get => this._data.Span[9..].GetBoolean(2); + set => this._data.Span[9..].SetBoolean(value, 2); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator AppearanceChangedExtended(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(AppearanceChangedExtended packet) => packet._data; +} + + /// /// Is sent by the server when: The server wants to show a message above any kind of character, even NPCs. /// Causes reaction on client side: The message is shown above the character. @@ -10960,6 +11171,162 @@ public byte ItemCount } +/// +/// Is sent by the server when: After the player requested to open a shop of another player. +/// Causes reaction on client side: The player shop dialog is shown with the provided item data. +/// +public readonly struct PlayerShopItemListExtended +{ + /// + /// The kind of action which led to the list message. + /// + public enum ActionKind + { + /// + /// The list was requested. + /// + ByRequest = 5, + + /// + /// The list was changed, e.g. because an item was sold. + /// + UpdateAfterItemChange = 19, + } + + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public PlayerShopItemListExtended(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private PlayerShopItemListExtended(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + this.Success = true; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0x3F; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x05; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the action. + /// + public PlayerShopItemListExtended.ActionKind Action + { + get => (ActionKind)this._data.Span[4]; + set => this._data.Span[4] = (byte)value; + } + + /// + /// Gets or sets the success. + /// + public bool Success + { + get => this._data.Span[5..].GetBoolean(); + set => this._data.Span[5..].SetBoolean(value); + } + + /// + /// Gets or sets the player id. + /// + public ushort PlayerId + { + get => ReadUInt16BigEndian(this._data.Span[6..]); + set => WriteUInt16BigEndian(this._data.Span[6..], value); + } + + /// + /// Gets or sets the player name. + /// + public string PlayerName + { + get => this._data.Span.ExtractString(8, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(8, 10).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the shop name. + /// + public string ShopName + { + get => this._data.Span.ExtractString(18, 36, System.Text.Encoding.UTF8); + set => this._data.Slice(18, 36).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the item count. + /// + public byte ItemCount + { + get => this._data.Span[54]; + set => this._data.Span[54] = value; + } + + /// + /// Gets the of the specified index. + /// + public PlayerShopItemExtended this[int index, int playerShopItemExtendedLength] => new (this._data.Slice(55 + index * playerShopItemExtendedLength)); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator PlayerShopItemListExtended(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(PlayerShopItemListExtended packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of and it's size. + /// + /// The count of from which the size will be calculated. + /// The length of from which the size will be calculated. + + public static int GetRequiredSize(int itemsCount, int structLength) => itemsCount * structLength + 55; +} + + /// /// Is sent by the server when: After the player gets into scope of a player with an opened shop. /// Causes reaction on client side: The player shop title is shown at the specified players. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 65eb98eeb..50c084ad2 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -41,6 +41,39 @@ + + PlayerShopItemExtended + Data of an item in a player shop, which allows for dynamic item sizes and trades for specific kind of items (e.g. jewels), too. + + + 0 + Byte + ItemSlot + + + 4 + IntegerLittleEndian + MoneyPrice + + + 8 + ShortLittleEndian + PriceItemType + Contains the item group in the highest 4 bits, and the item number in the remaining ones. + + + 9 + ShortLittleEndian + RequiredItemAmount + + + 11 + Binary + ItemData + + + + QuestIdentification Defines the information which identifies a quest. @@ -1506,6 +1539,64 @@ + + C1Header + 25 + AppearanceChangedExtended + 10 + ServerToClient + The appearance of a player changed, all surrounding players are informed about it. + The appearance of the player is updated. + + + 3 + ShortLittleEndian + ChangedPlayerId + + + 5 + Byte + 0 + ItemSlot + 4 + + + 5 + Byte + 4 + ItemGroup + 4 + + + 6 + ShortLittleEndian + ItemNumber + + + 8 + Byte + ItemLevel + + + 9 + 0 + Boolean + IsExcellent + + + 9 + 1 + Boolean + IsAncient + + + 9 + 2 + Boolean + IsAncientSetComplete + + + C1Header 01 @@ -3926,6 +4017,76 @@ + + C2HeaderWithSubCode + 3F + 05 + PlayerShopItemListExtended + ServerToClient + After the player requested to open a shop of another player. + The player shop dialog is shown with the provided item data. + + + 4 + Enum + ActionKind + Action + + + 5 + Boolean + Success + true + + + 6 + ShortBigEndian + PlayerId + + + 8 + String + PlayerName + 10 + + + 18 + String + ShopName + 36 + + + 54 + Byte + ItemCount + + + 55 + Structure[] + PlayerShopItemExtended + Items + ItemCount + + + + + ActionKind + The kind of action which led to the list message. + + + ByRequest + The list was requested. + 5 + + + UpdateAfterItemChange + The list was changed, e.g. because an item was sold. + 19 + + + + + C2HeaderWithSubCode 3F diff --git a/tests/MUnique.OpenMU.Tests/ItemSerializerTests.cs b/tests/MUnique.OpenMU.Tests/ItemSerializerTests.cs index 1ed56802f..d3e14aa54 100644 --- a/tests/MUnique.OpenMU.Tests/ItemSerializerTests.cs +++ b/tests/MUnique.OpenMU.Tests/ItemSerializerTests.cs @@ -18,7 +18,20 @@ namespace MUnique.OpenMU.Tests; /// Unit tests for the . /// [TestFixture] -public class ItemSerializerTests +public class ItemSerializerTests : ItemSerializerTests; + +/// +/// Unit tests for the . +/// +[TestFixture] +public class ItemSerializerExtendedTests : ItemSerializerTests; + +/// +/// Generic unit tests for the s. +/// +[Ignore("Generic test")] +public class ItemSerializerTests + where T: IItemSerializer, new() { private GameConfiguration _gameConfiguration = null!; private IPersistenceContextProvider _contextProvider = null!; @@ -33,7 +46,7 @@ public async ValueTask SetupAsync() this._contextProvider = new InMemoryPersistenceContextProvider(); await new DataInitialization(this._contextProvider, new NullLoggerFactory()).CreateInitialDataAsync(3, true).ConfigureAwait(false); this._gameConfiguration = (await this._contextProvider.CreateNewConfigurationContext().GetAsync().ConfigureAwait(false)).First(); - this._itemSerializer = new ItemSerializer(); + this._itemSerializer = new T(); } ///