diff --git a/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs b/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs new file mode 100644 index 000000000..92bdc718a --- /dev/null +++ b/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs @@ -0,0 +1,237 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView.Quest; + +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Configuration.Quests; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Views.Quest; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Season 6-compatible implementation of . +/// Sends GC[0xF6][0x0C] in the format the S6 GMO client expects: +/// PMSG_NPC_QUESTEXP_INFO header followed by 5 NPC_QUESTEXP_REQUEST_INFO slots +/// and 5 NPC_QUESTEXP_REWARD_INFO slots (all with default MSVC alignment). +/// +[PlugIn] +[Display(Name = "QuestProgressS6PlugIn", Description = "Season 6 quest progress plugin sending F6 0C in S6 client format.")] +[Guid("B10A7CBD-3C5F-4FF3-8CAC-EE712E20A6C1")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +public class QuestProgressS6PlugIn : IQuestProgressPlugIn +{ + // S6 QUEST_REQUEST_TYPE values + private const byte RequestTypeNone = 0; + private const byte RequestTypeMonster = 1; + private const byte RequestTypeItem = 3; + private const byte RequestTypeZen = 15; + + // S6 QUEST_REWARD_TYPE values + private const byte RewardTypeNone = 0; + private const byte RewardTypeExp = 1; + private const byte RewardTypeZen = 2; + private const byte RewardTypeItem = 4; + + // Packet layout constants. + // sizeof(PMSG_NPC_QUESTEXP_INFO) = PWMSG_HEADER(4) + SubCode(1) + RequestCount(1) + // + RewardCount(1) + RandRewardCount(1) + QuestIndex DWORD(4) = 12 bytes. + private const int HeaderSize = 12; + + private const int MaxSlots = 5; + private const int ItemInfoLength = 15; + + // sizeof(NPC_QUESTEXP_REQUEST_INFO) with default MSVC alignment (/Zp8): + // BYTE(1) + pad(1) + WORD(2) + DWORD(4) + DWORD(4) + BYTE[15](15) + pad(1) = 28 bytes. + private const int RequestSlotSize = 28; + + // sizeof(NPC_QUESTEXP_REWARD_INFO) with default MSVC alignment (/Zp8): + // BYTE(1) + pad(1) + WORD(2) + DWORD(4) + BYTE[15](15) + pad(1) = 24 bytes. + private const int RewardSlotSize = 24; + + private const int TotalPacketSize = + HeaderSize + (MaxSlots * RequestSlotSize) + (MaxSlots * RewardSlotSize); // 272 + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public QuestProgressS6PlugIn(RemotePlayer player) + { + this._player = player; + } + + /// + public async ValueTask ShowQuestProgressAsync(QuestDefinition quest, bool wasProgressionRequested) + { + var connection = this._player.Connection; + if (connection is null) + { + return; + } + + var questState = this._player.SelectedCharacter?.QuestStates + .FirstOrDefault(q => q.Group == quest.Group); + + int Write() + { + var span = connection.Output.GetSpan(TotalPacketSize)[..TotalPacketSize]; + span.Clear(); + + // C2 header: [0xC2, sizeH, sizeL, 0xF6] + span[0] = 0xC2; + span[1] = (byte)(TotalPacketSize >> 8); + span[2] = (byte)(TotalPacketSize & 0xFF); + span[3] = 0xF6; + + // SubCode + span[4] = 0x0C; + + var monsterKills = quest.RequiredMonsterKills?.ToList() ?? new(); + var itemReqs = quest.RequiredItems?.ToList() ?? new(); + var rewards = quest.Rewards? + .Where(r => r.RewardType is QuestRewardType.Experience + or QuestRewardType.Money + or QuestRewardType.Item) + .Take(MaxSlots) + .ToList() ?? new(); + + int requestCount = Math.Min(monsterKills.Count + itemReqs.Count, MaxSlots); + int rewardCount = rewards.Count; + + span[5] = (byte)requestCount; + span[6] = (byte)rewardCount; + span[7] = 0; // RandRewardCount + + // QuestIndex = (Group << 16) | Number, little-endian DWORD + uint questIndex = ((uint)(ushort)quest.Group << 16) | (uint)(ushort)quest.Number; + BinaryPrimitives.WriteUInt32LittleEndian(span[8..12], questIndex); + + // Request slots (monster kills first, then item requirements) + int slot = 0; + foreach (var kill in monsterKills.Take(MaxSlots)) + { + this.WriteMonsterKillSlot(span, slot, kill, questState); + slot++; + } + + foreach (var itemReq in itemReqs.Take(MaxSlots - slot)) + { + this.WriteItemReqSlot(span, slot, itemReq); + slot++; + } + + // Reward slots + for (int r = 0; r < rewards.Count; r++) + { + this.WriteRewardSlot(span, r, rewards[r]); + } + + return TotalPacketSize; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } + + private void WriteMonsterKillSlot( + Span packet, + int slotIndex, + QuestMonsterKillRequirement kill, + CharacterQuestState? questState) + { + int b = HeaderSize + (slotIndex * RequestSlotSize); + + // [+0] type; [+1] alignment padding (cleared) + packet[b + 0] = RequestTypeMonster; + + // [+2..3] m_wIndex = monster number (WORD LE) + BinaryPrimitives.WriteUInt16LittleEndian(packet[(b + 2)..(b + 4)], (ushort)(kill.Monster?.Number ?? 0)); + + // [+4..7] m_dwValue = required kill count (DWORD LE) + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 4)..(b + 8)], (uint)kill.MinimumNumber); + + // [+8..11] m_wCurValue = current kill count (DWORD LE) + uint curCount = questState is null + ? 0u + : (uint)(questState.RequirementStates + .FirstOrDefault(s => s.Requirement != null && s.Requirement.Equals(kill)) + ?.KillCount ?? 0); + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 8)..(b + 12)], curCount); + + // [+12..26] m_byItemInfo[15] = zeros (already cleared); [+27] struct padding + } + + private void WriteItemReqSlot( + Span packet, + int slotIndex, + QuestItemRequirement itemReq) + { + if (itemReq.Item is not { } item) + { + return; + } + + int b = HeaderSize + (slotIndex * RequestSlotSize); + + packet[b + 0] = RequestTypeItem; + + // m_wIndex = item type (group << 9 | number) + ushort itemType = (ushort)(((ushort)(item.Group << 9)) | (ushort)item.Number); + BinaryPrimitives.WriteUInt16LittleEndian(packet[(b + 2)..(b + 4)], itemType); + + // m_dwValue = required count + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 4)..(b + 8)], (uint)itemReq.MinimumNumber); + + // m_wCurValue = current count in inventory + uint curCount = (uint)(this._player.Inventory?.Items.Count(i => Equals(i.Definition, item)) ?? 0); + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 8)..(b + 12)], curCount); + + // Serialize item definition into m_byItemInfo[15] + var temporaryItem = new TemporaryItem { Definition = item }; + temporaryItem.Durability = temporaryItem.GetMaximumDurabilityOfOnePiece(); + this._player.ItemSerializer.SerializeItem(packet[(b + 12)..(b + 27)], temporaryItem); + } + + private void WriteRewardSlot( + Span packet, + int slotIndex, + QuestReward reward) + { + int b = HeaderSize + (MaxSlots * RequestSlotSize) + (slotIndex * RewardSlotSize); + + byte rewardType = reward.RewardType switch + { + QuestRewardType.Experience => RewardTypeExp, + QuestRewardType.Money => RewardTypeZen, + QuestRewardType.Item => RewardTypeItem, + _ => RewardTypeNone, + }; + packet[b + 0] = rewardType; + + if (reward.RewardType == QuestRewardType.Item && reward.ItemReward?.Definition is { } itemDef) + { + // m_wIndex = item type + ushort itemType = (ushort)(((ushort)(itemDef.Group << 9)) | (ushort)itemDef.Number); + BinaryPrimitives.WriteUInt16LittleEndian(packet[(b + 2)..(b + 4)], itemType); + + // m_dwValue = reward quantity + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 4)..(b + 8)], (uint)reward.Value); + + // Serialize item into m_byItemInfo[15] + this._player.ItemSerializer.SerializeItem(packet[(b + 8)..(b + 23)], reward.ItemReward); + } + else + { + // EXP or ZEN: m_wIndex = 0, m_dwValue = amount + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 4)..(b + 8)], (uint)reward.Value); + } + } +}