From 196899f5d33fa8f1682d6fac565288a0a00baa47 Mon Sep 17 00:00:00 2001 From: Noel Felipe Date: Mon, 1 Jun 2026 13:15:11 -0300 Subject: [PATCH 1/2] feat(quest): add QuestProgressS6PlugIn for Season 6 client compatibility ## Problem The default QuestProgressPlugIn sends a 251-byte C1 F6 0C packet in the Season 9 format (QuestProgress struct). When a Season 6 GMO client receives this packet, it interprets it as PMSG_NPC_QUESTEXP_INFO and attempts to parse the trailing data as NPC_QUESTEXP_REQUEST_INFO[5] and NPC_QUESTEXP_REWARD_INFO[5] arrays. Because the packet layout does not match what the S6 client expects, it reads garbage byte counts, loops out of bounds over an internal array, and disconnects. ## Root Cause OpenMU uses a single QuestProgressPlugIn with no client-version guard. The packet it produces matches the Season 9+ protocol (C1 header, QuestProgress / QuestState struct layout). The Season 6 client speaks a different, older protocol for the same opcode (F6 0C): Client S6 expected layout (C2 packet, 272 bytes): PWMSG_HEADER (4 bytes) : C2 sizeH sizeL 0xF6 SubCode (1 byte) : 0x0C RequestCount (1 byte) RewardCount (1 byte) RandCount (1 byte) QuestIndex (4 bytes) : (Group << 16) | Number, little-endian 5 x NPC_QUESTEXP_REQUEST_INFO (28 bytes each, MSVC default alignment): QUEST_REQUEST_TYPE [+0] BYTE (1) + pad (1) m_wIndex [+2] WORD (2) m_dwValue [+4] DWORD (4) required count / level m_wCurValue [+8] DWORD (4) current progress m_byItemInfo[15] [+12] BYTE (15) struct pad [+27] BYTE (1) 5 x NPC_QUESTEXP_REWARD_INFO (24 bytes each, MSVC default alignment): QUEST_REWARD_TYPE [+0] BYTE (1) + pad (1) m_wIndex [+2] WORD (2) m_dwValue [+4] DWORD (4) amount m_byItemInfo[15] [+8] BYTE (15) struct pad [+23] BYTE (1) ## Solution Add QuestProgressS6PlugIn decorated with [MinimumClient(6, 0)] so that the ViewPlugInContainer selects it over the undecorated default plugin for any Season 6 client. For Season 9+ clients the existing QuestProgressExtendedPlugIn (MinimumClient 106.3) takes precedence. The plugin builds the 272-byte packet manually using BinaryPrimitives for endian-safe writes and maps OpenMU domain objects to S6 wire types: OpenMU QuestMonsterKillRequirement -> QUEST_REQUEST_MONSTER (1) - Monster.Number -> m_wIndex - MinimumNumber -> m_dwValue - KillCount (state) -> m_wCurValue OpenMU QuestItemRequirement -> QUEST_REQUEST_ITEM (3) - Item type (group<<9|num)-> m_wIndex - MinimumNumber -> m_dwValue - inventory count -> m_wCurValue - serialized item data -> m_byItemInfo[15] OpenMU QuestReward (Experience) -> QUEST_REWARD_EXP (1) OpenMU QuestReward (Money) -> QUEST_REWARD_ZEN (2) OpenMU QuestReward (Item) -> QUEST_REWARD_ITEM (4) - item type -> m_wIndex - Value -> m_dwValue - serialized item data -> m_byItemInfo[15] ## Affected files src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs (new) No existing files were modified. The original QuestProgressPlugIn can be disabled via the PlugInConfiguration table in the database (set IsActive = false for GUID 5D2B7F90-FEFA-4889-B339-D64512471613) to prevent it from competing with this plugin on servers that only serve Season 6 clients. --- .../RemoteView/Quest/QuestProgressS6PlugIn.cs | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs diff --git a/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs b/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs new file mode 100644 index 000000000..ef7a2ea13 --- /dev/null +++ b/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs @@ -0,0 +1,232 @@ +// +// 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("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")] +[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(); + var itemReqs = quest.RequiredItems.ToList(); + var rewards = quest.Rewards + .Where(r => r.RewardType is QuestRewardType.Experience + or QuestRewardType.Money + or QuestRewardType.Item) + .Take(MaxSlots) + .ToList(); + + 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); + + // [+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) + { + int b = HeaderSize + (slotIndex * RequestSlotSize); + + packet[b + 0] = RequestTypeItem; + + // m_wIndex = item type (group << 9 | number) + ushort itemType = (ushort)(((ushort)(itemReq.Item!.Group << 9)) | (ushort)itemReq.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, itemReq.Item)) ?? 0); + BinaryPrimitives.WriteUInt32LittleEndian(packet[(b + 8)..(b + 12)], curCount); + + // Serialize item definition into m_byItemInfo[15] + var temporaryItem = new TemporaryItem { Definition = itemReq.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); + } + } +} From 0edcfa59b01cff35911986a5b802c18d9ce6ecab Mon Sep 17 00:00:00 2001 From: Noel Felipe Date: Mon, 1 Jun 2026 13:32:12 -0300 Subject: [PATCH 2/2] fix(quest): address Gemini code review feedback on QuestProgressS6PlugIn - Replace placeholder GUID with randomly generated one (B10A7CBD-...) - Use null-safe collection access: RequiredMonsterKills?.ToList() ?? new() - Use null-safe monster dereference: kill.Monster?.Number ?? 0 - Guard against null Item in WriteItemReqSlot with early return pattern --- .../RemoteView/Quest/QuestProgressS6PlugIn.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs b/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs index ef7a2ea13..92bdc718a 100644 --- a/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs +++ b/src/GameServer/RemoteView/Quest/QuestProgressS6PlugIn.cs @@ -23,7 +23,7 @@ namespace MUnique.OpenMU.GameServer.RemoteView.Quest; /// [PlugIn] [Display(Name = "QuestProgressS6PlugIn", Description = "Season 6 quest progress plugin sending F6 0C in S6 client format.")] -[Guid("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")] +[Guid("B10A7CBD-3C5F-4FF3-8CAC-EE712E20A6C1")] [MinimumClient(6, 0, ClientLanguage.Invariant)] public class QuestProgressS6PlugIn : IQuestProgressPlugIn { @@ -95,14 +95,14 @@ int Write() // SubCode span[4] = 0x0C; - var monsterKills = quest.RequiredMonsterKills.ToList(); - var itemReqs = quest.RequiredItems.ToList(); - var rewards = quest.Rewards + 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(); + .ToList() ?? new(); int requestCount = Math.Min(monsterKills.Count + itemReqs.Count, MaxSlots); int rewardCount = rewards.Count; @@ -153,7 +153,7 @@ private void WriteMonsterKillSlot( packet[b + 0] = RequestTypeMonster; // [+2..3] m_wIndex = monster number (WORD LE) - BinaryPrimitives.WriteUInt16LittleEndian(packet[(b + 2)..(b + 4)], (ushort)kill.Monster!.Number); + 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); @@ -174,23 +174,28 @@ private void WriteItemReqSlot( 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)(itemReq.Item!.Group << 9)) | (ushort)itemReq.Item.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, itemReq.Item)) ?? 0); + 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 = itemReq.Item }; + var temporaryItem = new TemporaryItem { Definition = item }; temporaryItem.Durability = temporaryItem.GetMaximumDurabilityOfOnePiece(); this._player.ItemSerializer.SerializeItem(packet[(b + 12)..(b + 27)], temporaryItem); }