From 4272491d56fda1c80ea005b987d382eb809b409c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 04:25:05 -0700 Subject: [PATCH 01/90] feat: send CMSG_SET_ACTION_BUTTON to server when action bar slot changes Action bar changes (dragging spells/items) were only saved locally. Now notifies the server via CMSG_SET_ACTION_BUTTON so the layout persists across relogs. Supports Classic (5-byte) and TBC/WotLK (packed uint32) wire formats. --- include/game/world_packets.hpp | 15 ++++++++++++++ src/game/game_handler.cpp | 10 ++++++++++ src/game/world_packets.cpp | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index e5c7e63c..7e0d9a41 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -947,6 +947,21 @@ public: static network::Packet build(uint8_t state); }; +// ============================================================ +// Action Bar +// ============================================================ + +/** CMSG_SET_ACTION_BUTTON packet builder */ +class SetActionButtonPacket { +public: + // button: 0-based slot index + // type: ActionBarSlot::Type (SPELL=0, ITEM=1, MACRO=2, EMPTY=0) + // id: spellId, itemId, or macroId (0 to clear) + // isClassic: true for Vanilla/Turtle format (5-byte payload), + // false for TBC/WotLK (5-byte packed uint32) + static network::Packet build(uint8_t button, uint8_t type, uint32_t id, bool isClassic); +}; + // ============================================================ // Display Toggles // ============================================================ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 90fa6a12..3046c4c6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16101,6 +16101,16 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t queryItemInfo(id, 0); } saveCharacterConfig(); + // Notify the server so the action bar persists across relogs. + if (state == WorldState::IN_WORLD && socket) { + const bool classic = isClassicLikeExpansion(); + auto pkt = SetActionButtonPacket::build( + static_cast(slot), + static_cast(type), + id, + classic); + socket->send(pkt); + } } float GameHandler::getSpellCooldown(uint32_t spellId) const { diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 14dc7a20..090ead75 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1905,6 +1905,42 @@ network::Packet StandStateChangePacket::build(uint8_t state) { return packet; } +// ============================================================ +// Action Bar +// ============================================================ + +network::Packet SetActionButtonPacket::build(uint8_t button, uint8_t type, uint32_t id, bool isClassic) { + // Classic/Turtle (1.12): uint8 button + uint16 id + uint8 type + uint8 misc(0) + // type encoding: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint8 button + uint32 packed (type<<24 | id) + // type encoding: 0x00=spell, 0x80=item, 0x40=macro + // packed=0 means clear the slot + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTION_BUTTON)); + packet.writeUInt8(button); + if (isClassic) { + // Classic: 16-bit id, 8-bit type code, 8-bit misc + // Map ActionBarSlot::Type (0=EMPTY,1=SPELL,2=ITEM,3=MACRO) → classic type byte + uint8_t classicType = 0; // 0 = spell + if (type == 2 /* ITEM */) classicType = 1; + if (type == 3 /* MACRO */) classicType = 64; + packet.writeUInt16(static_cast(id)); + packet.writeUInt8(classicType); + packet.writeUInt8(0); // misc + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (Classic): button=", (int)button, + " id=", id, " type=", (int)classicType); + } else { + // TBC/WotLK: type in bits 24–31, id in bits 0–23; packed=0 clears slot + uint8_t packedType = 0x00; // spell + if (type == 2 /* ITEM */) packedType = 0x80; + if (type == 3 /* MACRO */) packedType = 0x40; + uint32_t packed = (id == 0) ? 0 : (static_cast(packedType) << 24) | (id & 0x00FFFFFF); + packet.writeUInt32(packed); + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (TBC/WotLK): button=", (int)button, + " packed=0x", std::hex, packed, std::dec); + } + return packet; +} + // ============================================================ // Display Toggles // ============================================================ From 2c6902d27d5eac403e159dd054263753fc2f506e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 04:37:36 -0700 Subject: [PATCH 02/90] fix: mining nodes no longer report invalid target and now open loot after gather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. Retry logic (for Classic) re-sent CMSG_GAMEOBJ_USE at 0.15s while the gather cast was in-flight, causing SPELL_FAILED_BAD_TARGETS. Now clears pendingGameObjectLootRetries_ as soon as SMSG_SPELL_START shows the player started a cast (gather accepted). 2. CMSG_LOOT was sent immediately before the gather cast completed, then never sent again — so the loot window never opened. Now tracks the last interacted GO and sends CMSG_LOOT in handleSpellGo once the gather spell completes, matching how the real client behaves. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3481285b..7e35e203 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2778,6 +2778,9 @@ private: float timer = 0.0f; }; std::vector pendingGameObjectLootOpens_; + // Tracks the last GO we sent CMSG_GAMEOBJ_USE to; used in handleSpellGo + // to send CMSG_LOOT after a gather cast (mining/herbalism) completes. + uint64_t lastInteractedGoGuid_ = 0; uint64_t pendingLootMoneyGuid_ = 0; uint32_t pendingLootMoneyAmount_ = 0; float pendingLootMoneyNotifyTimer_ = 0.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3046c4c6..e2baca55 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16213,6 +16213,14 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { + // CMSG_GAMEOBJ_USE was accepted — cancel pending USE retries so we don't + // re-send GAMEOBJ_USE mid-gather-cast and get SPELL_FAILED_BAD_TARGETS. + // Keep entries that only have sendLoot (no-cast chests that still need looting). + pendingGameObjectLootRetries_.erase( + std::remove_if(pendingGameObjectLootRetries_.begin(), pendingGameObjectLootRetries_.end(), + [](const PendingLootRetry&) { return true; /* cancel all retries once a gather cast starts */ }), + pendingGameObjectLootRetries_.end()); + casting = true; castIsChannel = false; currentCastSpellId = data.spellId; @@ -16289,6 +16297,13 @@ void GameHandler::handleSpellGo(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; + // If we were gathering a node (mining/herbalism), send CMSG_LOOT now that + // the gather cast completed and the server has made the node lootable. + if (lastInteractedGoGuid_ != 0) { + lootTarget(lastInteractedGoGuid_); + lastInteractedGoGuid_ = 0; + } + // End cast animation on player character if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); @@ -17369,6 +17384,7 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { auto packet = GameObjectUsePacket::build(guid); socket->send(packet); + lastInteractedGoGuid_ = guid; // For mailbox GameObjects (type 19), open mail UI and request mail list. // In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends @@ -18582,6 +18598,7 @@ void GameHandler::handleLootResponse(network::Packet& packet) { const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; lootWindowOpen = true; + lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false}; // Query item info so loot window can show names instead of IDs From cc2b413e22798f7f9ed46b0167008e5d41f2a241 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 04:59:05 -0700 Subject: [PATCH 03/90] fix: guard gather-node CMSG_LOOT dispatch against instant casts and proc spells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSpellGo fired lootTarget(lastInteractedGoGuid_) on ANY player spell completion, including instant casts and proc/triggered spells that arrive while the gather cast is still in flight. Save the casting flag before clearing it and only dispatch CMSG_LOOT when wasInTimedCast is true — this ensures only the gather cast completion triggers the post-gather loot send, not unrelated instant spells that also produce SMSG_SPELL_GO. --- src/game/game_handler.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e2baca55..8ee75b9c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16292,6 +16292,12 @@ void GameHandler::handleSpellGo(network::Packet& packet) { meleeSwingCallback_(); } + // Capture cast state before clearing: SMSG_SPELL_GO for instant casts and + // proc/triggered spells arrives while casting == false (they never go through + // handleSpellStart with castTime > 0). We must NOT send CMSG_LOOT for a + // gather node in those cases — only when a real timed gather cast completes. + const bool wasInTimedCast = casting; + casting = false; castIsChannel = false; currentCastSpellId = 0; @@ -16299,7 +16305,8 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // If we were gathering a node (mining/herbalism), send CMSG_LOOT now that // the gather cast completed and the server has made the node lootable. - if (lastInteractedGoGuid_ != 0) { + // Guard with wasInTimedCast to avoid firing on instant casts / procs. + if (wasInTimedCast && lastInteractedGoGuid_ != 0) { lootTarget(lastInteractedGoGuid_); lastInteractedGoGuid_ = 0; } From 7a4347dbacc530a4e93daff8ce0b826971e94ef9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:02:58 -0700 Subject: [PATCH 04/90] fix: clear lastInteractedGoGuid_ on cast failure, cancel, and world reset If a gather cast was interrupted by SMSG_SPELL_FAILURE (e.g. player took damage during mining), lastInteractedGoGuid_ was left set. A subsequent timed cast completion would then fire CMSG_LOOT for the stale node even though the gather never completed. Clear lastInteractedGoGuid_ in all cast-termination paths: - SMSG_SPELL_FAILURE (cast interrupted by server) - SMSG_CAST_RESULT non-zero (cast rejected before it started) - cancelCast() (player or system cancelled the cast) - World reset / logout block (state-clear boundary) --- src/game/game_handler.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8ee75b9c..af089d93 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2002,6 +2002,7 @@ void GameHandler::handlePacket(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + lastInteractedGoGuid_ = 0; // Pass player's power type so result 85 says "Not enough rage/energy/etc." int playerPowerType = -1; if (auto pe = entityManager.getEntity(playerGuid)) { @@ -3012,10 +3013,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } } if (failGuid == playerGuid || failGuid == 0) { - // Player's own cast failed + // Player's own cast failed — clear gather-node loot target so the + // next timed cast doesn't try to loot a stale interrupted gather node. casting = false; castIsChannel = false; currentCastSpellId = 0; + lastInteractedGoGuid_ = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); @@ -7996,6 +7999,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; playerDead_ = false; @@ -12219,6 +12223,7 @@ void GameHandler::stopCasting() { castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; @@ -15912,6 +15917,7 @@ void GameHandler::cancelCast() { socket->send(packet); } pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; casting = false; castIsChannel = false; currentCastSpellId = 0; From 6878f120e9c6ef9ed0ddf83f8ad0a41484c11531 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:03:50 -0700 Subject: [PATCH 05/90] fix: clear lastInteractedGoGuid_ in handleCastFailed path SMSG_CAST_FAILED is a direct rejection (e.g. insufficient range, no mana) before the cast starts. Missing this path meant a stale gather-node guid could survive into the next timed cast if SMSG_CAST_FAILED fired instead of SMSG_SPELL_FAILURE. --- src/game/game_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index af089d93..05feb17b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16164,6 +16164,7 @@ void GameHandler::handleCastFailed(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + lastInteractedGoGuid_ = 0; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { From 01f4ef5e799d8aa8d35835c9210bb0def8b8b2d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:06:00 -0700 Subject: [PATCH 06/90] fix: clear lastInteractedGoGuid_ for non-lootable GO interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mailboxes, doors, buttons, and other non-lootable GOs set shouldSendLoot=false so no CMSG_LOOT is dispatched — but lastInteractedGoGuid_ was still set. Without SMSG_LOOT_RESPONSE to clear it, a subsequent timed cast completion (e.g. player buffs at the mailbox) would fire a spurious CMSG_LOOT for the mailbox GUID. --- src/game/game_handler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 05feb17b..11c6e6a7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17446,6 +17446,11 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { } if (shouldSendLoot) { lootTarget(guid); + } else { + // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be + // sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot + // guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT. + lastInteractedGoGuid_ = 0; } // Retry use briefly to survive packet loss/order races. const bool retryLoot = shouldSendLoot; From f44defec389684dac1009560ab5c0863dc792def Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:07:51 -0700 Subject: [PATCH 07/90] feat: show fish-hooked notification when fishing bobber splashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server sends SMSG_GAMEOBJECT_CUSTOM_ANIM with animId=0 for a GO of type 17 (FISHINGNODE), a fish has been hooked and the player needs to click the bobber quickly. Add a system chat message and a UI sound to alert the player — previously there was no visual/audio feedback beyond the bobber animation itself. --- src/game/game_handler.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 11c6e6a7..7e4fd86f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4528,6 +4528,25 @@ void GameHandler::handlePacket(network::Packet& packet) { if (gameObjectCustomAnimCallback_) { gameObjectCustomAnimCallback_(guid, animId); } + // animId == 0 is the fishing bobber splash ("fish hooked"). + // Detect by GO type 17 (FISHINGNODE) and notify the player so they + // know to click the bobber before the fish escapes. + if (animId == 0) { + auto goEnt = entityManager.getEntity(guid); + if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(goEnt); + auto* info = getCachedGameObjectInfo(go->getEntry()); + if (info && info->type == 17) { // GO_TYPE_FISHINGNODE + addSystemChatMessage("A fish is on your line!"); + // Play a distinctive UI sound to alert the player + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) { + sfx->playQuestUpdate(); // Distinct "ping" sound + } + } + } + } + } } break; } From 48bcee32b4252d4e04d2978b1cdb290979f74f6a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:12:22 -0700 Subject: [PATCH 08/90] fix: match spell ID in handleSpellGo to prevent proc spells triggering gather loot wasInTimedCast checked casting == true but not whether the completing spell was actually the gather cast. A triggered/proc spell (SMSG_SPELL_GO with a different spellId) could arrive while a gather cast is active (casting==true), satisfying the old guard and firing lootTarget prematurely. Require data.spellId == currentCastSpellId so only the spell that started the cast bar triggers the post-gather CMSG_LOOT dispatch. --- src/game/game_handler.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7e4fd86f..3d46fd31 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16318,11 +16318,12 @@ void GameHandler::handleSpellGo(network::Packet& packet) { meleeSwingCallback_(); } - // Capture cast state before clearing: SMSG_SPELL_GO for instant casts and - // proc/triggered spells arrives while casting == false (they never go through - // handleSpellStart with castTime > 0). We must NOT send CMSG_LOOT for a - // gather node in those cases — only when a real timed gather cast completes. - const bool wasInTimedCast = casting; + // Capture cast state before clearing. Guard with spellId match so that + // proc/triggered spells (which fire SMSG_SPELL_GO while a gather cast is + // still active and casting == true) do NOT trigger premature CMSG_LOOT. + // Only the spell that originally started the cast bar (currentCastSpellId) + // should count as "gather cast completed". + const bool wasInTimedCast = casting && (data.spellId == currentCastSpellId); casting = false; castIsChannel = false; From 103bb5a513d2ac98deaddc509d7395918a2bbe47 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:23:31 -0700 Subject: [PATCH 09/90] fix: query item info in SMSG_LOOT_START_ROLL and use live name in roll popup SMSG_LOOT_START_ROLL was not calling queryItemInfo(), so the roll popup would display item IDs instead of names when the item had not been previously cached (e.g. first time seeing that item in the session). Also update renderLootRollPopup to prefer the live ItemQueryResponseData name/quality over the snapshot captured at parse time, so the popup shows the correct name once SMSG_ITEM_QUERY_SINGLE_RESPONSE arrives. --- src/game/game_handler.cpp | 4 ++++ src/ui/game_screen.cpp | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3d46fd31..ac884141 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2084,6 +2084,10 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + // Ensure item info is queried so the roll popup can show the name/icon. + // The popup re-reads getItemInfo() live, so the name will populate once + // SMSG_ITEM_QUERY_SINGLE_RESPONSE arrives (usually within ~100 ms). + queryItemInfo(itemId, 0); auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 92dccefe..dd81e8eb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10716,7 +10716,14 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); ImGui::SameLine(); } - ImGui::TextColored(col, "[%s]", roll.itemName.c_str()); + // Prefer live item info (arrives via SMSG_ITEM_QUERY_SINGLE_RESPONSE after the + // roll popup opens); fall back to the name cached at SMSG_LOOT_START_ROLL time. + const char* displayName = (rollInfo && rollInfo->valid && !rollInfo->name.empty()) + ? rollInfo->name.c_str() + : roll.itemName.c_str(); + if (rollInfo && rollInfo->valid) + col = (rollInfo->quality < 6) ? kQualityColors[rollInfo->quality] : kQualityColors[1]; + ImGui::TextColored(col, "[%s]", displayName); if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { inventoryScreen.renderItemTooltip(*rollInfo); } From 3c704088af624feaae940f45c64eb55a94831259 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:26:27 -0700 Subject: [PATCH 10/90] fix: clear lastInteractedGoGuid_ on world transfer in handleNewWorld Same-map teleports (dungeon teleporters, etc.) clear casting state but were not clearing lastInteractedGoGuid_. If a gather cast was in progress when the teleport happened, the stale GO guid could theoretically trigger a spurious CMSG_LOOT on the destination map. Also clears lastInteractedGoGuid_ in handleNewWorld alongside the rest of the casting-state teardown for consistency with other reset paths. --- src/game/game_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ac884141..f81405ca 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -19533,6 +19533,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; // Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready From 2d587d0d4b014f75e705e0774544a224c5bcc987 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:37:15 -0700 Subject: [PATCH 11/90] feat: upgrade action bar slots to new spell rank on supercede When a spell is superceded (e.g. Fireball Rank 1 -> Rank 2 after training), update any action bar slots referencing the old spell ID to point to the new rank. This matches WoW client behaviour where training a new rank automatically upgrades your action bars so you don't have to manually re-place the spell. --- src/game/game_handler.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f81405ca..61189a95 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16559,6 +16559,18 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId); + // Update all action bar slots that reference the old spell rank to the new rank. + // This matches the WoW client behaviour: the action bar automatically upgrades + // to the new rank when you train it. + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) { + slot.id = newSpellId; + slot.cooldownRemaining = 0.0f; + slot.cooldownTotal = 0.0f; + LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId); + } + } + const std::string& newName = getSpellName(newSpellId); if (!newName.empty()) { addSystemChatMessage("Upgraded to " + newName); From 7dc12bb35e759d9654fcc36475284fe5dd474747 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:42:24 -0700 Subject: [PATCH 12/90] fix: persist action bar config after superceded spell slot upgrade After automatically upgrading action bar slots to the new spell rank in handleSupercededSpell, save the character config so the upgraded slot IDs persist across sessions. --- src/game/game_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 61189a95..fcd899f4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16562,14 +16562,17 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { // Update all action bar slots that reference the old spell rank to the new rank. // This matches the WoW client behaviour: the action bar automatically upgrades // to the new rank when you train it. + bool barChanged = false; for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) { slot.id = newSpellId; slot.cooldownRemaining = 0.0f; slot.cooldownTotal = 0.0f; + barChanged = true; LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId); } } + if (barChanged) saveCharacterConfig(); const std::string& newName = getSpellName(newSpellId); if (!newName.empty()) { From b34df013313f8f2fa8d13d4c6e179c3ecd9fcfb4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:51:15 -0700 Subject: [PATCH 13/90] fix: suppress duplicate chat message when learning trainer spell SMSG_TRAINER_BUY_SUCCEEDED pre-inserts the spell into knownSpells and shows "You have learned X." The subsequent SMSG_LEARNED_SPELL packet would then show a second "You have learned a new spell: X." message. Fix: check if the spell was already in knownSpells before inserting in handleLearnedSpell. If it was pre-inserted by the trainer handler, skip the chat notification to avoid the duplicate. --- src/game/game_handler.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fcd899f4..5f611baf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16505,8 +16505,14 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { const size_t minSz = classicSpellId ? 2u : 4u; if (packet.getSize() - packet.getReadPos() < minSz) return; uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); + + // Track whether we already knew this spell before inserting. + // SMSG_TRAINER_BUY_SUCCEEDED pre-inserts the spell and shows its own "You have + // learned X" message, so when the accompanying SMSG_LEARNED_SPELL arrives we + // must not duplicate it. + const bool alreadyKnown = knownSpells.count(spellId) > 0; knownSpells.insert(spellId); - LOG_INFO("Learned spell: ", spellId); + LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : ""); // Check if this spell corresponds to a talent rank for (const auto& [talentId, talent] : talentCache_) { @@ -16522,12 +16528,15 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { } } - // Show chat message for non-talent spells - const std::string& name = getSpellName(spellId); - if (!name.empty()) { - addSystemChatMessage("You have learned a new spell: " + name + "."); - } else { - addSystemChatMessage("You have learned a new spell."); + // Show chat message for non-talent spells, but only if not already announced by + // SMSG_TRAINER_BUY_SUCCEEDED (which pre-inserts into knownSpells). + if (!alreadyKnown) { + const std::string& name = getSpellName(spellId); + if (!name.empty()) { + addSystemChatMessage("You have learned a new spell: " + name + "."); + } else { + addSystemChatMessage("You have learned a new spell."); + } } } From 2b131548aad6273e34efc5746d48edef3d60a485 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:54:01 -0700 Subject: [PATCH 14/90] fix: show descriptive party command error messages Replace the generic "Party command failed (error N)" message with WoW-standard error strings for each PartyResult code, matching what the original client displays (e.g. "Your party is full.", "%s is already in a group.", "%s is ignoring you.", etc.). --- src/game/game_handler.cpp | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5f611baf..a3f015cf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16835,11 +16835,36 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { if (!PartyCommandResultParser::parse(packet, data)) return; if (data.result != PartyResult::OK) { + const char* errText = nullptr; + switch (data.result) { + case PartyResult::BAD_PLAYER_NAME: errText = "No player named \"%s\" is currently online."; break; + case PartyResult::TARGET_NOT_IN_GROUP: errText = "%s is not in your group."; break; + case PartyResult::TARGET_NOT_IN_INSTANCE:errText = "%s is not in your instance."; break; + case PartyResult::GROUP_FULL: errText = "Your party is full."; break; + case PartyResult::ALREADY_IN_GROUP: errText = "%s is already in a group."; break; + case PartyResult::NOT_IN_GROUP: errText = "You are not in a group."; break; + case PartyResult::NOT_LEADER: errText = "You are not the group leader."; break; + case PartyResult::PLAYER_WRONG_FACTION: errText = "%s is the wrong faction for this group."; break; + case PartyResult::IGNORING_YOU: errText = "%s is ignoring you."; break; + case PartyResult::LFG_PENDING: errText = "You cannot do that while in a LFG queue."; break; + case PartyResult::INVITE_RESTRICTED: errText = "Target is not accepting group invites."; break; + default: errText = "Party command failed."; break; + } + + char buf[256]; + if (!data.name.empty() && errText && std::strstr(errText, "%s")) { + std::snprintf(buf, sizeof(buf), errText, data.name.c_str()); + } else if (errText) { + std::snprintf(buf, sizeof(buf), "%s", errText); + } else { + std::snprintf(buf, sizeof(buf), "Party command failed (error %u).", + static_cast(data.result)); + } + MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - msg.message = "Party command failed (error " + std::to_string(static_cast(data.result)) + ")"; - if (!data.name.empty()) msg.message += " for " + data.name; + msg.message = buf; addLocalChatMessage(msg); } } From dfe091473c54e30e0c34df24d4bc2dc4fcc9d33f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 05:58:57 -0700 Subject: [PATCH 15/90] fix: show action bar cooldown timers for spells on cooldown at login SMSG_INITIAL_SPELLS delivers active cooldowns which were stored in spellCooldowns but never propagated to the action bar slot cooldownRemaining/cooldownTotal fields. This meant that spells with remaining cooldowns at login time showed no countdown overlay on the action bar. Sync the action bar slots from spellCooldowns after loadCharacterConfig() to restore the correct timers. --- src/game/game_handler.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a3f015cf..0b8846e6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16174,6 +16174,19 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { actionBar[11].id = 8690; // Hearthstone loadCharacterConfig(); + // Sync login-time cooldowns into action bar slot overlays. Without this, spells + // that are still on cooldown when the player logs in show no cooldown timer on the + // action bar even though spellCooldowns has the right remaining time. + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { + auto it = spellCooldowns.find(slot.id); + if (it != spellCooldowns.end() && it->second > 0.0f) { + slot.cooldownTotal = it->second; + slot.cooldownRemaining = it->second; + } + } + } + LOG_INFO("Learned ", knownSpells.size(), " spells"); } From b9c16e9be5704f80d128a250ae92031f2ed2c144 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:00:39 -0700 Subject: [PATCH 16/90] fix: suppress duplicate "Upgraded to X" message on trainer rank-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When buying a higher spell rank from a trainer, SMSG_TRAINER_BUY_SUCCEEDED already announces "You have learned X", and SMSG_SUPERCEDED_SPELLS would then also print "Upgraded to X" — two messages for one action. Fix: check if the new spell ID was already in knownSpells before inserting it in handleSupercededSpell. If so, the trainer handler already announced it and we skip the redundant "Upgraded to" message. Non-trainer supersedes (quest rewards, etc.) where the spell wasn't pre-inserted still show it. --- src/game/game_handler.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0b8846e6..1fcedce0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16576,6 +16576,11 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { // Remove old spell knownSpells.erase(oldSpellId); + // Track whether the new spell was already announced via SMSG_TRAINER_BUY_SUCCEEDED. + // If it was pre-inserted there, that handler already showed "You have learned X" so + // we should skip the "Upgraded to X" message to avoid a duplicate. + const bool newSpellAlreadyAnnounced = knownSpells.count(newSpellId) > 0; + // Add new spell knownSpells.insert(newSpellId); @@ -16596,9 +16601,14 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { } if (barChanged) saveCharacterConfig(); - const std::string& newName = getSpellName(newSpellId); - if (!newName.empty()) { - addSystemChatMessage("Upgraded to " + newName); + // Show "Upgraded to X" only when the new spell wasn't already announced by the + // trainer-buy handler. For non-trainer supersedes (e.g. quest rewards), the new + // spell won't be pre-inserted so we still show the message. + if (!newSpellAlreadyAnnounced) { + const std::string& newName = getSpellName(newSpellId); + if (!newName.empty()) { + addSystemChatMessage("Upgraded to " + newName); + } } } From 4507a223cc0e9495ae5d8d186074a3cfb9000cc8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:08:21 -0700 Subject: [PATCH 17/90] feat: color-code ENERGIZE combat text by power type Mana (0)=blue, Rage (1)=red, Focus (2)=orange, Energy (3)=yellow, Runic Power (6)=teal. Previously all energize events showed as blue regardless of resource type, making it impossible to distinguish e.g. a Warrior's Rage generation from a Mage's Mana return. Power type is now captured from SMSG_SPELLENERGIZELOG (uint8) and SMSG_PERIODICAURALOG OBS_MOD_POWER/PERIODIC_ENERGIZE (uint32 cast to uint8) and stored in CombatTextEntry::powerType. --- include/game/game_handler.hpp | 2 +- include/game/spell_defines.hpp | 1 + src/game/game_handler.cpp | 15 ++++++++------- src/ui/game_screen.cpp | 8 +++++++- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7e35e203..044c5007 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2314,7 +2314,7 @@ private: void handleLogoutResponse(network::Packet& packet); void handleLogoutComplete(network::Packet& packet); - void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource); + void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0); void addSystemChatMessage(const std::string& message); /** diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index dc38f813..d8f8c1df 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -59,6 +59,7 @@ struct CombatTextEntry { uint32_t spellId = 0; float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this + uint8_t powerType = 0; // For ENERGIZE: 0=mana,1=rage,2=focus,3=energy,6=runicpower static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1fcedce0..06140aeb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3879,11 +3879,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. if (packet.getSize() - packet.getReadPos() < 8) break; - /*uint32_t powerType =*/ packet.readUInt32(); + uint8_t periodicPowerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); if ((isPlayerVictim || isPlayerCaster) && amount > 0) addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), - spellId, isPlayerCaster); + spellId, isPlayerCaster, periodicPowerType); } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; @@ -3916,13 +3916,13 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); rem = packet.getSize() - packet.getReadPos(); if (rem < 6) { packet.setReadPos(packet.getSize()); break; } - uint32_t spellId = packet.readUInt32(); - /*uint8_t powerType =*/ packet.readUInt8(); - int32_t amount = static_cast(packet.readUInt32()); + uint32_t spellId = packet.readUInt32(); + uint8_t energizePowerType = packet.readUInt8(); + int32_t amount = static_cast(packet.readUInt32()); bool isPlayerVictim = (victimGuid == playerGuid); bool isPlayerCaster = (casterGuid == playerGuid); if ((isPlayerVictim || isPlayerCaster) && amount > 0) - addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster); + addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType); packet.setReadPos(packet.getSize()); break; } @@ -13629,13 +13629,14 @@ void GameHandler::stopAutoAttack() { LOG_INFO("Stopping auto-attack"); } -void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) { +void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType) { CombatTextEntry entry; entry.type = type; entry.amount = amount; entry.spellId = spellId; entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; + entry.powerType = powerType; combatText.push_back(entry); // Persistent combat log diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dd81e8eb..a7897e8d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8287,7 +8287,13 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { break; case game::CombatTextEntry::ENERGIZE: snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 0.6f, 1.0f, alpha); // Blue for mana/energy + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; // Rage: red + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; // Focus: orange + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; // Energy: yellow + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; // Runic Power: teal + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue + } break; case game::CombatTextEntry::XP_GAIN: snprintf(text, sizeof(text), "+%d XP", entry.amount); From d9b9d1d2f210cb4aecc5dbe078d5153bba799c83 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:09:42 -0700 Subject: [PATCH 18/90] fix: show dodge/parry/block/immune combat text when enemy spell misses player SMSG_SPELLLOGMISS contains miss events for both directions: spells the player cast that missed, and enemy spells that missed the player. The victim side (dodge/parry/block/immune/absorb/resist) was silently discarded. Now both caster==player and victim==player generate the appropriate combat text floater. --- src/game/game_handler.cpp | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 06140aeb..157fc8eb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2634,7 +2634,7 @@ void GameHandler::handlePacket(network::Packet& packet) { count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; - /*uint64_t victimGuid =*/ readSpellMissGuid(); + uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult @@ -2647,21 +2647,24 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } } - // Show combat text only for local player's spell misses + static const CombatTextEntry::Type missTypes[] = { + CombatTextEntry::MISS, // 0=MISS + CombatTextEntry::DODGE, // 1=DODGE + CombatTextEntry::PARRY, // 2=PARRY + CombatTextEntry::BLOCK, // 3=BLOCK + CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::IMMUNE, // 5=IMMUNE + CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::ABSORB, // 7=ABSORB + CombatTextEntry::RESIST, // 8=RESIST + }; + CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; if (casterGuid == playerGuid) { - static const CombatTextEntry::Type missTypes[] = { - CombatTextEntry::MISS, // 0=MISS - CombatTextEntry::DODGE, // 1=DODGE - CombatTextEntry::PARRY, // 2=PARRY - CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE - CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::ABSORB, // 7=ABSORB - CombatTextEntry::RESIST, // 8=RESIST - }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; + // We cast a spell and it missed the target addCombatText(ct, 0, 0, true); + } else if (victimGuid == playerGuid) { + // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) + addCombatText(ct, 0, 0, false); } } break; From c58fc3073f4dbcd39384c78a17453bfe12ec7786 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:11:10 -0700 Subject: [PATCH 19/90] fix: clear action bar slots when spells are removed or unlearned SMSG_REMOVED_SPELL and SMSG_SEND_UNLEARN_SPELLS both erased spells from knownSpells but left stale references on the action bar. After a respec or forced spell removal, action bar buttons would show removed talents and spells as still present. Now both handlers clear matching slots and persist the updated bar layout. --- src/game/game_handler.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 157fc8eb..778c8980 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16565,6 +16565,16 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO("Removed spell: ", spellId); + + // Clear any action bar slots referencing this spell + bool barChanged = false; + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot = ActionBarSlot{}; + barChanged = true; + } + } + if (barChanged) saveCharacterConfig(); } void GameHandler::handleSupercededSpell(network::Packet& packet) { @@ -16622,11 +16632,19 @@ void GameHandler::handleUnlearnSpells(network::Packet& packet) { uint32_t spellCount = packet.readUInt32(); LOG_INFO("Unlearning ", spellCount, " spells"); + bool barChanged = false; for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) { uint32_t spellId = packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO(" Unlearned spell: ", spellId); + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot = ActionBarSlot{}; + barChanged = true; + } + } } + if (barChanged) saveCharacterConfig(); if (spellCount > 0) { addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells"); From 3b499d6871ed4df93d5ee6f600f2a18ad572cec7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:14:28 -0700 Subject: [PATCH 20/90] fix: prefix SMSG_SPELL_FAILURE error message with spell name Previously a spell failure like "Not in range" gave no context about which spell failed. Now the message reads e.g. "Fireball: Not in range" using the spell name from the DBC cache. Falls back to the bare reason string if the spell name is not yet cached. --- src/game/game_handler.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 778c8980..8ce64482 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2998,7 +2998,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const size_t remainingFields = isClassic ? 5u : 6u; // spellId(4)+reason(1) [+castCount(1)] if (packet.getSize() - packet.getReadPos() >= remainingFields) { if (!isClassic) /*uint8_t castCount =*/ packet.readUInt8(); - /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t failSpellId = packet.readUInt32(); uint8_t rawFailReason = packet.readUInt8(); // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; @@ -3010,11 +3010,15 @@ void GameHandler::handlePacket(network::Packet& packet) { pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); if (reason) { - addUIError(reason); + // Prefix with spell name for context, e.g. "Fireball: Not in range" + const std::string& sName = getSpellName(failSpellId); + std::string fullMsg = sName.empty() ? reason + : sName + ": " + reason; + addUIError(fullMsg); MessageChatData emsg; emsg.type = ChatType::SYSTEM; emsg.language = ChatLanguage::UNIVERSAL; - emsg.message = reason; + emsg.message = std::move(fullMsg); addLocalChatMessage(emsg); } } From 38ab1e0aea22d1f95bdb66b77f3d27676cff3bf8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:16:19 -0700 Subject: [PATCH 21/90] fix: show correct duel fled message when loser left the duel area SMSG_DUEL_WINNER type=1 means the loser fled the duel zone rather than being defeated; was previously treated the same as a normal win. Now shows "X has fled from the duel. Y wins!" for the flee case vs the standard "X has defeated Y in a duel!" for a normal outcome. --- src/game/game_handler.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8ce64482..45e6f107 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11996,13 +11996,18 @@ void GameHandler::handleDuelComplete(network::Packet& packet) { void GameHandler::handleDuelWinner(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 3) return; - /*uint8_t type =*/ packet.readUInt8(); // 0=normal, 1=flee + uint8_t duelType = packet.readUInt8(); // 0=normal win, 1=opponent fled duel area std::string winner = packet.readString(); std::string loser = packet.readString(); - std::string msg = winner + " has defeated " + loser + " in a duel!"; + std::string msg; + if (duelType == 1) { + msg = loser + " has fled from the duel. " + winner + " wins!"; + } else { + msg = winner + " has defeated " + loser + " in a duel!"; + } addSystemChatMessage(msg); - LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser); + LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser, " type=", static_cast(duelType)); } void GameHandler::toggleAfk(const std::string& message) { From d79c79e1bc67a8af50dcf1966844deba703b8dee Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:18:23 -0700 Subject: [PATCH 22/90] feat: show chat messages for channel notification events SMSG_CHANNEL_NOTIFY carries many event types that were silently dropped in the default case: wrong password, muted, banned, throttled, kicked, not owner, not moderator, password changed, owner changed, invalid name, not in area, not in LFG. These are now surfaced as system chat messages matching WoW-standard phrasing. --- src/game/game_handler.cpp | 40 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 45e6f107..08623dc3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11228,10 +11228,46 @@ void GameHandler::handleChannelNotify(network::Packet& packet) { } break; } - case ChannelNotifyType::NOT_IN_AREA: { + case ChannelNotifyType::NOT_IN_AREA: + addSystemChatMessage("You must be in the area to join '" + data.channelName + "'."); LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)"); break; - } + case ChannelNotifyType::WRONG_PASSWORD: + addSystemChatMessage("Wrong password for channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MEMBER: + addSystemChatMessage("You are not in channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MODERATOR: + addSystemChatMessage("You are not a moderator of '" + data.channelName + "'."); + break; + case ChannelNotifyType::MUTED: + addSystemChatMessage("You are muted in channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::BANNED: + addSystemChatMessage("You are banned from channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::THROTTLED: + addSystemChatMessage("Channel '" + data.channelName + "' is throttled. Please wait."); + break; + case ChannelNotifyType::NOT_IN_LFG: + addSystemChatMessage("You must be in a LFG queue to join '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_KICKED: + addSystemChatMessage("A player was kicked from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PASSWORD_CHANGED: + addSystemChatMessage("Password for '" + data.channelName + "' changed."); + break; + case ChannelNotifyType::OWNER_CHANGED: + addSystemChatMessage("Owner of '" + data.channelName + "' changed."); + break; + case ChannelNotifyType::NOT_OWNER: + addSystemChatMessage("You are not the owner of '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVALID_NAME: + addSystemChatMessage("Invalid channel name '" + data.channelName + "'."); + break; default: LOG_DEBUG("Channel notify type ", static_cast(data.notifyType), " for channel ", data.channelName); From 43b007cdcdb95d9205a554fc9e776df747604c70 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:21:33 -0700 Subject: [PATCH 23/90] fix: only show SMSG_DISPEL_FAILED message when player is the caster The dispel-failed handler was showing the failure notification for every dispel attempt in the party/raid, regardless of who cast it. Now checks casterGuid == playerGuid before showing "X failed to dispel." so only the player's own failed dispels surface in chat. --- src/game/game_handler.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 08623dc3..d514606e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3172,20 +3172,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // [+ count × uint32 failedSpellId] const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint32_t dispelSpellId = 0; + uint64_t dispelCasterGuid = 0; if (dispelTbcLike) { if (packet.getSize() - packet.getReadPos() < 20) break; - /*uint64_t caster =*/ packet.readUInt64(); + dispelCasterGuid = packet.readUInt64(); /*uint64_t victim =*/ packet.readUInt64(); dispelSpellId = packet.readUInt32(); } else { if (packet.getSize() - packet.getReadPos() < 4) break; dispelSpellId = packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < 1) break; - /*uint64_t caster =*/ UpdateObjectParser::readPackedGuid(packet); + dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); } - { + // Only show failure to the player who attempted the dispel + if (dispelCasterGuid == playerGuid) { loadSpellNameCache(); auto it = spellNameCache_.find(dispelSpellId); char buf[128]; From 156ddfad9a7d6677e03672b35006e5828a29dfa9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:22:51 -0700 Subject: [PATCH 24/90] fix: pass power type from POWER_DRAIN energize to color-code combat text SMSG_SPELLLOGEXECUTE POWER_DRAIN reads drainPower but was not passing it to addCombatText, so drained-resource returns showed as blue (mana) even for rage or energy. Now correctly colored following the energize palette added in the earlier commit. --- src/game/game_handler.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d514606e..10f9f144 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6203,7 +6203,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (drainTarget == playerGuid) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false); else if (isPlayerCaster) - addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true); + addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, + static_cast(drainPower)); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, " power=", drainPower, " amount=", drainAmount); From 2a52aedbf77afb01ac3efe7779031c07077c6352 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:24:16 -0700 Subject: [PATCH 25/90] fix: show quest name instead of ID in failed/timed-out quest messages SMSG_QUESTUPDATE_FAILED and SMSG_QUESTUPDATE_FAILEDTIMER were emitting generic "Quest 12345 failed!" messages. Now looks up the title from questLog_ and shows e.g. "\"Report to Gryan Stoutmantle\" failed!" for a much more readable notification. Falls back to the generic form if the title is not cached. --- src/game/game_handler.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 10f9f144..18713eb0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1845,9 +1845,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), "Quest %u failed!", questId); - addSystemChatMessage(buf); + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + addSystemChatMessage(questTitle.empty() + ? std::string("Quest failed!") + : ('"' + questTitle + "\" failed!")); } break; } @@ -1855,9 +1858,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), "Quest %u timed out!", questId); - addSystemChatMessage(buf); + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + addSystemChatMessage(questTitle.empty() + ? std::string("Quest timed out!") + : ('"' + questTitle + "\" has timed out.")); } break; } From b1015abffee65653010339149ea042ce871ab164 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:30:30 -0700 Subject: [PATCH 26/90] fix: use actual pct in durability death message, fix pet cast failure feedback - SMSG_DURABILITY_DAMAGE_DEATH: use the actual pct field from the packet instead of hardcoding "10%" - SMSG_PET_CAST_FAILED: read reason as uint8 (not uint32), look up spell name and show human-readable failure reason to player - Trade status 9 (REJECTED): show "Trade declined." instead of "Trade cancelled." to distinguish explicit decline from cancellation --- src/game/game_handler.cpp | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 18713eb0..c028f202 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3243,8 +3243,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DURABILITY_DAMAGE_DEATH: { // uint32 percent (how much durability was lost due to death) if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t pct =*/ packet.readUInt32(); - addSystemChatMessage("You have lost 10% of your gear's durability due to death."); + uint32_t pct = packet.readUInt32(); + char buf[80]; + std::snprintf(buf, sizeof(buf), + "You have lost %u%% of your gear's durability due to death.", pct); + addSystemChatMessage(buf); } break; } @@ -6869,10 +6872,20 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 5) { uint8_t castCount = packet.readUInt8(); uint32_t spellId = packet.readUInt32(); - uint32_t reason = (packet.getSize() - packet.getReadPos() >= 4) - ? packet.readUInt32() : 0; + uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1) + ? packet.readUInt8() : 0; LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, - " reason=", reason, " castCount=", (int)castCount); + " reason=", (int)reason, " castCount=", (int)castCount); + if (reason != 0) { + const char* reasonStr = getSpellCastResultString(reason); + const std::string& sName = getSpellName(spellId); + std::string errMsg; + if (reasonStr && *reasonStr) + errMsg = sName.empty() ? reasonStr : (sName + ": " + reasonStr); + else + errMsg = sName.empty() ? "Pet spell failed." : (sName + ": Pet spell failed."); + addSystemChatMessage(errMsg); + } } packet.setReadPos(packet.getSize()); break; @@ -21999,12 +22012,15 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); break; - case 3: // CANCELLED - case 9: // REJECTED + case 3: // CANCELLED case 12: // CLOSE_WINDOW resetTradeState(); addSystemChatMessage("Trade cancelled."); break; + case 9: // REJECTED — other player clicked Decline + resetTradeState(); + addSystemChatMessage("Trade declined."); + break; case 4: // ACCEPTED (partner accepted) tradeStatus_ = TradeStatus::Accepted; addSystemChatMessage("Trade accepted. Awaiting other player..."); From 8a81ffa29c25275e9011e58a586371cd851ac532 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:36:04 -0700 Subject: [PATCH 27/90] fix: show quest name in QUESTGIVER_QUEST_FAILED, use playerNameCache for achievements - SMSG_QUESTGIVER_QUEST_FAILED: look up quest title from questLog_ and include it in the failure message (same pattern as QUESTUPDATE_FAILED fix from previous session) - SMSG_ACHIEVEMENT_EARNED: fall back to playerNameCache for non-visible players before showing a raw hex GUID in the achievement message --- src/game/game_handler.cpp | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c028f202..f41a978b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6579,19 +6579,26 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { // uint32 questId + uint32 reason if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t questId =*/ packet.readUInt32(); + uint32_t questId = packet.readUInt32(); uint32_t reason = packet.readUInt32(); - const char* reasonStr = "Unknown reason"; + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + const char* reasonStr = nullptr; switch (reason) { - case 1: reasonStr = "Quest failed: failed conditions"; break; - case 2: reasonStr = "Quest failed: inventory full"; break; - case 3: reasonStr = "Quest failed: too far away"; break; - case 4: reasonStr = "Quest failed: another quest is blocking"; break; - case 5: reasonStr = "Quest failed: wrong time of day"; break; - case 6: reasonStr = "Quest failed: wrong race"; break; - case 7: reasonStr = "Quest failed: wrong class"; break; + case 1: reasonStr = "failed conditions"; break; + case 2: reasonStr = "inventory full"; break; + case 3: reasonStr = "too far away"; break; + case 4: reasonStr = "another quest is blocking"; break; + case 5: reasonStr = "wrong time of day"; break; + case 6: reasonStr = "wrong race"; break; + case 7: reasonStr = "wrong class"; break; } - addSystemChatMessage(reasonStr); + std::string msg = questTitle.empty() ? "Quest" : ('"' + questTitle + '"'); + msg += " failed"; + if (reasonStr) msg += std::string(": ") + reasonStr; + msg += '.'; + addSystemChatMessage(msg); } break; } @@ -22453,6 +22460,11 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { senderName = unit->getName(); } + if (senderName.empty()) { + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) + senderName = nit->second; + } if (senderName.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", From 3a143b9b5b01341b8d4dffe64f9a93872ba7dee2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:37:26 -0700 Subject: [PATCH 28/90] fix: use playerNameCache fallback and show zone name in summon/trade requests - SMSG_SUMMON_REQUEST: fall back to playerNameCache when entity not in range; include zone name from getAreaName() in the summon message (e.g. "Bob is summoning you to Stormwind.") - SMSG_TRADE_STATUS BEGIN_TRADE: fall back to playerNameCache when the trade initiator's entity is not visible --- src/game/game_handler.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f41a978b..7829ca67 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21936,7 +21936,7 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 16) return; summonerGuid_ = packet.readUInt64(); - /*uint32_t zoneId =*/ packet.readUInt32(); + uint32_t zoneId = packet.readUInt32(); uint32_t timeoutMs = packet.readUInt32(); summonTimeoutSec_ = timeoutMs / 1000.0f; pendingSummonRequest_= true; @@ -21946,6 +21946,11 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { summonerName_ = unit->getName(); } + if (summonerName_.empty()) { + auto nit = playerNameCache.find(summonerGuid_); + if (nit != playerNameCache.end()) + summonerName_ = nit->second; + } if (summonerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -21953,9 +21958,14 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { summonerName_ = tmp; } - addSystemChatMessage(summonerName_ + " is summoning you."); + std::string msg = summonerName_ + " is summoning you"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + msg += " to " + zoneName; + msg += '.'; + addSystemChatMessage(msg); LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_, - " timeout=", summonTimeoutSec_, "s"); + " zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s"); } void GameHandler::acceptSummon() { @@ -22001,6 +22011,11 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { tradePeerName_ = unit->getName(); } + if (tradePeerName_.empty()) { + auto nit = playerNameCache.find(tradePeerGuid_); + if (nit != playerNameCache.end()) + tradePeerName_ = nit->second; + } if (tradePeerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", From e882110e7f5f8d38f79f10cab4aba337c793710d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:38:50 -0700 Subject: [PATCH 29/90] fix: suppress repeated group-join chat spam on every SMSG_GROUP_LIST update Previously "You are now in a group with N members." was shown on every GROUP_LIST packet, which fires for each party stat update. Now only show a message on actual state transitions: joining, leaving the group. --- src/game/game_handler.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7829ca67..01fc8f05 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16923,17 +16923,23 @@ void GameHandler::handleGroupList(network::Packet& packet) { // WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder. // Classic 1.12 and TBC 2.4.3 do not send the roles byte. const bool hasRoles = isActiveExpansion("wotlk"); + // Snapshot state before reset so we can detect transitions. + const uint32_t prevCount = partyData.memberCount; + const bool wasInGroup = !partyData.isEmpty(); // Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta. // Without this, repeated GROUP_LIST packets push duplicate members. partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles)) return; - if (partyData.isEmpty()) { + const bool nowInGroup = !partyData.isEmpty(); + if (!nowInGroup && wasInGroup) { LOG_INFO("No longer in a group"); addSystemChatMessage("You are no longer in a group."); - } else { - LOG_INFO("In group with ", partyData.memberCount, " members"); - addSystemChatMessage("You are now in a group with " + std::to_string(partyData.memberCount) + " members."); + } else if (nowInGroup && !wasInGroup) { + LOG_INFO("Joined group with ", partyData.memberCount, " members"); + addSystemChatMessage("You are now in a group."); + } else if (nowInGroup && partyData.memberCount != prevCount) { + LOG_INFO("Group updated: ", partyData.memberCount, " members"); } } From ecc02595de650b3db2b6d7c3ca1aa20be62e79c6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:43:11 -0700 Subject: [PATCH 30/90] fix: improve guild command result messages and suppress repeated guild name announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map GuildCommandError codes to human-readable strings instead of showing raw error numbers (e.g. \"error 4\" → \"No player named X is online.\") - Handle errorCode==0 for QUIT command: show \"You have left the guild.\" and clear guild state (name, ranks, roster) — previously silent - Handle errorCode==0 for CREATE and INVITE commands with appropriate messages - Substitute %s-style error messages with the player name from data.name - Suppress repeated \"Guild: \" chat message on every SMSG_GUILD_QUERY_RESPONSE; only announce once when the guild name is first learned at login --- src/game/game_handler.cpp | 73 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 01fc8f05..7cac6d14 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17365,6 +17365,7 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; + const bool wasUnknown = guildName_.empty(); guildName_ = data.guildName; guildQueryData_ = data; guildRankNames_.clear(); @@ -17372,7 +17373,10 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { guildRankNames_.push_back(data.rankNames[i]); } LOG_INFO("Guild name set to: ", guildName_); - addSystemChatMessage("Guild: <" + guildName_ + ">"); + // Only announce once — when we first learn our own guild name at login. + // Subsequent queries (e.g. querying other players' guilds) are silent. + if (wasUnknown && !guildName_.empty()) + addSystemChatMessage("Guild: <" + guildName_ + ">"); } void GameHandler::handleGuildEvent(network::Packet& packet) { @@ -17471,12 +17475,73 @@ void GameHandler::handleGuildCommandResult(network::Packet& packet) { GuildCommandResultData data; if (!GuildCommandResultParser::parse(packet, data)) return; - if (data.errorCode != 0) { - std::string msg = "Guild command failed"; + // command: 0=CREATE, 1=INVITE, 2=QUIT, 3=FOUNDER + if (data.errorCode == 0) { + switch (data.command) { + case 0: // CREATE + addSystemChatMessage("Guild created."); + break; + case 1: // INVITE — invited another player + if (!data.name.empty()) + addSystemChatMessage("You have invited " + data.name + " to the guild."); + break; + case 2: // QUIT — player successfully left + addSystemChatMessage("You have left the guild."); + guildName_.clear(); + guildRankNames_.clear(); + guildRoster_ = GuildRosterData{}; + hasGuildRoster_ = false; + break; + default: + break; + } + return; + } + + // Error codes from AzerothCore SharedDefines.h GuildCommandError + const char* errStr = nullptr; + switch (data.errorCode) { + case 2: errStr = "You are not in a guild."; break; + case 3: errStr = "That player is not in a guild."; break; + case 4: errStr = "No player named \"%s\" is online."; break; + case 7: errStr = "You are the guild leader."; break; + case 8: errStr = "You must transfer leadership before leaving."; break; + case 11: errStr = "\"%s\" is already in a guild."; break; + case 13: errStr = "You are already in a guild."; break; + case 14: errStr = "\"%s\" has already been invited to a guild."; break; + case 15: errStr = "You cannot invite yourself."; break; + case 16: + case 17: errStr = "You are not the guild leader."; break; + case 18: errStr = "That player's rank is too high to remove."; break; + case 19: errStr = "You cannot remove someone with a higher rank."; break; + case 20: errStr = "Guild ranks are locked."; break; + case 21: errStr = "That rank is in use."; break; + case 22: errStr = "That player is ignoring you."; break; + case 25: errStr = "Insufficient guild bank withdrawal quota."; break; + case 26: errStr = "Guild doesn't have enough money."; break; + case 28: errStr = "Guild bank is full."; break; + case 31: errStr = "Too many guild ranks."; break; + case 37: errStr = "That player is the guild leader."; break; + case 49: errStr = "Guild reputation is too low."; break; + default: break; + } + + std::string msg; + if (errStr) { + // Substitute %s with player name where applicable + std::string fmt = errStr; + auto pos = fmt.find("%s"); + if (pos != std::string::npos && !data.name.empty()) + fmt.replace(pos, 2, data.name); + else if (pos != std::string::npos) + fmt.replace(pos, 2, "that player"); + msg = fmt; + } else { + msg = "Guild command failed"; if (!data.name.empty()) msg += " for " + data.name; msg += " (error " + std::to_string(data.errorCode) + ")"; - addSystemChatMessage(msg); } + addSystemChatMessage(msg); } // ============================================================ From 9216a6da28788da1cd8c0d2151f1ba8dbee2d3b8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:46:56 -0700 Subject: [PATCH 31/90] fix: show zone names in hearthstone bind messages, add playerNameCache to duel challenger - SMSG_BINDPOINTUPDATE: show zone name in \"Your home has been set to X.\" (was just \"Your home has been set.\") - SMSG_PLAYERBOUND: replace \"map N, zone N\" raw IDs with zone name lookup - SMSG_BINDER_CONFIRM: suppress redundant \"This innkeeper is now your home location.\" since SMSG_PLAYERBOUND fires immediately after with zone context - SMSG_DUEL_REQUESTED: add playerNameCache fallback before hex GUID for challenger name --- src/game/game_handler.cpp | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7cac6d14..48527d8e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2196,17 +2196,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 binderGuid + uint32 mapId + uint32 zoneId if (packet.getSize() - packet.getReadPos() < 16) break; /*uint64_t binderGuid =*/ packet.readUInt64(); - uint32_t mapId = packet.readUInt32(); + /*uint32_t mapId =*/ packet.readUInt32(); uint32_t zoneId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Your home location has been set (map %u, zone %u).", mapId, zoneId); - addSystemChatMessage(buf); + std::string pbMsg = "Your home location has been set"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + pbMsg += " to " + zoneName; + pbMsg += '.'; + addSystemChatMessage(pbMsg); break; } case Opcode::SMSG_BINDER_CONFIRM: { - // uint64 npcGuid — server confirming bind point has been set - addSystemChatMessage("This innkeeper is now your home location."); + // uint64 npcGuid — fires just before SMSG_PLAYERBOUND; PLAYERBOUND shows + // the zone name so this confirm is redundant. Consume silently. packet.setReadPos(packet.getSize()); break; } @@ -3529,7 +3531,12 @@ void GameHandler::handlePacket(network::Packet& packet) { bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); } if (wasSet) { - addSystemChatMessage("Your home has been set."); + std::string bindMsg = "Your home has been set"; + std::string zoneName = getAreaName(data.zoneId); + if (!zoneName.empty()) + bindMsg += " to " + zoneName; + bindMsg += '.'; + addSystemChatMessage(bindMsg); } } else { LOG_WARNING("Failed to parse SMSG_BINDPOINTUPDATE"); @@ -12034,6 +12041,11 @@ void GameHandler::handleDuelRequested(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { duelChallengerName_ = unit->getName(); } + if (duelChallengerName_.empty()) { + auto nit = playerNameCache.find(duelChallengerGuid_); + if (nit != playerNameCache.end()) + duelChallengerName_ = nit->second; + } if (duelChallengerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", From cf88a960f48f0081683b8bf497fae00eb2b43650 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:48:12 -0700 Subject: [PATCH 32/90] feat: add missing SMSG_CHANNEL_NOTIFY feedback for 12 unhandled notification types Previously, PLAYER_NOT_FOUND, ANNOUNCEMENTS_ON/OFF, MODERATION_ON/OFF, PLAYER_BANNED, PLAYER_UNBANNED, PLAYER_NOT_BANNED, INVITE, WRONG_FACTION, INVITE_WRONG_FACTION, NOT_MODERATED, PLAYER_INVITED, and PLAYER_INVITE_BANNED all fell silently to the default log-only path. Now each shows an appropriate system message in chat. --- src/game/game_handler.cpp | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 48527d8e..36fe34c9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11304,6 +11304,46 @@ void GameHandler::handleChannelNotify(network::Packet& packet) { case ChannelNotifyType::INVALID_NAME: addSystemChatMessage("Invalid channel name '" + data.channelName + "'."); break; + case ChannelNotifyType::PLAYER_NOT_FOUND: + addSystemChatMessage("Player not found."); + break; + case ChannelNotifyType::ANNOUNCEMENTS_ON: + addSystemChatMessage("Channel '" + data.channelName + "': announcements enabled."); + break; + case ChannelNotifyType::ANNOUNCEMENTS_OFF: + addSystemChatMessage("Channel '" + data.channelName + "': announcements disabled."); + break; + case ChannelNotifyType::MODERATION_ON: + addSystemChatMessage("Channel '" + data.channelName + "' is now moderated."); + break; + case ChannelNotifyType::MODERATION_OFF: + addSystemChatMessage("Channel '" + data.channelName + "' is no longer moderated."); + break; + case ChannelNotifyType::PLAYER_BANNED: + addSystemChatMessage("A player was banned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_UNBANNED: + addSystemChatMessage("A player was unbanned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_NOT_BANNED: + addSystemChatMessage("That player is not banned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVITE: + addSystemChatMessage("You have been invited to join channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVITE_WRONG_FACTION: + case ChannelNotifyType::WRONG_FACTION: + addSystemChatMessage("Wrong faction for channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MODERATED: + addSystemChatMessage("Channel '" + data.channelName + "' is not moderated."); + break; + case ChannelNotifyType::PLAYER_INVITED: + addSystemChatMessage("Player invited to channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_INVITE_BANNED: + addSystemChatMessage("That player is banned from '" + data.channelName + "'."); + break; default: LOG_DEBUG("Channel notify type ", static_cast(data.notifyType), " for channel ", data.channelName); From 20b59c9d63a6863a18a63242e137463c249c456d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:56:37 -0700 Subject: [PATCH 33/90] fix: correct LFG vote kick result logic and show item names in dungeon rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleLfgBootProposalUpdate: was using myAnswer (player's own vote) to determine if the boot passed — should use bootVotes >= votesNeeded instead. Player who voted yes would see "passed" even if the vote failed, and vice versa. - handleLfgPlayerReward: look up item name from item cache instead of showing raw "item #12345" for dungeon reward items --- src/game/game_handler.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 36fe34c9..b996814a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14786,7 +14786,10 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { uint32_t itemCount = packet.readUInt32(); packet.readUInt8(); // unk if (i == 0) { - rewardMsg += ", item #" + std::to_string(itemId); + std::string itemLabel = "item #" + std::to_string(itemId); + if (const ItemQueryResponseData* info = getItemInfo(itemId)) + if (!info->name.empty()) itemLabel = info->name; + rewardMsg += ", " + itemLabel; if (itemCount > 1) rewardMsg += " x" + std::to_string(itemCount); } } @@ -14802,15 +14805,13 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { if (remaining < 7 + 4 + 4 + 4 + 4) return; bool inProgress = packet.readUInt8() != 0; - bool myVote = packet.readUInt8() != 0; - bool myAnswer = packet.readUInt8() != 0; + /*bool myVote =*/ packet.readUInt8(); // whether local player has voted + /*bool myAnswer =*/ packet.readUInt8(); // local player's vote (yes/no) — unused; result derived from counts uint32_t totalVotes = packet.readUInt32(); uint32_t bootVotes = packet.readUInt32(); uint32_t timeLeft = packet.readUInt32(); uint32_t votesNeeded = packet.readUInt32(); - (void)myVote; - lfgBootVotes_ = bootVotes; lfgBootTotal_ = totalVotes; lfgBootTimeLeft_ = timeLeft; @@ -14825,12 +14826,14 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { if (inProgress) { lfgState_ = LfgState::Boot; } else { - // Boot vote ended — return to InDungeon state regardless of outcome + // Boot vote ended — pass/fail determined by whether enough yes votes were cast, + // not by the local player's own vote (myAnswer = what *I* voted, not the result). + const bool bootPassed = (bootVotes >= votesNeeded); lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; lfgBootTargetName_.clear(); lfgBootReason_.clear(); lfgState_ = LfgState::InDungeon; - if (myAnswer) { + if (bootPassed) { addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); } else { addSystemChatMessage("Dungeon Finder: Vote kick failed."); From 8e67a419837f5d5d48fd8ae8139c5757762209db Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 06:59:02 -0700 Subject: [PATCH 34/90] fix: show battleground names instead of IDs in SMSG_BATTLEFIELD_STATUS messages Replace raw \"Battleground #2\" with proper names (Warsong Gulch, Arathi Basin, Eye of the Storm, Strand of the Ancients, Isle of Conquest, arena names, etc.) for all three expansions' BG type ID space. --- src/game/game_handler.cpp | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b996814a..e82c4681 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14269,9 +14269,35 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t statusId = packet.readUInt32(); - std::string bgName = "Battleground #" + std::to_string(bgTypeId); + // Map BG type IDs to their names (stable across all three expansions) + static const std::pair kBgNames[] = { + {1, "Alterac Valley"}, + {2, "Warsong Gulch"}, + {3, "Arathi Basin"}, + {6, "Eye of the Storm"}, + {9, "Strand of the Ancients"}, + {11, "Isle of Conquest"}, + {30, "Nagrand Arena"}, + {31, "Blade's Edge Arena"}, + {32, "Dalaran Sewers"}, + {33, "Ring of Valor"}, + {34, "Ruins of Lordaeron"}, + }; + std::string bgName = "Battleground"; + for (const auto& kv : kBgNames) { + if (kv.first == bgTypeId) { bgName = kv.second; break; } + } + if (bgName == "Battleground") + bgName = "Battleground #" + std::to_string(bgTypeId); if (arenaType > 0) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; + // If bgTypeId matches a named arena, prefer that name + for (const auto& kv : kBgNames) { + if (kv.first == bgTypeId) { + bgName += " (" + std::string(kv.second) + ")"; + break; + } + } } // Parse status-specific fields From 28ce441214f759fb57f0f6bf4b396fb660512913 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:02:20 -0700 Subject: [PATCH 35/90] feat: add missing player feedback for level-up, pet spells, and pet name errors - SMSG_LEVELUP_INFO: show \"You have reached level N!\" chat message on level-up (was only calling the UI ding callback without any chat notification) - SMSG_PET_LEARNED_SPELL: show \"Your pet has learned X.\" with spell name lookup (was LOG_DEBUG only) - SMSG_PET_NAME_INVALID: show \"That pet name is invalid.\" (was silently consumed) --- src/game/game_handler.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e82c4681..31544e5d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4060,8 +4060,9 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } } - if (newLevel > oldLevel && levelUpCallback_) { - levelUpCallback_(newLevel); + if (newLevel > oldLevel) { + addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); + if (levelUpCallback_) levelUpCallback_(newLevel); } } } @@ -6865,6 +6866,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t spellId = packet.readUInt32(); petSpellList_.push_back(spellId); + const std::string& sname = getSpellName(spellId); + addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + ".")); LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); } packet.setReadPos(packet.getSize()); @@ -6908,11 +6911,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: case Opcode::SMSG_PET_UNLEARN_CONFIRM: - case Opcode::SMSG_PET_NAME_INVALID: case Opcode::SMSG_PET_RENAMEABLE: case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PET_NAME_INVALID: + addSystemChatMessage("That pet name is invalid. Please choose a different name."); + packet.setReadPos(packet.getSize()); + break; // ---- Inspect (Classic 1.12 gear inspection) ---- case Opcode::SMSG_INSPECT: { From 2c72d8462ddfcdf9b157120e43d3dc71cada5698 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:10:10 -0700 Subject: [PATCH 36/90] feat: add map name lookups and improve instance/RAF/misc feedback - Add getMapName() helper backed by Map.dbc (parallel to getAreaName) - SMSG_RAID_INSTANCE_MESSAGE: show dungeon name instead of raw map ID - SMSG_INSTANCE_RESET: show dungeon name instead of raw map ID - SMSG_INSTANCE_RESET_FAILED: show dungeon name instead of raw map ID - SMSG_EQUIPMENT_SET_SAVED: show "Equipment set saved." confirmation - SMSG_PROPOSE_LEVEL_GRANT: show mentor name offering a level grant (RAF) - SMSG_REFER_A_FRIEND_EXPIRED: show link-expired message - SMSG_REFER_A_FRIEND_FAILURE: show reason-mapped error message - SMSG_REPORT_PVP_AFK_RESULT: show success/failure feedback - SMSG_QUEST_CONFIRM_ACCEPT: add playerNameCache fallback for sharer name --- include/game/game_handler.hpp | 6 ++ src/game/game_handler.cpp | 113 ++++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 044c5007..10ca1c43 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2971,6 +2971,12 @@ private: bool areaNameCacheLoaded_ = false; void loadAreaNameCache(); std::string getAreaName(uint32_t areaId) const; + + // Map name cache (lazy-loaded from Map.dbc; maps mapId → localized display name) + std::unordered_map mapNameCache_; + bool mapNameCacheLoaded_ = false; + void loadMapNameCache(); + std::string getMapName(uint32_t mapId) const; std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 31544e5d..b132aae3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3836,6 +3836,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_EQUIPMENT_SET_SAVED: // uint32 setIndex + uint64 guid — equipment set was successfully saved + addSystemChatMessage("Equipment set saved."); LOG_DEBUG("Equipment set saved"); break; case Opcode::SMSG_PERIODICAURALOG: { @@ -5178,17 +5179,18 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t msgType = packet.readUInt32(); uint32_t mapId = packet.readUInt32(); /*uint32_t diff =*/ packet.readUInt32(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); // type: 1=warning(time left), 2=saved, 3=welcome if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) { uint32_t timeLeft = packet.readUInt32(); uint32_t minutes = timeLeft / 60; - std::string msg = "Instance " + std::to_string(mapId) + - " will reset in " + std::to_string(minutes) + " minute(s)."; - addSystemChatMessage(msg); + addSystemChatMessage(mapLabel + " will reset in " + + std::to_string(minutes) + " minute(s)."); } else if (msgType == 2) { - addSystemChatMessage("You have been saved to instance " + std::to_string(mapId) + "."); + addSystemChatMessage("You have been saved to " + mapLabel + "."); } else if (msgType == 3) { - addSystemChatMessage("Welcome to instance " + std::to_string(mapId) + "."); + addSystemChatMessage("Welcome to " + mapLabel + "."); } LOG_INFO("SMSG_RAID_INSTANCE_MESSAGE: type=", msgType, " map=", mapId); } @@ -5201,7 +5203,9 @@ void GameHandler::handlePacket(network::Packet& packet) { auto it = std::remove_if(instanceLockouts_.begin(), instanceLockouts_.end(), [mapId](const InstanceLockout& lo){ return lo.mapId == mapId; }); instanceLockouts_.erase(it, instanceLockouts_.end()); - addSystemChatMessage("Instance " + std::to_string(mapId) + " has been reset."); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addSystemChatMessage(mapLabel + " has been reset."); LOG_INFO("SMSG_INSTANCE_RESET: mapId=", mapId); } break; @@ -5214,8 +5218,10 @@ void GameHandler::handlePacket(network::Packet& packet) { "Not max level.", "Offline party members.", "Party members inside.", "Party members changing zone.", "Heroic difficulty only." }; - const char* msg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; - addSystemChatMessage("Cannot reset instance " + std::to_string(mapId) + ": " + msg); + const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason); } break; @@ -7014,16 +7020,65 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Misc consume (no state change needed) ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: - case Opcode::SMSG_PROPOSE_LEVEL_GRANT: - case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: - case Opcode::SMSG_REFER_A_FRIEND_FAILURE: - case Opcode::SMSG_REPORT_PVP_AFK_RESULT: case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: case Opcode::SMSG_PLAYER_SKINNED: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PROPOSE_LEVEL_GRANT: { + // Recruit-A-Friend: a mentor is offering to grant you a level + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t mentorGuid = packet.readUInt64(); + std::string mentorName; + auto ent = entityManager.getEntity(mentorGuid); + if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); + if (mentorName.empty()) { + auto nit = playerNameCache.find(mentorGuid); + if (nit != playerNameCache.end()) mentorName = nit->second; + } + addSystemChatMessage(mentorName.empty() + ? "A player is offering to grant you a level." + : (mentorName + " is offering to grant you a level.")); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: + addSystemChatMessage("Your Recruit-A-Friend link has expired."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_REFER_A_FRIEND_FAILURE: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t reason = packet.readUInt32(); + static const char* kRafErrors[] = { + "Not eligible", // 0 + "Target not eligible", // 1 + "Too many referrals", // 2 + "Wrong faction", // 3 + "Not a recruit", // 4 + "Recruit requirements not met", // 5 + "Level above requirement", // 6 + "Friend needs account upgrade", // 7 + }; + const char* msg = (reason < 8) ? kRafErrors[reason] + : "Recruit-A-Friend failed."; + addSystemChatMessage(std::string("Recruit-A-Friend: ") + msg); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_REPORT_PVP_AFK_RESULT: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("AFK report submitted."); + else + addSystemChatMessage("Cannot report that player as AFK right now."); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: handleRespondInspectAchievements(packet); break; @@ -22057,6 +22112,11 @@ void GameHandler::handleQuestConfirmAccept(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { sharedQuestSharerName_ = unit->getName(); } + if (sharedQuestSharerName_.empty()) { + auto nit = playerNameCache.find(sharedQuestSharerGuid_); + if (nit != playerNameCache.end()) + sharedQuestSharerName_ = nit->second; + } if (sharedQuestSharerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -22871,6 +22931,35 @@ std::string GameHandler::getAreaName(uint32_t areaId) const { return (it != areaNameCache_.end()) ? it->second : std::string{}; } +void GameHandler::loadMapNameCache() { + if (mapNameCacheLoaded_) return; + mapNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Map.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + // Field 2 = MapName_enUS (first localized); field 1 = InternalName fallback + std::string name = dbc->getString(i, 2); + if (name.empty()) name = dbc->getString(i, 1); + if (!name.empty() && !mapNameCache_.count(id)) { + mapNameCache_[id] = std::move(name); + } + } + LOG_INFO("Map.dbc: loaded ", mapNameCache_.size(), " map names"); +} + +std::string GameHandler::getMapName(uint32_t mapId) const { + if (mapId == 0) return {}; + const_cast(this)->loadMapNameCache(); + auto it = mapNameCache_.find(mapId); + return (it != mapNameCache_.end()) ? it->second : std::string{}; +} + // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- From 2be793cfbac301ef7a85535d488d53e07ebf1515 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:14:40 -0700 Subject: [PATCH 37/90] feat: improve calendar lockout and char rename messages - SMSG_CALENDAR_RAID_LOCKOUT_ADDED: show dungeon name (from Map.dbc) and difficulty label (Normal/Heroic/25-Man/25-Man Heroic) - SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: show dungeon name instead of raw map ID; now emits a chat message (was silent/LOG_DEBUG only) - SMSG_CHAR_RENAME: map result codes 1-7 to human-readable strings ("Name already in use.", "Name too short.", etc.) instead of "Character rename failed (error N)." --- src/game/game_handler.cpp | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b132aae3..77484697 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2261,7 +2261,20 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Character name changed to: " + newName); } else { - addSystemChatMessage("Character rename failed (error " + std::to_string(result) + ")."); + // ResponseCodes for name changes (shared with char create) + static const char* kRenameErrors[] = { + nullptr, // 0 = success + "Name already in use.", // 1 + "Name too short.", // 2 + "Name too long.", // 3 + "Name contains invalid characters.", // 4 + "Name contains a profanity.", // 5 + "Name is reserved.", // 6 + "Character name does not meet requirements.", // 7 + }; + const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; + addSystemChatMessage(errMsg ? std::string("Rename failed: ") + errMsg + : "Character rename failed."); } LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); } @@ -7458,10 +7471,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t mapId = packet.readUInt32(); uint32_t difficulty = packet.readUInt32(); /*uint64_t resetTime =*/ packet.readUInt64(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Calendar: Raid lockout added for map %u (difficulty %u).", mapId, difficulty); - addSystemChatMessage(buf); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout added for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); } packet.setReadPos(packet.getSize()); @@ -7474,7 +7491,14 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint64_t eventId =*/ packet.readUInt64(); uint32_t mapId = packet.readUInt32(); uint32_t difficulty = packet.readUInt32(); - (void)mapId; (void)difficulty; + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout removed for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, " difficulty=", difficulty); } From 0a41ef7285c5abee37e7a81f0be3d9da1f4ebb28 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:20:58 -0700 Subject: [PATCH 38/90] feat: improve player feedback for purchases, friend status, and instance entry - SMSG_BUY_ITEM: show "Purchased: x" confirmation using pendingBuyItemId_ set at buy time (fallback to "item #N") - handleFriendStatus: look up name from contacts_ (populated by SMSG_FRIEND_LIST) before playerNameCache, reducing "Unknown" fallbacks for online/offline/removed notifications - Channel member list: also check playerNameCache when entity manager has no name, reducing "(unknown)" placeholders - setFocus: use Unit::getName() (covers NPCs too) + playerNameCache fallback instead of Player-only cast - SMSG_INSTANCE_LOCK_WARNING_QUERY: show dungeon name + difficulty + remaining time when auto-accepting a saved instance re-entry --- src/game/game_handler.cpp | 82 +++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 77484697..a1fe739c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4416,12 +4416,20 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount // Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT. if (packet.getSize() - packet.getReadPos() >= 20) { - uint64_t vendorGuid = packet.readUInt64(); - uint32_t vendorSlot = packet.readUInt32(); - int32_t newCount = static_cast(packet.readUInt32()); + /*uint64_t vendorGuid =*/ packet.readUInt64(); + /*uint32_t vendorSlot =*/ packet.readUInt32(); + /*int32_t newCount =*/ static_cast(packet.readUInt32()); uint32_t itemCount = packet.readUInt32(); - LOG_DEBUG("SMSG_BUY_ITEM: vendorGuid=0x", std::hex, vendorGuid, std::dec, - " slot=", vendorSlot, " newCount=", newCount, " bought=", itemCount); + // Show purchase confirmation with item name if available + if (pendingBuyItemId_ != 0) { + std::string itemLabel; + if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) + if (!info->name.empty()) itemLabel = info->name; + if (itemLabel.empty()) itemLabel = "item #" + std::to_string(pendingBuyItemId_); + std::string msg = "Purchased: " + itemLabel; + if (itemCount > 1) msg += " x" + std::to_string(itemCount); + addSystemChatMessage(msg); + } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; } @@ -5243,16 +5251,30 @@ void GameHandler::handlePacket(network::Packet& packet) { // Server asks player to confirm entering a saved instance. // We auto-confirm with CMSG_INSTANCE_LOCK_RESPONSE. if (socket && packet.getSize() - packet.getReadPos() >= 17) { - /*uint32_t mapId =*/ packet.readUInt32(); - /*uint32_t diff =*/ packet.readUInt32(); - /*uint32_t timeLeft =*/ packet.readUInt32(); + uint32_t ilMapId = packet.readUInt32(); + uint32_t ilDiff = packet.readUInt32(); + uint32_t ilTimeLeft = packet.readUInt32(); packet.readUInt32(); // unk - /*uint8_t locked =*/ packet.readUInt8(); + uint8_t ilLocked = packet.readUInt8(); + // Notify player which instance is being entered/resumed + std::string ilName = getMapName(ilMapId); + if (ilName.empty()) ilName = "instance #" + std::to_string(ilMapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + std::string ilMsg = "Entering " + ilName; + if (ilDiff < 4) ilMsg += std::string(" (") + kDiff[ilDiff] + ")"; + if (ilLocked && ilTimeLeft > 0) { + uint32_t ilMins = ilTimeLeft / 60; + ilMsg += " — " + std::to_string(ilMins) + " min remaining."; + } else { + ilMsg += "."; + } + addSystemChatMessage(ilMsg); // Send acceptance network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE)); resp.writeUInt8(1); // 1=accept socket->send(resp); - LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted"); + LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted mapId=", ilMapId, + " diff=", ilDiff, " timeLeft=", ilTimeLeft); } break; } @@ -5453,13 +5475,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 9) break; uint64_t memberGuid = packet.readUInt64(); uint8_t memberFlags = packet.readUInt8(); - // Look up the name from our entity manager + // Look up the name: entity manager > playerNameCache auto entity = entityManager.getEntity(memberGuid); - std::string name = "(unknown)"; + std::string name; if (entity) { auto player = std::dynamic_pointer_cast(entity); if (player && !player->getName().empty()) name = player->getName(); } + if (name.empty()) { + auto nit = playerNameCache.find(memberGuid); + if (nit != playerNameCache.end()) name = nit->second; + } + if (name.empty()) name = "(unknown)"; std::string entry = " " + name; if (memberFlags & 0x01) entry += " [Moderator]"; if (memberFlags & 0x02) entry += " [Muted]"; @@ -11494,13 +11521,16 @@ void GameHandler::setFocus(uint64_t guid) { if (guid != 0) { auto entity = entityManager.getEntity(guid); if (entity) { - std::string name = "Unknown"; - if (entity->getType() == ObjectType::PLAYER) { - auto player = std::dynamic_pointer_cast(entity); - if (player && !player->getName().empty()) { - name = player->getName(); - } + std::string name; + auto unit = std::dynamic_pointer_cast(entity); + if (unit && !unit->getName().empty()) { + name = unit->getName(); } + if (name.empty()) { + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) name = nit->second; + } + if (name.empty()) name = "Unknown"; addSystemChatMessage("Focus set: " + name); LOG_INFO("Focus set: 0x", std::hex, guid, std::dec); } @@ -20914,13 +20944,17 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { return; } - // Look up player name from GUID + // Look up player name: contacts_ (populated by SMSG_FRIEND_LIST) > playerNameCache std::string playerName; - auto it = playerNameCache.find(data.guid); - if (it != playerNameCache.end()) { - playerName = it->second; - } else { - playerName = "Unknown"; + { + auto cit2 = std::find_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }); + if (cit2 != contacts_.end() && !cit2->name.empty()) { + playerName = cit2->name; + } else { + auto it = playerNameCache.find(data.guid); + if (it != playerNameCache.end()) playerName = it->second; + } } // Update friends cache From d2f2d6db723b076baa441068942b2a592df77f17 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:27:01 -0700 Subject: [PATCH 39/90] fix: distinguish auction owner notification action types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_AUCTION_OWNER_NOTIFICATION action field was ignored — all events showed "has sold!" regardless. Now: - action 0 (won/sold): "Your auction of has sold!" - action 1 (expired): "Your auction of has expired." - action 2 (bid placed): "A bid has been placed on your auction of ." --- src/game/game_handler.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a1fe739c..e79f878c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5531,16 +5531,21 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: { // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ... + // action: 0=sold/won, 1=expired, 2=bid placed on your auction if (packet.getSize() - packet.getReadPos() >= 16) { - uint32_t auctionId = packet.readUInt32(); - uint32_t action = packet.readUInt32(); - uint32_t error = packet.readUInt32(); + /*uint32_t auctionId =*/ packet.readUInt32(); + uint32_t action = packet.readUInt32(); + /*uint32_t error =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - (void)auctionId; (void)action; (void)error; ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); - addSystemChatMessage("Your auction of " + itemName + " has sold!"); + if (action == 1) + addSystemChatMessage("Your auction of " + itemName + " has expired."); + else if (action == 2) + addSystemChatMessage("A bid has been placed on your auction of " + itemName + "."); + else + addSystemChatMessage("Your auction of " + itemName + " has sold!"); } packet.setReadPos(packet.getSize()); break; From 90c88d7ecd3c8c47a0e08fb8143602e21d6d8116 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:31:44 -0700 Subject: [PATCH 40/90] feat: show equipment set name in save confirmation SMSG_EQUIPMENT_SET_SAVED: parse the set index and GUID, look up the matching set name from equipmentSets_, and show "Equipment set \"\" saved." instead of the generic message. Falls back to "Equipment set saved." when the set is not yet in the local cache (e.g. first save before SMSG_EQUIPMENT_SET_LIST arrives). --- src/game/game_handler.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e79f878c..2eb4fc56 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3847,11 +3847,27 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_EQUIPMENT_SET_SAVED: + case Opcode::SMSG_EQUIPMENT_SET_SAVED: { // uint32 setIndex + uint64 guid — equipment set was successfully saved - addSystemChatMessage("Equipment set saved."); + std::string setName; + if (packet.getSize() - packet.getReadPos() >= 12) { + uint32_t setIndex = packet.readUInt32(); + uint64_t setGuid = packet.readUInt64(); + for (const auto& es : equipmentSets_) { + if (es.setGuid == setGuid || + (es.setGuid == 0 && es.setId == setIndex)) { + setName = es.name; + break; + } + } + (void)setIndex; + } + addSystemChatMessage(setName.empty() + ? std::string("Equipment set saved.") + : "Equipment set \"" + setName + "\" saved."); LOG_DEBUG("Equipment set saved"); break; + } case Opcode::SMSG_PERIODICAURALOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects From 67e4497945afcf4bb92e13a544c4134f0bd0c3e3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:37:40 -0700 Subject: [PATCH 41/90] feat: improve arena team event messages and add vote kick feedback --- src/game/game_handler.cpp | 50 +++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2eb4fc56..3743169f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7607,6 +7607,8 @@ void GameHandler::handlePacket(network::Packet& packet) { msg = "You have been removed from the group: " + reason; else if (reasonType == 1) msg = "You have been removed from the group for being AFK."; + else if (reasonType == 2) + msg = "You have been removed from the group by vote."; addSystemChatMessage(msg); addUIError(msg); LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, @@ -15305,12 +15307,6 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 1) return; uint8_t event = packet.readUInt8(); - static const char* events[] = { - "joined", "left", "removed", "leader changed", - "disbanded", "created" - }; - std::string eventName = (event < 6) ? events[event] : "unknown event"; - // Read string params (up to 3) uint8_t strCount = 0; if (packet.getSize() - packet.getReadPos() >= 1) { @@ -15321,11 +15317,45 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { if (strCount >= 1 && packet.getSize() > packet.getReadPos()) param1 = packet.readString(); if (strCount >= 2 && packet.getSize() > packet.getReadPos()) param2 = packet.readString(); - std::string msg = "Arena team " + eventName; - if (!param1.empty()) msg += ": " + param1; - if (!param2.empty()) msg += " (" + param2 + ")"; + // Build natural-language message based on event type + // Event params: 0=joined(name), 1=left(name), 2=removed(name,kicker), + // 3=leader_changed(new,old), 4=disbanded, 5=created(name) + std::string msg; + switch (event) { + case 0: // joined + msg = param1.empty() ? "A player has joined your arena team." + : param1 + " has joined your arena team."; + break; + case 1: // left + msg = param1.empty() ? "A player has left the arena team." + : param1 + " has left the arena team."; + break; + case 2: // removed + if (!param1.empty() && !param2.empty()) + msg = param1 + " has been removed from the arena team by " + param2 + "."; + else if (!param1.empty()) + msg = param1 + " has been removed from the arena team."; + else + msg = "A player has been removed from the arena team."; + break; + case 3: // leader changed + msg = param1.empty() ? "The arena team captain has changed." + : param1 + " is now the arena team captain."; + break; + case 4: // disbanded + msg = "Your arena team has been disbanded."; + break; + case 5: // created + msg = param1.empty() ? "Your arena team has been created." + : "Arena team \"" + param1 + "\" has been created."; + break; + default: + msg = "Arena team event " + std::to_string(event); + if (!param1.empty()) msg += ": " + param1; + break; + } addSystemChatMessage(msg); - LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2); + LOG_INFO("Arena team event: ", (int)event, " ", param1, " ", param2); } void GameHandler::handleArenaTeamStats(network::Packet& packet) { From ee3c12b2c0e627b47c9f6120bbdaca84c6f0c04e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:39:41 -0700 Subject: [PATCH 42/90] feat: announce weather changes in chat (rain/snow/storm/clear) --- src/game/game_handler.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3743169f..15093fd0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4501,10 +4501,26 @@ void GameHandler::handlePacket(network::Packet& packet) { float wIntensity = packet.readFloat(); if (packet.getSize() - packet.getReadPos() >= 1) /*uint8_t isAbrupt =*/ packet.readUInt8(); + uint32_t prevWeatherType = weatherType_; weatherType_ = wType; weatherIntensity_ = wIntensity; const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); + // Announce weather changes (including initial zone weather) + if (wType != prevWeatherType) { + const char* weatherMsg = nullptr; + if (wIntensity < 0.05f || wType == 0) { + if (prevWeatherType != 0) + weatherMsg = "The weather clears."; + } else if (wType == 1) { + weatherMsg = "It begins to rain."; + } else if (wType == 2) { + weatherMsg = "It begins to snow."; + } else if (wType == 3) { + weatherMsg = "A storm rolls in."; + } + if (weatherMsg) addSystemChatMessage(weatherMsg); + } // Storm transition: trigger a low-frequency thunder rumble shake if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units From 8cac557f8694e2ad0124716a5fef2615a773ae11 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:42:40 -0700 Subject: [PATCH 43/90] feat: notify player when dungeon difficulty changes --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 15093fd0..800bdcf0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14716,6 +14716,7 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) { // MSG_SET_DUNGEON_DIFFICULTY: uint32 difficulty[, uint32 isInGroup, uint32 savedBool] (4 or 12 bytes) auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 4) return; + uint32_t prevDifficulty = instanceDifficulty_; instanceDifficulty_ = packet.readUInt32(); if (rem() >= 4) { uint32_t secondField = packet.readUInt32(); @@ -14734,6 +14735,15 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) { instanceIsHeroic_ = (instanceDifficulty_ == 1); } LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); + + // Announce difficulty change to the player (only when it actually changes) + // difficulty values: 0=Normal, 1=Heroic, 2=25-Man Normal, 3=25-Man Heroic + if (instanceDifficulty_ != prevDifficulty) { + static const char* kDiffLabels[] = {"Normal", "Heroic", "25-Man Normal", "25-Man Heroic"}; + const char* diffLabel = (instanceDifficulty_ < 4) ? kDiffLabels[instanceDifficulty_] : nullptr; + if (diffLabel) + addSystemChatMessage(std::string("Dungeon difficulty set to ") + diffLabel + "."); + } } // --------------------------------------------------------------------------- From a9835f687337063163f47523b2b31a1b91b16233 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 07:54:02 -0700 Subject: [PATCH 44/90] feat: show force-kill notification and resolve Meeting Stone zone name --- src/game/game_handler.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 800bdcf0..825110a4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2363,6 +2363,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Server forces player into dead state (GM command, scripted event, etc.) playerDead_ = true; if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet + addSystemChatMessage("You have been killed."); LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); packet.setReadPos(packet.getSize()); break; @@ -5871,9 +5872,15 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t levelMin = packet.readUInt8(); uint8_t levelMax = packet.readUInt8(); char buf[128]; - std::snprintf(buf, sizeof(buf), - "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", - zoneId, levelMin, levelMax); + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for %s (levels %u-%u).", + zoneName.c_str(), levelMin, levelMax); + else + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", + zoneId, levelMin, levelMax); addSystemChatMessage(buf); LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, " levels=", (int)levelMin, "-", (int)levelMax); From 9fe2ef381cf1c899c80e6f0c2b0ad5d19519f5ab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:00:46 -0700 Subject: [PATCH 45/90] feat: use zone name in battlefield entry invite message Replace raw bfZoneId integer with getAreaName() lookup in SMSG_BATTLEFIELD_MGR_ENTRY_INVITE so players see "Wintergrasp" instead of "zone 4197" in the invitation prompt. --- src/game/game_handler.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 825110a4..6779bff0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7300,8 +7300,15 @@ void GameHandler::handlePacket(network::Packet& packet) { bfMgrInvitePending_ = true; bfMgrZoneId_ = bfZoneId; char buf[128]; - std::snprintf(buf, sizeof(buf), - "You are invited to the outdoor battlefield in zone %u. Click to enter.", bfZoneId); + std::string bfZoneName = getAreaName(bfZoneId); + if (!bfZoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in %s. Click to enter.", + bfZoneName.c_str()); + else + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in zone %u. Click to enter.", + bfZoneId); addSystemChatMessage(buf); LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); break; From 59e29e298826529bc15febaad3a328bb74d62c0a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:05:16 -0700 Subject: [PATCH 46/90] feat: show taxi destination name in flight messages Replace generic "Taxi: requesting flight..." and "Flight started." with "Requesting flight to [node name]..." and "Flight to [node name] started." using the already-loaded taxiNodes_ map. Falls back to generic text when the node name is unavailable. --- src/game/game_handler.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6779bff0..161f0b95 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20674,7 +20674,13 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { dismount(); } - addSystemChatMessage("Taxi: requesting flight..."); + { + auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) + addSystemChatMessage("Requesting flight to " + destIt->second.name + "..."); + else + addSystemChatMessage("Taxi: requesting flight..."); + } // BFS to find path from startNode to destNodeId std::unordered_map> adj; @@ -20792,10 +20798,13 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { taxiActivateTimer_ = 0.0f; } - addSystemChatMessage("Flight started."); - // Save recovery target in case of disconnect during taxi. auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) + addSystemChatMessage("Flight to " + destIt->second.name + " started."); + else + addSystemChatMessage("Flight started."); + if (destIt != taxiNodes_.end()) { taxiRecoverMapId_ = destIt->second.mapId; taxiRecoverPos_ = core::coords::serverToCanonical( From ed02f5872ad9a269e9ee167d1994cd7d1316b0da Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:14:47 -0700 Subject: [PATCH 47/90] feat: show LFG dungeon name in Dungeon Finder queue messages Add LFGDungeons.dbc cache (loadLfgDungeonDbc / getLfgDungeonName) and use it to enrich three LFG chat messages in WotLK: - handleLfgJoinResult: "Joined the queue for Culling of Stratholme." - handleLfgProposalUpdate case 1: "Group found for Halls of Lightning!" - handleLfgProposalUpdate case 2: "A group has been found for ... Accept or decline." Falls back to generic text when DBC is unavailable or dungeon ID unknown. --- Data/expansions/wotlk/dbc_layouts.json | 3 ++ include/game/game_handler.hpp | 6 +++ src/game/game_handler.cpp | 62 +++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index de137ad8..73d50a87 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -113,5 +113,8 @@ "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, "Threshold8": 46, "Threshold9": 47 + }, + "LFGDungeons": { + "ID": 0, "Name": 1 } } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 10ca1c43..78cc0bbf 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2977,6 +2977,12 @@ private: bool mapNameCacheLoaded_ = false; void loadMapNameCache(); std::string getMapName(uint32_t mapId) const; + + // LFG dungeon name cache (lazy-loaded from LFGDungeons.dbc; WotLK only) + std::unordered_map lfgDungeonNameCache_; + bool lfgDungeonNameCacheLoaded_ = false; + void loadLfgDungeonDbc(); + std::string getLfgDungeonName(uint32_t dungeonId) const; std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 161f0b95..94a7ccd7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14809,7 +14809,13 @@ void GameHandler::handleLfgJoinResult(network::Packet& packet) { // Success — state tells us what phase we're entering lfgState_ = static_cast(state); LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast(state)); - addSystemChatMessage("Dungeon Finder: Joined the queue."); + { + std::string dName = getLfgDungeonName(lfgDungeonId_); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: Joined the queue for " + dName + "."); + else + addSystemChatMessage("Dungeon Finder: Joined the queue."); + } } else { const char* msg = lfgJoinResultString(result); std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); @@ -14860,15 +14866,25 @@ void GameHandler::handleLfgProposalUpdate(network::Packet& packet) { lfgProposalId_ = 0; addSystemChatMessage("Dungeon Finder: Group proposal failed."); break; - case 1: + case 1: { lfgState_ = LfgState::InDungeon; lfgProposalId_ = 0; - addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); + std::string dName = getLfgDungeonName(dungeonId); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: Group found for " + dName + "! Entering dungeon..."); + else + addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); break; - case 2: + } + case 2: { lfgState_ = LfgState::Proposal; - addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); + std::string dName = getLfgDungeonName(dungeonId); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: A group has been found for " + dName + ". Accept or decline."); + else + addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); break; + } default: break; } @@ -23118,6 +23134,42 @@ std::string GameHandler::getMapName(uint32_t mapId) const { return (it != mapNameCache_.end()) ? it->second : std::string{}; } +// --------------------------------------------------------------------------- +// LFG dungeon name cache (WotLK: LFGDungeons.dbc) +// --------------------------------------------------------------------------- + +void GameHandler::loadLfgDungeonDbc() { + if (lfgDungeonNameCacheLoaded_) return; + lfgDungeonNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("LFGDungeons.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("LFGDungeons") : nullptr; + const uint32_t idField = layout ? (*layout)["ID"] : 0; + const uint32_t nameField = layout ? (*layout)["Name"] : 1; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string name = dbc->getString(i, nameField); + if (!name.empty()) + lfgDungeonNameCache_[id] = std::move(name); + } + LOG_INFO("LFGDungeons.dbc: loaded ", lfgDungeonNameCache_.size(), " dungeon names"); +} + +std::string GameHandler::getLfgDungeonName(uint32_t dungeonId) const { + if (dungeonId == 0) return {}; + const_cast(this)->loadLfgDungeonDbc(); + auto it = lfgDungeonNameCache_.find(dungeonId); + return (it != lfgDungeonNameCache_.end()) ? it->second : std::string{}; +} + // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- From 75139aca77c8eaa00961dd1d7fef730f5b3bf713 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:16:59 -0700 Subject: [PATCH 48/90] feat: display LFG dungeon name in DungeonFinder UI status banner Extend the LFGDungeons.dbc name lookup to the Dungeon Finder window UI: - Queued state: "In queue for Culling of Stratholme (1:23)" - Proposal state: "Group found for Halls of Lightning!" - InDungeon state: "In dungeon (Utgarde Pinnacle)" - FinishedDungeon state: "Culling of Stratholme complete" - Proposal accept banner: "A group has been found for !" All states fall back gracefully when DBC name is unavailable. --- include/game/game_handler.hpp | 1 + src/ui/game_screen.cpp | 43 +++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 78cc0bbf..53e7574e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1274,6 +1274,7 @@ public: bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } bool isLfgInDungeon() const { return lfgState_ == LfgState::InDungeon; } uint32_t getLfgDungeonId() const { return lfgDungeonId_; } + std::string getCurrentLfgDungeonName() const { return getLfgDungeonName(lfgDungeonId_); } uint32_t getLfgProposalId() const { return lfgProposalId_; } int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a7897e8d..73297138 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -19328,7 +19328,12 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); int qMin = static_cast(qMs / 60000); int qSec = static_cast((qMs % 60000) / 1000); - ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), + "Status: In queue for %s (%d:%02d)", dName.c_str(), qMin, qSec); + else + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); if (avgSec >= 0) { int aMin = avgSec / 60; int aSec = avgSec % 60; @@ -19337,18 +19342,33 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { } break; } - case LfgState::Proposal: - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); + case LfgState::Proposal: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); break; + } case LfgState::Boot: ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Status: Vote kick in progress"); break; - case LfgState::InDungeon: - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); + case LfgState::InDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon (%s)", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); break; - case LfgState::FinishedDungeon: - ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); + } + case LfgState::FinishedDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: %s complete", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); break; + } case LfgState::RaidBrowser: ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser"); break; @@ -19358,8 +19378,13 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // ---- Proposal accept/decline ---- if (state == LfgState::Proposal) { - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), - "A group has been found for your dungeon!"); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for your dungeon!"); ImGui::Spacing(); if (ImGui::Button("Accept", ImVec2(120, 0))) { gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); From ea7b27612586fbc3d50ee6bc27854847932ad718 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:23:43 -0700 Subject: [PATCH 49/90] refactor: use gameHandler.getMapName in instance lockout window Replaces the static local Map.dbc cache in renderInstanceLockoutsWindow() with the existing GameHandler::getMapName() accessor, eliminating duplicate DBC loading. Moves getMapName declaration to public interface. --- include/game/game_handler.hpp | 2 +- src/ui/game_screen.cpp | 26 ++++---------------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 53e7574e..292206fc 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1275,6 +1275,7 @@ public: bool isLfgInDungeon() const { return lfgState_ == LfgState::InDungeon; } uint32_t getLfgDungeonId() const { return lfgDungeonId_; } std::string getCurrentLfgDungeonName() const { return getLfgDungeonName(lfgDungeonId_); } + std::string getMapName(uint32_t mapId) const; uint32_t getLfgProposalId() const { return lfgProposalId_; } int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } @@ -2977,7 +2978,6 @@ private: std::unordered_map mapNameCache_; bool mapNameCacheLoaded_ = false; void loadMapNameCache(); - std::string getMapName(uint32_t mapId) const; // LFG dungeon name cache (lazy-loaded from LFGDungeons.dbc; WotLK only) std::unordered_map lfgDungeonNameCache_; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 73297138..feb6efe9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -19555,24 +19555,6 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { if (lockouts.empty()) { ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active instance lockouts."); } else { - // Build map name lookup from Map.dbc (cached after first call) - static std::unordered_map sMapNames; - static bool sMapNamesLoaded = false; - if (!sMapNamesLoaded) { - sMapNamesLoaded = true; - if (auto* am = core::Application::getInstance().getAssetManager()) { - if (auto dbc = am->loadDBC("Map.dbc"); dbc && dbc->isLoaded()) { - for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { - uint32_t id = dbc->getUInt32(i, 0); - // Field 2 = MapName_enUS (first localized), field 1 = InternalName - std::string name = dbc->getString(i, 2); - if (name.empty()) name = dbc->getString(i, 1); - if (!name.empty()) sMapNames[id] = std::move(name); - } - } - } - } - auto difficultyLabel = [](uint32_t diff) -> const char* { switch (diff) { case 0: return "Normal"; @@ -19598,11 +19580,11 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { for (const auto& lo : lockouts) { ImGui::TableNextRow(); - // Instance name + // Instance name — use GameHandler's Map.dbc cache (avoids duplicate DBC load) ImGui::TableSetColumnIndex(0); - auto it = sMapNames.find(lo.mapId); - if (it != sMapNames.end()) { - ImGui::TextUnformatted(it->second.c_str()); + std::string mapName = gameHandler.getMapName(lo.mapId); + if (!mapName.empty()) { + ImGui::TextUnformatted(mapName.c_str()); } else { ImGui::Text("Map %u", lo.mapId); } From c0ffca68f24b7fd57ecffa6f9d1658ad0722ab2d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:35:18 -0700 Subject: [PATCH 50/90] feat: track and display WotLK server-authoritative combat stats Adds update field tracking for WotLK secondary combat statistics: - UNIT_FIELD_ATTACK_POWER / RANGED_ATTACK_POWER (fields 123, 126) - PLAYER_DODGE/PARRY/BLOCK/CRIT_PERCENTAGE (fields 1025-1029) - PLAYER_RANGED_CRIT_PERCENTAGE, PLAYER_SPELL_CRIT_PERCENTAGE1 (1030, 1032) - PLAYER_FIELD_COMBAT_RATING_1 (25 slots at 1231, hit/expertise/haste/etc.) Both CREATE_OBJECT and VALUES update paths now populate these fields. The Character screen Stats tab shows them when received from the server, with graceful fallback when not available (Classic/TBC expansions). Field indices verified against AzerothCore 3.3.5a UpdateFields.h. --- Data/expansions/wotlk/update_fields.json | 9 ++++ include/game/game_handler.hpp | 35 ++++++++++++++++ include/game/update_field_table.hpp | 13 ++++++ include/ui/inventory_screen.hpp | 3 +- src/game/game_handler.cpp | 53 ++++++++++++++++++++++++ src/game/update_field_table.cpp | 10 +++++ src/ui/inventory_screen.cpp | 46 +++++++++++++++++++- 7 files changed, 166 insertions(+), 3 deletions(-) diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index b35422a3..2f0a50a5 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -23,6 +23,8 @@ "UNIT_FIELD_STAT3": 87, "UNIT_FIELD_STAT4": 88, "UNIT_END": 148, + "UNIT_FIELD_ATTACK_POWER": 123, + "UNIT_FIELD_RANGED_ATTACK_POWER": 126, "PLAYER_FLAGS": 150, "PLAYER_BYTES": 153, "PLAYER_BYTES_2": 154, @@ -38,6 +40,13 @@ "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, "PLAYER_CHOSEN_TITLE": 1349, + "PLAYER_BLOCK_PERCENTAGE": 1024, + "PLAYER_DODGE_PERCENTAGE": 1025, + "PLAYER_PARRY_PERCENTAGE": 1026, + "PLAYER_CRIT_PERCENTAGE": 1029, + "PLAYER_RANGED_CRIT_PERCENTAGE": 1030, + "PLAYER_SPELL_CRIT_PERCENTAGE1": 1032, + "PLAYER_FIELD_COMBAT_RATING_1": 1231, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_DURABILITY": 60, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 292206fc..d9f5d0a8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -309,6 +309,31 @@ public: return playerStats_[idx]; } + // Server-authoritative attack power (WotLK: UNIT_FIELD_ATTACK_POWER / RANGED). + // Returns -1 if not yet received. + int32_t getMeleeAttackPower() const { return playerMeleeAP_; } + int32_t getRangedAttackPower() const { return playerRangedAP_; } + + // Server-authoritative combat chance percentages (WotLK: PLAYER_* float fields). + // Returns -1.0f if not yet received. + float getDodgePct() const { return playerDodgePct_; } + float getParryPct() const { return playerParryPct_; } + float getBlockPct() const { return playerBlockPct_; } + float getCritPct() const { return playerCritPct_; } + float getRangedCritPct() const { return playerRangedCritPct_; } + // Spell crit by school (0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane) + float getSpellCritPct(int school = 1) const { + if (school < 0 || school > 6) return -1.0f; + return playerSpellCritPct_[school]; + } + + // Server-authoritative combat ratings (WotLK: PLAYER_FIELD_COMBAT_RATING_1+idx). + // Returns -1 if not yet received. Indices match AzerothCore CombatRating enum. + int32_t getCombatRating(int cr) const { + if (cr < 0 || cr > 24) return -1; + return playerCombatRatings_[cr]; + } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -2792,6 +2817,16 @@ private: int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet int32_t playerStats_[5] = {-1, -1, -1, -1, -1}; + // WotLK secondary combat stats (-1 = not yet received) + int32_t playerMeleeAP_ = -1; + int32_t playerRangedAP_ = -1; + float playerDodgePct_ = -1.0f; + float playerParryPct_ = -1.0f; + float playerBlockPct_ = -1.0f; + float playerCritPct_ = -1.0f; + float playerRangedCritPct_ = -1.0f; + float playerSpellCritPct_[7] = {-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f}; + int32_t playerCombatRatings_[25] = {-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating // money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime. uint32_t pendingMoneyDelta_ = 0; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index bc8a53f6..20a17016 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -42,6 +42,10 @@ enum class UF : uint16_t { UNIT_FIELD_STAT4, // Spirit UNIT_END, + // Unit combat fields (WotLK: PRIVATE+OWNER — only visible for the player character) + UNIT_FIELD_ATTACK_POWER, // Melee attack power (int32) + UNIT_FIELD_RANGED_ATTACK_POWER, // Ranged attack power (int32) + // Player fields PLAYER_FLAGS, PLAYER_BYTES, @@ -59,6 +63,15 @@ enum class UF : uint16_t { PLAYER_EXPLORED_ZONES_START, PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) + // Player combat stats (WotLK: PRIVATE — float values) + PLAYER_BLOCK_PERCENTAGE, // Block chance % + PLAYER_DODGE_PERCENTAGE, // Dodge chance % + PLAYER_PARRY_PERCENTAGE, // Parry chance % + PLAYER_CRIT_PERCENTAGE, // Melee crit chance % + PLAYER_RANGED_CRIT_PERCENTAGE, // Ranged crit chance % + PLAYER_SPELL_CRIT_PERCENTAGE1, // Spell crit chance % (first school; 7 consecutive float fields) + PLAYER_FIELD_COMBAT_RATING_1, // First of 25 int32 combat rating slots (CR_* indices) + // GameObject fields GAMEOBJECT_DISPLAYID, diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 7a40f43b..65ef41c9 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -149,7 +149,8 @@ private: void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, - const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr); + const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr, + const game::GameHandler* gh = nullptr); void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 94a7ccd7..720f2f96 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8195,6 +8195,15 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { playerArmorRating_ = 0; std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); std::fill(std::begin(playerStats_), std::end(playerStats_), -1); + playerMeleeAP_ = -1; + playerRangedAP_ = -1; + playerDodgePct_ = -1.0f; + playerParryPct_ = -1.0f; + playerBlockPct_ = -1.0f; + playerCritPct_ = -1.0f; + playerRangedCritPct_ = -1.0f; + std::fill(std::begin(playerSpellCritPct_), std::end(playerSpellCritPct_), -1.0f); + std::fill(std::begin(playerCombatRatings_), std::end(playerCombatRatings_), -1); knownSpells.clear(); spellCooldowns.clear(); spellFlatMods_.clear(); @@ -10374,6 +10383,15 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), fieldIndex(UF::UNIT_FIELD_STAT4) }; + const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } @@ -10409,6 +10427,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { chosenTitleBit_ = static_cast(val); LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); } + else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } + else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); + } + else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { + playerCombatRatings_[key - ufRating1] = static_cast(val); + } else { for (int si = 0; si < 5; ++si) { if (ufStats[si] != 0xFFFF && key == ufStats[si]) { @@ -10766,6 +10797,15 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), fieldIndex(UF::UNIT_FIELD_STAT4) }; + const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; @@ -10830,6 +10870,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(false); } } + else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } + else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); + } + else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { + playerCombatRatings_[key - ufRating1V] = static_cast(val); + } else { for (int si = 0; si < 5; ++si) { if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 41473539..4d9d8c66 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -43,6 +43,8 @@ static const UFNameEntry kUFNames[] = { {"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3}, {"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4}, {"UNIT_END", UF::UNIT_END}, + {"UNIT_FIELD_ATTACK_POWER", UF::UNIT_FIELD_ATTACK_POWER}, + {"UNIT_FIELD_RANGED_ATTACK_POWER", UF::UNIT_FIELD_RANGED_ATTACK_POWER}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_BYTES", UF::PLAYER_BYTES}, {"PLAYER_BYTES_2", UF::PLAYER_BYTES_2}, @@ -61,6 +63,14 @@ static const UFNameEntry kUFNames[] = { {"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY}, {"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY}, {"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE}, + {"PLAYER_CHOSEN_TITLE", UF::PLAYER_CHOSEN_TITLE}, + {"PLAYER_BLOCK_PERCENTAGE", UF::PLAYER_BLOCK_PERCENTAGE}, + {"PLAYER_DODGE_PERCENTAGE", UF::PLAYER_DODGE_PERCENTAGE}, + {"PLAYER_PARRY_PERCENTAGE", UF::PLAYER_PARRY_PERCENTAGE}, + {"PLAYER_CRIT_PERCENTAGE", UF::PLAYER_CRIT_PERCENTAGE}, + {"PLAYER_RANGED_CRIT_PERCENTAGE", UF::PLAYER_RANGED_CRIT_PERCENTAGE}, + {"PLAYER_SPELL_CRIT_PERCENTAGE1", UF::PLAYER_SPELL_CRIT_PERCENTAGE1}, + {"PLAYER_FIELD_COMBAT_RATING_1", UF::PLAYER_FIELD_COMBAT_RATING_1}, {"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS}, {"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1}, }; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index cfea2be4..98cf3482 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1161,7 +1161,7 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; int32_t resists[6]; for (int i = 0; i < 6; ++i) resists[i] = gameHandler.getResistance(i + 1); - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists); + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists, &gameHandler); // Played time (shown if available, fetched on character screen open) uint32_t totalSec = gameHandler.getTotalTimePlayed(); @@ -1606,7 +1606,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor, const int32_t* serverStats, - const int32_t* serverResists) { + const int32_t* serverResists, + const game::GameHandler* gh) { // Sum equipment stats for item-query bonus display int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; // Secondary stat sums from extraStats @@ -1776,6 +1777,47 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } } } + + // Server-authoritative combat stats (WotLK update fields — only shown when received) + if (gh) { + int32_t meleeAP = gh->getMeleeAttackPower(); + int32_t rangedAP = gh->getRangedAttackPower(); + float dodgePct = gh->getDodgePct(); + float parryPct = gh->getParryPct(); + float blockPct = gh->getBlockPct(); + float critPct = gh->getCritPct(); + float rCritPct = gh->getRangedCritPct(); + float sCritPct = gh->getSpellCritPct(1); // Holy school (avg proxy for spell crit) + // Hit rating (CR_HIT_MELEE = 5), expertise (CR_EXPERTISE = 23), haste (CR_HASTE_MELEE = 17) + int32_t hitRating = gh->getCombatRating(5); + int32_t expertiseR = gh->getCombatRating(23); + int32_t hasteR = gh->getCombatRating(17); + int32_t armorPenR = gh->getCombatRating(24); + int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience + + bool hasAny = (meleeAP >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f || + blockPct >= 0.0f || critPct >= 0.0f || hitRating >= 0); + if (hasAny) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Combat"); + ImVec4 cyan(0.5f, 0.9f, 1.0f, 1.0f); + if (meleeAP >= 0) ImGui::TextColored(cyan, "Attack Power: %d", meleeAP); + if (rangedAP >= 0 && rangedAP != meleeAP) + ImGui::TextColored(cyan, "Ranged Attack Power: %d", rangedAP); + if (dodgePct >= 0.0f) ImGui::TextColored(cyan, "Dodge: %.2f%%", dodgePct); + if (parryPct >= 0.0f) ImGui::TextColored(cyan, "Parry: %.2f%%", parryPct); + if (blockPct >= 0.0f) ImGui::TextColored(cyan, "Block: %.2f%%", blockPct); + if (critPct >= 0.0f) ImGui::TextColored(cyan, "Melee Crit: %.2f%%", critPct); + if (rCritPct >= 0.0f) ImGui::TextColored(cyan, "Ranged Crit: %.2f%%", rCritPct); + if (sCritPct >= 0.0f) ImGui::TextColored(cyan, "Spell Crit: %.2f%%", sCritPct); + if (hitRating >= 0) ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); + if (expertiseR >= 0) ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR); + if (hasteR >= 0) ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); + if (armorPenR >= 0) ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR); + if (resilR >= 0) ImGui::TextColored(cyan, "Resilience: %d", resilR); + } + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { From 2b79f9d121c292c1807bd9e310222ed7b183acff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:37:55 -0700 Subject: [PATCH 51/90] feat: add spell power and healing bonus to WotLK character stats Tracks PLAYER_FIELD_MOD_DAMAGE_DONE_POS (7 schools at field 1171) and PLAYER_FIELD_MOD_HEALING_DONE_POS (field 1192) from server update fields. getSpellPower() returns the max damage bonus across magic schools 1-6. getHealingPower() returns the raw healing bonus. Both values displayed in the character screen Combat section alongside the previously added attack power, dodge, parry, crit, and rating fields. --- Data/expansions/wotlk/update_fields.json | 2 ++ include/game/game_handler.hpp | 14 ++++++++++++++ include/game/update_field_table.hpp | 4 ++++ src/game/game_handler.cpp | 14 ++++++++++++++ src/game/update_field_table.cpp | 2 ++ src/ui/inventory_screen.cpp | 13 +++++++++---- 6 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 2f0a50a5..1628b94c 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -40,6 +40,8 @@ "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, "PLAYER_CHOSEN_TITLE": 1349, + "PLAYER_FIELD_MOD_DAMAGE_DONE_POS": 1171, + "PLAYER_FIELD_MOD_HEALING_DONE_POS": 1192, "PLAYER_BLOCK_PERCENTAGE": 1024, "PLAYER_DODGE_PERCENTAGE": 1025, "PLAYER_PARRY_PERCENTAGE": 1026, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d9f5d0a8..86c8a43a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -314,6 +314,18 @@ public: int32_t getMeleeAttackPower() const { return playerMeleeAP_; } int32_t getRangedAttackPower() const { return playerRangedAP_; } + // Server-authoritative spell damage / healing bonus (WotLK: PLAYER_FIELD_MOD_*). + // getSpellPower returns the max damage bonus across magic schools 1-6 (Holy/Fire/Nature/Frost/Shadow/Arcane). + // Returns -1 if not yet received. + int32_t getSpellPower() const { + int32_t sp = -1; + for (int i = 1; i <= 6; ++i) { + if (playerSpellDmgBonus_[i] > sp) sp = playerSpellDmgBonus_[i]; + } + return sp; + } + int32_t getHealingPower() const { return playerHealBonus_; } + // Server-authoritative combat chance percentages (WotLK: PLAYER_* float fields). // Returns -1.0f if not yet received. float getDodgePct() const { return playerDodgePct_; } @@ -2820,6 +2832,8 @@ private: // WotLK secondary combat stats (-1 = not yet received) int32_t playerMeleeAP_ = -1; int32_t playerRangedAP_ = -1; + int32_t playerSpellDmgBonus_[7] = {-1,-1,-1,-1,-1,-1,-1}; // per school 0-6 + int32_t playerHealBonus_ = -1; float playerDodgePct_ = -1.0f; float playerParryPct_ = -1.0f; float playerBlockPct_ = -1.0f; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 20a17016..5e42a049 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -63,6 +63,10 @@ enum class UF : uint16_t { PLAYER_EXPLORED_ZONES_START, PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) + // Player spell power / healing bonus (WotLK: PRIVATE — int32 per school) + PLAYER_FIELD_MOD_DAMAGE_DONE_POS, // Spell damage bonus (first of 7 schools) + PLAYER_FIELD_MOD_HEALING_DONE_POS, // Healing bonus + // Player combat stats (WotLK: PRIVATE — float values) PLAYER_BLOCK_PERCENTAGE, // Block chance % PLAYER_DODGE_PERCENTAGE, // Dodge chance % diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 720f2f96..cd6f58c1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8197,6 +8197,8 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { std::fill(std::begin(playerStats_), std::end(playerStats_), -1); playerMeleeAP_ = -1; playerRangedAP_ = -1; + std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1); + playerHealBonus_ = -1; playerDodgePct_ = -1.0f; playerParryPct_ = -1.0f; playerBlockPct_ = -1.0f; @@ -10385,6 +10387,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { }; const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); @@ -10429,6 +10433,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { + playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); + } + else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } @@ -10799,6 +10807,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { }; const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); @@ -10872,6 +10882,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { + playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); + } + else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 4d9d8c66..ae45deb7 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -64,6 +64,8 @@ static const UFNameEntry kUFNames[] = { {"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY}, {"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE}, {"PLAYER_CHOSEN_TITLE", UF::PLAYER_CHOSEN_TITLE}, + {"PLAYER_FIELD_MOD_DAMAGE_DONE_POS", UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS}, + {"PLAYER_FIELD_MOD_HEALING_DONE_POS", UF::PLAYER_FIELD_MOD_HEALING_DONE_POS}, {"PLAYER_BLOCK_PERCENTAGE", UF::PLAYER_BLOCK_PERCENTAGE}, {"PLAYER_DODGE_PERCENTAGE", UF::PLAYER_DODGE_PERCENTAGE}, {"PLAYER_PARRY_PERCENTAGE", UF::PLAYER_PARRY_PERCENTAGE}, diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 98cf3482..247fa1cf 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1780,9 +1780,11 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play // Server-authoritative combat stats (WotLK update fields — only shown when received) if (gh) { - int32_t meleeAP = gh->getMeleeAttackPower(); - int32_t rangedAP = gh->getRangedAttackPower(); - float dodgePct = gh->getDodgePct(); + int32_t meleeAP = gh->getMeleeAttackPower(); + int32_t rangedAP = gh->getRangedAttackPower(); + int32_t spellPow = gh->getSpellPower(); + int32_t healPow = gh->getHealingPower(); + float dodgePct = gh->getDodgePct(); float parryPct = gh->getParryPct(); float blockPct = gh->getBlockPct(); float critPct = gh->getCritPct(); @@ -1795,7 +1797,7 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play int32_t armorPenR = gh->getCombatRating(24); int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience - bool hasAny = (meleeAP >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f || + bool hasAny = (meleeAP >= 0 || spellPow >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f || blockPct >= 0.0f || critPct >= 0.0f || hitRating >= 0); if (hasAny) { ImGui::Spacing(); @@ -1805,6 +1807,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play if (meleeAP >= 0) ImGui::TextColored(cyan, "Attack Power: %d", meleeAP); if (rangedAP >= 0 && rangedAP != meleeAP) ImGui::TextColored(cyan, "Ranged Attack Power: %d", rangedAP); + if (spellPow >= 0) ImGui::TextColored(cyan, "Spell Power: %d", spellPow); + if (healPow >= 0 && healPow != spellPow) + ImGui::TextColored(cyan, "Healing Power: %d", healPow); if (dodgePct >= 0.0f) ImGui::TextColored(cyan, "Dodge: %.2f%%", dodgePct); if (parryPct >= 0.0f) ImGui::TextColored(cyan, "Parry: %.2f%%", parryPct); if (blockPct >= 0.0f) ImGui::TextColored(cyan, "Block: %.2f%%", blockPct); From 3e85c78790faafe4abc57100faa282483283790f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:43:11 -0700 Subject: [PATCH 52/90] feat: show zone and map names in character selection screen Replace raw zone/map IDs with human-readable names via the existing getWhoAreaName() and getMapName() DBC caches. The Zone column is also widened from fixed 55px to a stretch column so names fit properly. Falls back to numeric IDs gracefully when DBC data is unavailable. --- src/ui/character_screen.cpp | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 406164ac..bce4c6dc 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -184,7 +184,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 45.0f); ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 1.2f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 1.5f); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); @@ -227,7 +227,13 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("%s", game::getClassName(character.characterClass)); ImGui::TableSetColumnIndex(4); - ImGui::Text("%d", character.zoneId); + { + std::string zoneName = gameHandler.getWhoAreaName(character.zoneId); + if (!zoneName.empty()) + ImGui::TextUnformatted(zoneName.c_str()); + else + ImGui::Text("%u", character.zoneId); + } } ImGui::EndTable(); @@ -328,7 +334,18 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("%s", game::getClassName(character.characterClass)); ImGui::Text("%s", game::getGenderName(character.gender)); ImGui::Spacing(); - ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId); + { + std::string mapName = gameHandler.getMapName(character.mapId); + std::string zoneName = gameHandler.getWhoAreaName(character.zoneId); + if (!mapName.empty() && !zoneName.empty()) + ImGui::Text("%s — %s", mapName.c_str(), zoneName.c_str()); + else if (!mapName.empty()) + ImGui::Text("%s (Zone %u)", mapName.c_str(), character.zoneId); + else if (!zoneName.empty()) + ImGui::Text("Map %u — %s", character.mapId, zoneName.c_str()); + else + ImGui::Text("Map %u, Zone %u", character.mapId, character.zoneId); + } if (character.hasGuild()) { ImGui::Text("Guild ID: %d", character.guildId); From a9f21b282096b0324c86a8a4a86dc02b77c17925 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:54:15 -0700 Subject: [PATCH 53/90] feat: show percentage conversions for WotLK combat ratings Convert raw combat rating values to meaningful percentages using level-scaled divisors based on known WotLK level-80 constants (from gtCombatRatings.dbc): Hit Rating : 26.23 per 1% at level 80 Expertise : 8.19 per expertise pt (0.25% dodge/parry each) Haste : 32.79 per 1% at level 80 Armor Pen : 13.99 per 1% at level 80 Resilience : 94.27 per 1% at level 80 Each stat now displays as "Hit Rating: 120 (4.58%)" instead of just "Hit Rating: 120". The divisor scales by pow(level/80, 0.93) for characters below level 80. --- src/ui/inventory_screen.cpp | 65 ++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 247fa1cf..6be4eef8 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace wowee { @@ -1816,11 +1817,65 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play if (critPct >= 0.0f) ImGui::TextColored(cyan, "Melee Crit: %.2f%%", critPct); if (rCritPct >= 0.0f) ImGui::TextColored(cyan, "Ranged Crit: %.2f%%", rCritPct); if (sCritPct >= 0.0f) ImGui::TextColored(cyan, "Spell Crit: %.2f%%", sCritPct); - if (hitRating >= 0) ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); - if (expertiseR >= 0) ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR); - if (hasteR >= 0) ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); - if (armorPenR >= 0) ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR); - if (resilR >= 0) ImGui::TextColored(cyan, "Resilience: %d", resilR); + + // Combat ratings with percentage conversion (WotLK level-80 divisors scaled by level). + // Formula: pct = rating / (divisorAt80 * pow(level/80.0, 0.93)) + // Level-80 divisors derived from gtCombatRatings.dbc (well-known WotLK constants): + // Hit: 26.23, Expertise: 8.19/expertise (0.25% each), + // Haste: 32.79, ArmorPen: 13.99, Resilience: 94.27 + uint32_t level = playerLevel > 0 ? playerLevel : gh->getPlayerLevel(); + if (level == 0) level = 80; + double lvlScale = level <= 80 + ? std::pow(static_cast(level) / 80.0, 0.93) + : 1.0; + + auto ratingPct = [&](int32_t rating, double divisorAt80) -> float { + if (rating < 0 || divisorAt80 <= 0.0) return -1.0f; + double d = divisorAt80 * lvlScale; + return static_cast(rating / d); + }; + + if (hitRating >= 0) { + float pct = ratingPct(hitRating, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Hit Rating: %d (%.2f%%)", hitRating, pct); + else + ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); + } + if (expertiseR >= 0) { + // Each expertise point reduces dodge and parry chance by 0.25% + // expertise_points = rating / 8.19 + float exp_pts = ratingPct(expertiseR, 8.19); + if (exp_pts >= 0.0f) { + float exp_pct = exp_pts * 0.25f; // % dodge/parry reduction + ImGui::TextColored(cyan, "Expertise: %d (%.1f / %.2f%%)", + expertiseR, exp_pts, exp_pct); + } else { + ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR); + } + } + if (hasteR >= 0) { + float pct = ratingPct(hasteR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Haste Rating: %d (%.2f%%)", hasteR, pct); + else + ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); + } + if (armorPenR >= 0) { + float pct = ratingPct(armorPenR, 13.99); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Armor Pen: %d (%.2f%%)", armorPenR, pct); + else + ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR); + } + if (resilR >= 0) { + // Resilience: reduces crit chance against you by pct%, and crit damage by 2*pct% + float pct = ratingPct(resilR, 94.27); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Resilience: %d (%.2f%%)", resilR, pct); + else + ImGui::TextColored(cyan, "Resilience: %d", resilR); + } } } } From 84f9d2e493e3111f67d87beb6e0add43cc99a459 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:57:44 -0700 Subject: [PATCH 54/90] feat: color class names by WoW class color in character selection screen --- src/ui/character_screen.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index bce4c6dc..96b53dd0 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -37,6 +37,22 @@ static uint64_t hashEquipment(const std::vector& eq) { return h; } +static ImVec4 classColor(uint8_t classId) { + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A + default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + } +} + void CharacterScreen::render(game::GameHandler& gameHandler) { ImGuiViewport* vp = ImGui::GetMainViewport(); const ImVec2 pad(24.0f, 24.0f); @@ -224,7 +240,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("%s", game::getRaceName(character.race)); ImGui::TableSetColumnIndex(3); - ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::TextColored(classColor(static_cast(character.characterClass)), "%s", game::getClassName(character.characterClass)); ImGui::TableSetColumnIndex(4); { @@ -331,7 +347,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("Level %d", character.level); ImGui::Text("%s", game::getRaceName(character.race)); - ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::TextColored(classColor(static_cast(character.characterClass)), "%s", game::getClassName(character.characterClass)); ImGui::Text("%s", game::getGenderName(character.gender)); ImGui::Spacing(); { From 850e4e798da0f064a7b73b11b29c2e8936fd932f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:01:30 -0700 Subject: [PATCH 55/90] feat: add right-click context menu to reputation rows to set tracked faction --- src/ui/inventory_screen.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 6be4eef8..eae1efc5 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1444,6 +1444,8 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId); bool isWatched = (factionId == watchedFactionId); + ImGui::PushID(static_cast(factionId)); + // Faction name + tier label on same line; mark at-war and watched factions ImGui::TextColored(tier.color, "[%s]", tier.name); ImGui::SameLine(90.0f); @@ -1479,7 +1481,23 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(-1.0f); ImGui::ProgressBar(ratio, ImVec2(0, 12.0f), overlay); ImGui::PopStyleColor(); + + // Right-click context menu on the progress bar + if (ImGui::BeginPopupContextItem("##RepCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (isWatched) { + if (ImGui::MenuItem("Untrack")) + gameHandler.setWatchedFactionId(0); + } else { + if (ImGui::MenuItem("Track on Rep Bar")) + gameHandler.setWatchedFactionId(factionId); + } + ImGui::EndPopup(); + } + ImGui::Spacing(); + ImGui::PopID(); } ImGui::EndChild(); From dd412af093642262cd9d59911e8572ca2f842bc8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:03:13 -0700 Subject: [PATCH 56/90] feat: show spell/ranged hit and haste ratings separately when they differ from melee --- src/ui/inventory_screen.cpp | 48 ++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index eae1efc5..e5493cc5 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1809,12 +1809,18 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play float critPct = gh->getCritPct(); float rCritPct = gh->getRangedCritPct(); float sCritPct = gh->getSpellCritPct(1); // Holy school (avg proxy for spell crit) - // Hit rating (CR_HIT_MELEE = 5), expertise (CR_EXPERTISE = 23), haste (CR_HASTE_MELEE = 17) - int32_t hitRating = gh->getCombatRating(5); - int32_t expertiseR = gh->getCombatRating(23); - int32_t hasteR = gh->getCombatRating(17); - int32_t armorPenR = gh->getCombatRating(24); - int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience + // Hit rating: CR_HIT_MELEE=5, CR_HIT_RANGED=6, CR_HIT_SPELL=7 + // Haste rating: CR_HASTE_MELEE=17, CR_HASTE_RANGED=18, CR_HASTE_SPELL=19 + // Other: CR_EXPERTISE=23, CR_ARMOR_PENETRATION=24, CR_CRIT_TAKEN_MELEE=14 + int32_t hitRating = gh->getCombatRating(5); + int32_t hitRangedR = gh->getCombatRating(6); + int32_t hitSpellR = gh->getCombatRating(7); + int32_t expertiseR = gh->getCombatRating(23); + int32_t hasteR = gh->getCombatRating(17); + int32_t hasteRangedR = gh->getCombatRating(18); + int32_t hasteSpellR = gh->getCombatRating(19); + int32_t armorPenR = gh->getCombatRating(24); + int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience bool hasAny = (meleeAP >= 0 || spellPow >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f || blockPct >= 0.0f || critPct >= 0.0f || hitRating >= 0); @@ -1860,6 +1866,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play else ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); } + // Show ranged/spell hit only when they differ from melee hit + if (hitRangedR >= 0 && hitRangedR != hitRating) { + float pct = ratingPct(hitRangedR, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Ranged Hit Rating: %d (%.2f%%)", hitRangedR, pct); + else + ImGui::TextColored(cyan, "Ranged Hit Rating: %d", hitRangedR); + } + if (hitSpellR >= 0 && hitSpellR != hitRating) { + // Spell hit cap at 17% (446 rating at 80); divisor same as melee hit + float pct = ratingPct(hitSpellR, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Spell Hit Rating: %d (%.2f%%)", hitSpellR, pct); + else + ImGui::TextColored(cyan, "Spell Hit Rating: %d", hitSpellR); + } if (expertiseR >= 0) { // Each expertise point reduces dodge and parry chance by 0.25% // expertise_points = rating / 8.19 @@ -1879,6 +1901,20 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play else ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); } + if (hasteRangedR >= 0 && hasteRangedR != hasteR) { + float pct = ratingPct(hasteRangedR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Ranged Haste Rating: %d (%.2f%%)", hasteRangedR, pct); + else + ImGui::TextColored(cyan, "Ranged Haste Rating: %d", hasteRangedR); + } + if (hasteSpellR >= 0 && hasteSpellR != hasteR) { + float pct = ratingPct(hasteSpellR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Spell Haste Rating: %d (%.2f%%)", hasteSpellR, pct); + else + ImGui::TextColored(cyan, "Spell Haste Rating: %d", hasteSpellR); + } if (armorPenR >= 0) { float pct = ratingPct(armorPenR, 13.99); if (pct >= 0.0f) From 736d266c7edefdfaed4c7e0e864786026d438f69 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:06:48 -0700 Subject: [PATCH 57/90] feat: add target-of-target display in target frame with click-to-target --- src/ui/game_screen.cpp | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index feb6efe9..0abc7523 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3758,6 +3758,44 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Target-of-Target (ToT): show who the current target is targeting + { + uint64_t totGuid = 0; + const auto& tFields = target->getFields(); + auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (itLo != tFields.end()) { + totGuid = itLo->second; + auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (itHi != tFields.end()) + totGuid |= (static_cast(itHi->second) << 32); + } + if (totGuid != 0) { + auto totEnt = gameHandler.getEntityManager().getEntity(totGuid); + std::string totName; + ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f); + if (totGuid == gameHandler.getPlayerGuid()) { + auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid); + totName = playerEnt ? getEntityName(playerEnt) : "You"; + totColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (totEnt) { + totName = getEntityName(totEnt); + uint8_t cid = entityClassId(totEnt.get()); + if (cid != 0) totColor = classColorVec4(cid); + } + if (!totName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(totColor, "%s", totName.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str()); + } + if (ImGui::IsItemClicked()) { + gameHandler.setTarget(totGuid); + } + } + } + } + // Distance const auto& movement = gameHandler.getMovementInfo(); float dx = target->getX() - movement.x; From fdd6ca30c3b76d0be09c2779b811139230567b01 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:10:03 -0700 Subject: [PATCH 58/90] feat: display active player title in gold below the player frame name --- src/ui/game_screen.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0abc7523..bbd90a8d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2756,6 +2756,18 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); } + // Active title — shown in gold below the name/level line + { + int32_t titleBit = gameHandler.getChosenTitleBit(); + if (titleBit >= 0) { + const std::string titleText = gameHandler.getFormattedTitle( + static_cast(titleBit)); + if (!titleText.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str()); + } + } + } + // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { From 01ec830555ee56c8a5004145c94ef8f1b8269e68 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:25:23 -0700 Subject: [PATCH 59/90] feat: show calendar pending invites indicator below minimap (WotLK) Add a pulsing purple "Calendar: N Invite(s)" notification below the minimap indicator stack when the server reports unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING / EVENT_INVITE_ALERT). Only rendered when the WotLK expansion is active since the calendar system is WotLK-exclusive. Consistent with the existing New Mail, talent point, BG queue, and LFG queue indicator stack. --- src/ui/game_screen.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bbd90a8d..857f903d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16653,6 +16653,28 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Calendar pending invites indicator (WotLK only) + { + auto* expReg = core::Application::getInstance().getExpansionRegistry(); + bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk"; + if (isWotLK) { + uint32_t calPending = gameHandler.getCalendarPendingInvites(); + if (calPending > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.0f); + char calBuf[48]; + snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s", + calPending, calPending == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + } + // Latency indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { From f5a834b543f5e3fa3fcb821e63eeb6bf51c227f6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:38:39 -0700 Subject: [PATCH 60/90] feat: add /clear chat command and movement speed display in stats - /clear slash command empties the chat history (was listed in autocomplete but never handled) - Stats panel shows run/flight/swim speed as percentage of base only when non-default (e.g. mounted or speed-buffed), under a new Movement section --- include/game/game_handler.hpp | 1 + src/ui/game_screen.cpp | 6 ++++++ src/ui/inventory_screen.cpp | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 86c8a43a..e380d196 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -283,6 +283,7 @@ public: * @return Vector of chat messages */ const std::deque& getChatHistory() const { return chatHistory; } + void clearChatHistory() { chatHistory.clear(); } /** * Add a locally-generated chat message (e.g., emote feedback) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 857f903d..08694921 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4669,6 +4669,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "clear") { + gameHandler.clearChatHistory(); + chatInputBuffer[0] = '\0'; + return; + } + // /invite command if (cmdLower == "invite" && spacePos != std::string::npos) { std::string targetName = command.substr(spacePos + 1); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e5493cc5..aa6c6c75 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1931,6 +1931,38 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImGui::TextColored(cyan, "Resilience: %d", resilR); } } + + // Movement speeds (always show when non-default) + { + constexpr float kBaseRun = 7.0f; + constexpr float kBaseFlight = 7.0f; + float runSpeed = gh->getServerRunSpeed(); + float flightSpeed = gh->getServerFlightSpeed(); + float swimSpeed = gh->getServerSwimSpeed(); + + bool showRun = runSpeed > 0.0f && std::fabs(runSpeed - kBaseRun) > 0.05f; + bool showFlight = flightSpeed > 0.0f && std::fabs(flightSpeed - kBaseFlight) > 0.05f; + bool showSwim = swimSpeed > 0.0f && std::fabs(swimSpeed - 4.722f) > 0.05f; + + if (showRun || showFlight || showSwim) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Movement"); + ImVec4 speedColor(0.6f, 1.0f, 0.8f, 1.0f); + if (showRun) { + float pct = (runSpeed / kBaseRun) * 100.0f; + ImGui::TextColored(speedColor, "Run Speed: %.1f%%", pct); + } + if (showFlight) { + float pct = (flightSpeed / kBaseFlight) * 100.0f; + ImGui::TextColored(speedColor, "Flight Speed: %.1f%%", pct); + } + if (showSwim) { + float pct = (swimSpeed / 4.722f) * 100.0f; + ImGui::TextColored(speedColor, "Swim Speed: %.1f%%", pct); + } + } + } } } From d58c2f4269bf12ecdc1b5e663742c7b8bce2f242 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:44:27 -0700 Subject: [PATCH 61/90] feat: show taxi flight destination indicator below minimap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores the destination node name when activateTaxi() is called and displays a "✈ → " indicator in the minimap indicator stack while isOnTaxiFlight() is true. Falls back to "✈ In Flight" when the destination name is unavailable. --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 7 +++++-- src/ui/game_screen.cpp | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e380d196..61ee11cc 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1873,6 +1873,7 @@ public: bool isTaxiMountActive() const { return taxiMountActive_; } bool isTaxiActivationPending() const { return taxiActivatePending_; } void forceClearTaxiAndMovementState(); + const std::string& getTaxiDestName() const { return taxiDestName_; } const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; } uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; } @@ -2900,6 +2901,7 @@ private: ShowTaxiNodesData currentTaxiData_; uint64_t taxiNpcGuid_ = 0; bool onTaxiFlight_ = false; + std::string taxiDestName_; bool taxiMountActive_ = false; uint32_t taxiMountDisplayId_ = 0; bool taxiActivatePending_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index cd6f58c1..3a56a670 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20759,10 +20759,13 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { { auto destIt = taxiNodes_.find(destNodeId); - if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) { + taxiDestName_ = destIt->second.name; addSystemChatMessage("Requesting flight to " + destIt->second.name + "..."); - else + } else { + taxiDestName_.clear(); addSystemChatMessage("Taxi: requesting flight..."); + } } // BFS to find path from startNode to destNodeId diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 08694921..2776108f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16681,6 +16681,25 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Taxi flight indicator — shown while on a flight path + if (gameHandler.isOnTaxiFlight()) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) { + const std::string& dest = gameHandler.getTaxiDestName(); + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 1.0f); + if (dest.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight"); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str()); + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + // Latency indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { From 727dfa5c6c8c4e1e673483f2fd5053a3ac62dbc9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:52:23 -0700 Subject: [PATCH 62/90] feat: integrate lightning system for storm weather and heavy rain The lightning system (lightning.hpp/cpp) was fully implemented but never wired into the renderer. Connect it now: - Enable lightning during server storm weather (wType==3, intensity>0.1) and heavy rain (wType==1, intensity>0.7) as a bonus visual - Scale lightning intensity proportionally to weather intensity - Render in both parallel (SEC_POST) and fallback rendering paths - Update and shutdown alongside the weather system - Show active lightning info in the performance HUD weather section --- include/rendering/renderer.hpp | 3 +++ src/rendering/performance_hud.cpp | 6 ++++++ src/rendering/renderer.cpp | 31 +++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index a198b0c7..dd727caf 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -39,6 +39,7 @@ class StarField; class Clouds; class LensFlare; class Weather; +class Lightning; class LightingManager; class SwimEffects; class MountDust; @@ -127,6 +128,7 @@ public: Clouds* getClouds() const { return skySystem ? skySystem->getClouds() : nullptr; } LensFlare* getLensFlare() const { return skySystem ? skySystem->getLensFlare() : nullptr; } Weather* getWeather() const { return weather.get(); } + Lightning* getLightning() const { return lightning.get(); } CharacterRenderer* getCharacterRenderer() const { return characterRenderer.get(); } WMORenderer* getWMORenderer() const { return wmoRenderer.get(); } M2Renderer* getM2Renderer() const { return m2Renderer.get(); } @@ -216,6 +218,7 @@ private: std::unique_ptr clouds; std::unique_ptr lensFlare; std::unique_ptr weather; + std::unique_ptr lightning; std::unique_ptr lightingManager; std::unique_ptr skySystem; // Coordinator for sky rendering std::unique_ptr swimEffects; diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 1351e715..a5a76ab2 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -10,6 +10,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" +#include "rendering/lightning.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" @@ -369,6 +370,11 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text("Intensity: %.0f%%", weather->getIntensity() * 100.0f); } + auto* lightning = renderer->getLightning(); + if (lightning && lightning->isEnabled()) { + ImGui::Text("Lightning: active (%.0f%%)", lightning->getIntensity() * 100.0f); + } + ImGui::Spacing(); } } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 9f85c3d5..af577ca6 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -12,6 +12,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" +#include "rendering/lightning.hpp" #include "rendering/lighting_manager.hpp" #include "rendering/sky_system.hpp" #include "rendering/swim_effects.hpp" @@ -699,6 +700,9 @@ bool Renderer::initialize(core::Window* win) { weather = std::make_unique(); weather->initialize(vkCtx, perFrameSetLayout); + lightning = std::make_unique(); + lightning->initialize(vkCtx, perFrameSetLayout); + swimEffects = std::make_unique(); swimEffects->initialize(vkCtx, perFrameSetLayout); @@ -802,6 +806,11 @@ void Renderer::shutdown() { weather.reset(); } + if (lightning) { + lightning->shutdown(); + lightning.reset(); + } + if (swimEffects) { swimEffects->shutdown(); swimEffects.reset(); @@ -942,6 +951,7 @@ void Renderer::applyMsaaChange() { if (characterRenderer) characterRenderer->recreatePipelines(); if (questMarkerRenderer) questMarkerRenderer->recreatePipelines(); if (weather) weather->recreatePipelines(); + if (lightning) lightning->recreatePipelines(); if (swimEffects) swimEffects->recreatePipelines(); if (mountDust) mountDust->recreatePipelines(); if (chargeEffect) chargeEffect->recreatePipelines(); @@ -2863,6 +2873,20 @@ void Renderer::update(float deltaTime) { weather->updateZoneWeather(currentZoneId, deltaTime); } weather->setEnabled(true); + + // Enable lightning during storms (wType==3) and heavy rain + if (lightning) { + uint32_t wType2 = gh->getWeatherType(); + float wInt2 = gh->getWeatherIntensity(); + bool stormActive = (wType2 == 3 && wInt2 > 0.1f) + || (wType2 == 1 && wInt2 > 0.7f); + lightning->setEnabled(stormActive); + if (stormActive) { + // Scale intensity: storm at full, heavy rain proportionally + float lIntensity = (wType2 == 3) ? wInt2 : (wInt2 - 0.7f) / 0.3f; + lightning->setIntensity(lIntensity); + } + } } else if (weather) { // No game handler (single-player without network) — zone weather only weather->updateZoneWeather(currentZoneId, deltaTime); @@ -2932,6 +2956,11 @@ void Renderer::update(float deltaTime) { weather->update(*camera, deltaTime); } + // Update lightning (storm / heavy rain) + if (lightning && camera && lightning->isEnabled()) { + lightning->update(deltaTime, *camera); + } + // Update swim effects if (swimEffects && camera && cameraController && waterRenderer) { swimEffects->update(*camera, *cameraController, *waterRenderer, deltaTime); @@ -5217,6 +5246,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (waterRenderer && camera) waterRenderer->render(cmd, perFrameSet, *camera, globalTime, false, frameIdx); if (weather && camera) weather->render(cmd, perFrameSet); + if (lightning && camera && lightning->isEnabled()) lightning->render(cmd, perFrameSet); if (swimEffects && camera) swimEffects->render(cmd, perFrameSet); if (mountDust && camera) mountDust->render(cmd, perFrameSet); if (chargeEffect && camera) chargeEffect->render(cmd, perFrameSet); @@ -5353,6 +5383,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (waterRenderer && camera) waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, frameIdx); if (weather && camera) weather->render(currentCmd, perFrameSet); + if (lightning && camera && lightning->isEnabled()) lightning->render(currentCmd, perFrameSet); if (swimEffects && camera) swimEffects->render(currentCmd, perFrameSet); if (mountDust && camera) mountDust->render(currentCmd, perFrameSet); if (chargeEffect && camera) chargeEffect->render(currentCmd, perFrameSet); From 44ff2dd4eebf0b945c4e3b223c01e5475a7826ce Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 09:59:58 -0700 Subject: [PATCH 63/90] feat: show rain particles and audio during thunderstorm weather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storm weather (wType==3 from SMSG_WEATHER) previously rendered no visual particles and no audio. Map it to RAIN in the weather system so thunderstorms produce rain particles at the server-sent intensity level, and the ambient sound manager picks up rain_heavy/medium/light audio from the same intensity logic already used for plain rain. This pairs with the lightning commit — storms now have both rain particles and lightning flashes for a complete thunderstorm experience. --- src/rendering/renderer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index af577ca6..f5ab086e 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2866,6 +2866,7 @@ void Renderer::update(float deltaTime) { // Server-driven weather (SMSG_WEATHER) — authoritative if (wType == 1) weather->setWeatherType(Weather::Type::RAIN); else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW); + else if (wType == 3) weather->setWeatherType(Weather::Type::RAIN); // thunderstorm — use rain particles else weather->setWeatherType(Weather::Type::NONE); weather->setIntensity(wInt); } else { From c31ab8c8b620b59c704c017a2f2a4f368cc8ec57 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:05:10 -0700 Subject: [PATCH 64/90] feat: play error sound when UI error messages are triggered --- src/ui/game_screen.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2776108f..0c9ece01 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -395,6 +395,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { gameHandler.setUIErrorCallback([this](const std::string& msg) { uiErrors_.push_back({msg, 0.0f}); if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin()); + // Play error sound for each new error (rate-limited by deque cap of 5) + if (auto* r = core::Application::getInstance().getRenderer()) { + if (auto* sfx = r->getUiSoundManager()) sfx->playError(); + } }); uiErrorCallbackSet_ = true; } From 792d8e1cf5809844e0ae7a1be12a9a0d7a564668 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:10:04 -0700 Subject: [PATCH 65/90] feat: show estimated BG wait time in queue indicator --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 9 +++++++-- src/ui/game_screen.cpp | 11 +++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 61ee11cc..74e7ed46 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -440,6 +440,8 @@ public: uint8_t arenaType = 0; uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress uint32_t inviteTimeout = 80; + uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE) + uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE) std::chrono::steady_clock::time_point inviteReceivedTime{}; }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3a56a670..0bc3fac6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14537,11 +14537,12 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { // Parse status-specific fields uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) + uint32_t avgWaitSec = 0, timeInQueueSec = 0; if (statusId == 1) { // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t avgWait =*/ packet.readUInt32(); - /*uint32_t inQueue =*/ packet.readUInt32(); + avgWaitSec = packet.readUInt32() / 1000; // ms → seconds + timeInQueueSec = packet.readUInt32() / 1000; } } else if (statusId == 2) { // STATUS_WAIT_JOIN: timeout(4) + mapId(4) @@ -14566,6 +14567,10 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; + if (statusId == 1) { + bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec; + bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec; + } if (statusId == 2 && !wasInvite) { bgQueues_[queueSlot].inviteTimeout = inviteTimeout; bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0c9ece01..7ff35b72 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16630,8 +16630,15 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), - "In Queue: %s", bgName.c_str()); + if (slot.avgWaitTimeSec > 0) { + int avgMin = static_cast(slot.avgWaitTimeSec) / 60; + int avgSec = static_cast(slot.avgWaitTimeSec) % 60; + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec); + } else { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "In Queue: %s", bgName.c_str()); + } } ImGui::End(); nextIndicatorY += kIndicatorH; From b03c326bcdb0c4913f6bdc478aad7bd0aefd7fa0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:13:54 -0700 Subject: [PATCH 66/90] feat: show logout countdown overlay with cancel button --- include/game/game_handler.hpp | 5 ++- include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 11 ++++++ src/ui/game_screen.cpp | 63 +++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 74e7ed46..0f1320b4 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -497,6 +497,8 @@ public: // Logout commands void requestLogout(); void cancelLogout(); + bool isLoggingOut() const { return loggingOut_; } + float getLogoutCountdown() const { return logoutCountdown_; } // Stand state void setStandState(uint8_t state); // 0=stand, 1=sit, 2=sit_chair, 3=sleep, 4=sit_low_chair, 5=sit_medium_chair, 6=sit_high_chair, 7=dead, 8=kneel, 9=submerged @@ -2491,7 +2493,8 @@ private: std::unordered_map ignoreCache; // name -> guid // ---- Logout state ---- - bool loggingOut_ = false; + bool loggingOut_ = false; + float logoutCountdown_ = 0.0f; // seconds remaining before server logs us out (0 = instant/done) // ---- Display state ---- bool helmVisible_ = true; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e8fbca0f..f22ba4da 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -348,6 +348,7 @@ private: void renderTrainerWindow(game::GameHandler& gameHandler); void renderStableWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); + void renderLogoutCountdown(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0bc3fac6..8803257a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -960,6 +960,12 @@ void GameHandler::update(float deltaTime) { updateCombatText(deltaTime); tickMinimapPings(deltaTime); + // Tick logout countdown + if (loggingOut_ && logoutCountdown_ > 0.0f) { + logoutCountdown_ -= deltaTime; + if (logoutCountdown_ < 0.0f) logoutCountdown_ = 0.0f; + } + // Update taxi landing cooldown if (taxiLandingCooldown_ > 0.0f) { taxiLandingCooldown_ -= deltaTime; @@ -11992,6 +11998,7 @@ void GameHandler::cancelLogout() { auto packet = LogoutCancelPacket::build(); socket->send(packet); loggingOut_ = false; + logoutCountdown_ = 0.0f; addSystemChatMessage("Logout cancelled."); LOG_INFO("Cancelled logout"); } @@ -21256,14 +21263,17 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { // Success - logout initiated if (data.instant) { addSystemChatMessage("Logging out..."); + logoutCountdown_ = 0.0f; } else { addSystemChatMessage("Logging out in 20 seconds..."); + logoutCountdown_ = 20.0f; } LOG_INFO("Logout response: success, instant=", (int)data.instant); } else { // Failure addSystemChatMessage("Cannot logout right now."); loggingOut_ = false; + logoutCountdown_ = 0.0f; LOG_WARNING("Logout failed, result=", data.result); } } @@ -21271,6 +21281,7 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) { addSystemChatMessage("Logout complete."); loggingOut_ = false; + logoutCountdown_ = 0.0f; LOG_INFO("Logout complete"); // Server will disconnect us } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7ff35b72..9a462959 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -726,6 +726,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (showMinimap_) { renderMinimapMarkers(gameHandler); } + renderLogoutCountdown(gameHandler); renderDeathScreen(gameHandler); renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); @@ -14089,6 +14090,68 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// Logout Countdown +// ============================================================ + +void GameScreen::renderLogoutCountdown(game::GameHandler& gameHandler) { + if (!gameHandler.isLoggingOut()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float W = 280.0f; + constexpr float H = 80.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - W) * 0.5f, screenH * 0.5f - H * 0.5f - 60.0f), + ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(W, H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.88f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.8f, 1.0f)); + + if (ImGui::Begin("##LogoutCountdown", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) { + + float cd = gameHandler.getLogoutCountdown(); + if (cd > 0.0f) { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 6.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out in 20s...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), + "Logging out in %ds...", static_cast(std::ceil(cd))); + + // Progress bar (20 second countdown) + float frac = 1.0f - std::min(cd / 20.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.5f, 0.5f, 0.9f, 1.0f)); + ImGui::ProgressBar(frac, ImVec2(-1.0f, 8.0f), ""); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } else { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 14.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "Logging out..."); + ImGui::Spacing(); + } + + // Cancel button — only while countdown is still running + if (cd > 0.0f) { + float btnW = 100.0f; + ImGui::SetCursorPosX((W - btnW) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.cancelLogout(); + } + ImGui::PopStyleColor(2); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Death Screen // ============================================================ From cc245979835ca6c88c90d00f5079193f579eafeb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:18:31 -0700 Subject: [PATCH 67/90] feat: show hearthstone bind zone name in tooltip instead of continent --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 1 + src/ui/game_screen.cpp | 24 +++++++++++++++++------- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 0f1320b4..325e3bb1 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -218,6 +218,7 @@ public: pos = homeBindPos_; return true; } + uint32_t getHomeBindZoneId() const { return homeBindZoneId_; } /** * Send a movement packet @@ -2466,6 +2467,7 @@ private: uint32_t currentMapId_ = 0; bool hasHomeBind_ = false; uint32_t homeBindMapId_ = 0; + uint32_t homeBindZoneId_ = 0; glm::vec3 homeBindPos_{0.0f}; // ---- Phase 1: Name caches ---- diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8803257a..7a6dac2e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3546,6 +3546,7 @@ void GameHandler::handlePacket(network::Packet& packet) { bool wasSet = hasHomeBind_; hasHomeBind_ = true; homeBindMapId_ = data.mapId; + homeBindZoneId_ = data.zoneId; homeBindPos_ = canonical; if (bindPointCallback_) { bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9a462959..4208d833 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6815,14 +6815,24 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (slot.id == 8690) { uint32_t mapId = 0; glm::vec3 pos; if (gameHandler.getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; + std::string homeLocation; + // Zone name (from zoneId stored in bind point) + uint32_t zoneId = gameHandler.getHomeBindZoneId(); + if (zoneId != 0) { + homeLocation = gameHandler.getWhoAreaName(zoneId); } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + default: homeLocation = "Unknown"; break; + } + } + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), + "Home: %s", homeLocation.c_str()); } } if (outOfRange) { From bbbc4efced439cd211d48fda6db22f0d7c29c9cf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:22:34 -0700 Subject: [PATCH 68/90] feat: add Heirloom and Artifact item quality tiers with light gold color Extends ItemQuality enum with ARTIFACT (6) and HEIRLOOM (7) to match WotLK 3.3.5a quality values, with light gold color (e6cc80) and display name support in inventory UI and tooltips. --- include/game/inventory.hpp | 2 ++ src/game/inventory.cpp | 2 ++ src/ui/inventory_screen.cpp | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index 7a3bcb8c..ac6af201 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -15,6 +15,8 @@ enum class ItemQuality : uint8_t { RARE = 3, // Blue EPIC = 4, // Purple LEGENDARY = 5, // Orange + ARTIFACT = 6, // Yellow (unused in 3.3.5a but valid quality value) + HEIRLOOM = 7, // Yellow/gold (WotLK bind-on-account heirlooms) }; enum class EquipSlot : uint8_t { diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 1750253a..259fb872 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -313,6 +313,8 @@ const char* getQualityName(ItemQuality quality) { case ItemQuality::RARE: return "Rare"; case ItemQuality::EPIC: return "Epic"; case ItemQuality::LEGENDARY: return "Legendary"; + case ItemQuality::ARTIFACT: return "Artifact"; + case ItemQuality::HEIRLOOM: return "Heirloom"; default: return "Unknown"; } } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index aa6c6c75..23b83d33 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -103,6 +103,8 @@ ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) { case game::ItemQuality::RARE: return ImVec4(0.0f, 0.44f, 0.87f, 1.0f); // Blue case game::ItemQuality::EPIC: return ImVec4(0.64f, 0.21f, 0.93f, 1.0f); // Purple case game::ItemQuality::LEGENDARY: return ImVec4(1.0f, 0.50f, 0.0f, 1.0f); // Orange + case game::ItemQuality::ARTIFACT: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold + case game::ItemQuality::HEIRLOOM: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold default: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); } } @@ -2268,6 +2270,8 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite case game::ItemQuality::RARE: qualHex = "0070dd"; break; case game::ItemQuality::EPIC: qualHex = "a335ee"; break; case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break; + case game::ItemQuality::ARTIFACT: qualHex = "e6cc80"; break; + case game::ItemQuality::HEIRLOOM: qualHex = "e6cc80"; break; default: break; } char linkBuf[512]; From 8da5e5c029fad5dd90dddd43f5096df7de754b7c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:29:56 -0700 Subject: [PATCH 69/90] feat: extend quality colors for Artifact/Heirloom and add guild bank repair - Add light gold (e6cc80) color for quality 6 (Artifact) and 7 (Heirloom) in the loot roll window and loot toast notification displays - Add "Repair (Guild)" button next to "Repair All" in vendor window when player is in a guild, using guild bank funds for the repair cost --- src/ui/game_screen.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4208d833..d3d0f79d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10751,9 +10751,11 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImVec4(0.0f, 0.44f, 0.87f, 1.0f),// 3=rare (blue) ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4=epic (purple) ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5=legendary (orange) + ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 6=artifact (light gold) + ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 7=heirloom (light gold) }; uint8_t q = roll.itemQuality; - ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; + ImVec4 col = (q < 8) ? kQualityColors[q] : kQualityColors[1]; // Countdown bar { @@ -13214,7 +13216,16 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { gameHandler.repairAll(vendor.vendorGuid, false); } if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Repair all equipped items"); + ImGui::SetTooltip("Repair all equipped items using your gold"); + } + if (gameHandler.isInGuild()) { + ImGui::SameLine(); + if (ImGui::SmallButton("Repair (Guild)")) { + gameHandler.repairAll(vendor.vendorGuid, true); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Repair all equipped items using guild bank funds"); + } } } ImGui::Separator(); @@ -19037,6 +19048,8 @@ void GameScreen::renderItemLootToasts() { IM_COL32( 0, 112, 221, 255), // 3 blue (rare) IM_COL32(163, 53, 238, 255), // 4 purple (epic) IM_COL32(255, 128, 0, 255), // 5 orange (legendary) + IM_COL32(230, 204, 128, 255), // 6 light gold (artifact) + IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom) }; // Stack at bottom-left above action bars; each item is 24 px tall @@ -19075,7 +19088,7 @@ void GameScreen::renderItemLootToasts() { IM_COL32(12, 12, 12, bgA), 3.0f); // Quality colour accent bar on left edge (3px wide) - ImU32 qualCol = kQualityColors[std::min(static_cast(5u), toast.quality)]; + ImU32 qualCol = kQualityColors[std::min(static_cast(7u), toast.quality)]; ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast(fgA) << 24u); bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f); From 605d0468387b19e625620b7944b61fd1380bf7b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:30:54 -0700 Subject: [PATCH 70/90] fix: extend quality color bounds for Artifact/Heirloom in chat links and loot roll Handle quality indices 6 (Artifact) and 7 (Heirloom) in all remaining quality-color lookup tables: chat link hex colors and loot roll tooltip. --- src/ui/game_screen.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d3d0f79d..09ba2a91 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -50,8 +50,8 @@ namespace { // Build a WoW-format item link string for chat insertion. // Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) { - static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000"}; - uint8_t qi = quality < 6 ? quality : 1; + static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint8_t qi = quality < 8 ? quality : 1; char buf[512]; snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", kQualHex[qi], itemId, name.c_str()); @@ -10801,7 +10801,7 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ? rollInfo->name.c_str() : roll.itemName.c_str(); if (rollInfo && rollInfo->valid) - col = (rollInfo->quality < 6) ? kQualityColors[rollInfo->quality] : kQualityColors[1]; + col = (rollInfo->quality < 8) ? kQualityColors[rollInfo->quality] : kQualityColors[1]; ImGui::TextColored(col, "[%s]", displayName); if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { inventoryScreen.renderItemTooltip(*rollInfo); From fa9017c6dcaf130eb719c0c44279f49e573f5bcd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:33:44 -0700 Subject: [PATCH 71/90] fix: update homeBindZoneId on SMSG_PLAYERBOUND so hearthstone tooltip stays accurate SMSG_PLAYERBOUND fires when the player sets a new hearthstone location. Previously homeBindMapId_ and homeBindZoneId_ were only set by SMSG_BINDPOINTUPDATE (login), so the tooltip would show the old zone until next login. Now both are updated on SMSG_PLAYERBOUND as well. --- src/game/game_handler.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7a6dac2e..a161e396 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2202,8 +2202,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 binderGuid + uint32 mapId + uint32 zoneId if (packet.getSize() - packet.getReadPos() < 16) break; /*uint64_t binderGuid =*/ packet.readUInt64(); - /*uint32_t mapId =*/ packet.readUInt32(); + uint32_t mapId = packet.readUInt32(); uint32_t zoneId = packet.readUInt32(); + // Update home bind location so hearthstone tooltip reflects the new zone + homeBindMapId_ = mapId; + homeBindZoneId_ = zoneId; std::string pbMsg = "Your home location has been set"; std::string zoneName = getAreaName(zoneId); if (!zoneName.empty()) From 6bca3dd6c5ad25465eaded2a7fa693c266810bad Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:36:28 -0700 Subject: [PATCH 72/90] fix: correct item spell trigger type labels in tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add trigger 4 (soulstone), 5 (no-delay use), 6 (learn/recipe) as "Use:" — all show as "Use:" in WoW, matching client behavior - Fix trigger 6 which was incorrectly labeled "Soulstone" (trigger 4 is soulstone; trigger 6 is LEARN_SPELL_ID used by recipe/pattern items) - Both ItemDef tooltip and ItemSlot inline tooltip are now consistent --- src/ui/inventory_screen.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 23b83d33..89f32368 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2482,10 +2482,12 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (sp.spellId == 0) continue; const char* trigger = nullptr; switch (sp.spellTrigger) { - case 0: trigger = "Use"; break; - case 1: trigger = "Equip"; break; - case 2: trigger = "Chance on Hit"; break; - case 6: trigger = "Soulstone"; break; + case 0: trigger = "Use"; break; // on use + case 1: trigger = "Equip"; break; // on equip + case 2: trigger = "Chance on Hit"; break; // proc on melee hit + case 4: trigger = "Use"; break; // soulstone (still shows as Use) + case 5: trigger = "Use"; break; // on use, no delay + case 6: trigger = "Use"; break; // learn spell (recipe/pattern) default: break; } if (!trigger) continue; @@ -2773,9 +2775,12 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, if (sp.spellId == 0) continue; const char* trigger = nullptr; switch (sp.spellTrigger) { - case 0: trigger = "Use"; break; - case 1: trigger = "Equip"; break; - case 2: trigger = "Chance on Hit"; break; + case 0: trigger = "Use"; break; // on use + case 1: trigger = "Equip"; break; // on equip + case 2: trigger = "Chance on Hit"; break; // proc on melee hit + case 4: trigger = "Use"; break; // soulstone (still shows as Use) + case 5: trigger = "Use"; break; // on use, no delay + case 6: trigger = "Use"; break; // learn spell (recipe/pattern) default: break; } if (!trigger) continue; From c48065473f3840159baf962a3b9660f527ccbdd1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:47:10 -0700 Subject: [PATCH 73/90] fix: show specific zone name in hearthstone inventory tooltip The inventory screen item tooltip showed only the continent name (Eastern Kingdoms, Kalimdor, etc.) for the hearthstone home location. Apply the same zone-name lookup already used by the action bar tooltip: prefer the zone name from homeBindZoneId_ via getWhoAreaName(), falling back to the continent name if the zone is unavailable. --- src/ui/inventory_screen.cpp | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 89f32368..d63cdb9e 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2311,16 +2311,24 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I uint32_t mapId = 0; glm::vec3 pos; if (gameHandler_->getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; - case 13: mapName = "Test"; break; - case 169: mapName = "Emerald Dream"; break; + std::string homeLocation; + // Prefer the specific zone name from the bind-point zone ID + uint32_t zoneId = gameHandler_->getHomeBindZoneId(); + if (zoneId != 0) + homeLocation = gameHandler_->getWhoAreaName(zoneId); + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + case 13: homeLocation = "Test"; break; + case 169: homeLocation = "Emerald Dream"; break; + default: homeLocation = "Unknown"; break; + } } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", homeLocation.c_str()); } else { ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Home: not set"); } From a6c4f6d2e9bf671c763405be9821aaa6fce12a0f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 10:58:05 -0700 Subject: [PATCH 74/90] feat: show effective skill value with bonus indicator in skills panel Read the third update field (bonusTemp/bonusPerm) for each skill slot so the skills tab displays the actual buffed value rather than just the base value. Skills buffed by food/potions/items now show "value / max (+N)" with a cyan name, and maxed-out skills show a gold bar and name for quick identification. --- include/game/game_handler.hpp | 5 ++++- src/game/game_handler.cpp | 10 ++++++++++ src/ui/inventory_screen.cpp | 25 +++++++++++++++++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 325e3bb1..224a6ae6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -38,8 +38,11 @@ namespace game { struct PlayerSkill { uint32_t skillId = 0; - uint16_t value = 0; + uint16_t value = 0; // base + permanent item bonuses uint16_t maxValue = 0; + uint16_t bonusTemp = 0; // temporary buff bonus (food, potions, etc.) + uint16_t bonusPerm = 0; // permanent spec/misc bonus (rarely non-zero) + uint16_t effectiveValue() const { return value + bonusTemp + bonusPerm; } }; /** diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a161e396..27afba4a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21382,10 +21382,20 @@ void GameHandler::extractSkillFields(const std::map& fields) uint16_t value = raw1 & 0xFFFF; uint16_t maxValue = (raw1 >> 16) & 0xFFFF; + uint16_t bonusTemp = 0; + uint16_t bonusPerm = 0; + auto bonusIt = fields.find(static_cast(baseField + 2)); + if (bonusIt != fields.end()) { + bonusTemp = bonusIt->second & 0xFFFF; + bonusPerm = (bonusIt->second >> 16) & 0xFFFF; + } + PlayerSkill skill; skill.skillId = skillId; skill.value = value; skill.maxValue = maxValue; + skill.bonusTemp = bonusTemp; + skill.bonusPerm = bonusPerm; newSkills[skillId] = skill; } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index d63cdb9e..42090f2a 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1245,18 +1245,35 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { snprintf(label, sizeof(label), "%s", name.c_str()); } - // Show progress bar with value/max overlay + // Effective value includes temporary and permanent bonuses + uint16_t effective = skill->effectiveValue(); + uint16_t bonus = skill->bonusTemp + skill->bonusPerm; + + // Progress bar reflects effective / max; cap visual fill at 1.0 float ratio = (skill->maxValue > 0) - ? static_cast(skill->value) / static_cast(skill->maxValue) + ? std::min(1.0f, static_cast(effective) / static_cast(skill->maxValue)) : 0.0f; char overlay[64]; - snprintf(overlay, sizeof(overlay), "%u / %u", skill->value, skill->maxValue); + if (bonus > 0) + snprintf(overlay, sizeof(overlay), "%u / %u (+%u)", effective, skill->maxValue, bonus); + else + snprintf(overlay, sizeof(overlay), "%u / %u", effective, skill->maxValue); - ImGui::Text("%s", label); + // Gold name when maxed out, cyan when buffed above base, default otherwise + bool isMaxed = (effective >= skill->maxValue && skill->maxValue > 0); + bool isBuffed = (bonus > 0); + ImVec4 nameColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) + : isBuffed ? ImVec4(0.4f, 0.9f, 1.0f, 1.0f) + : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + ImGui::TextColored(nameColor, "%s", label); ImGui::SameLine(180.0f); ImGui::SetNextItemWidth(-1.0f); + // Bar color: gold when maxed, green otherwise + ImVec4 barColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) : ImVec4(0.2f, 0.7f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); ImGui::ProgressBar(ratio, ImVec2(0, 14.0f), overlay); + ImGui::PopStyleColor(); } } } From b0b47c354a009f538cfd73e05115bc5e039c40a1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:11:33 -0700 Subject: [PATCH 75/90] feat: parse and display item skill/reputation requirements in tooltips - Store requiredSkill, requiredSkillRank, allowableClass, allowableRace, requiredReputationFaction, and requiredReputationRank from SMSG_ITEM_QUERY_SINGLE_RESPONSE in ItemQueryResponseData (was discarded) - Show "Requires ()" in item tooltip, highlighted red when the player doesn't have sufficient skill level - Show "Requires with " for reputation-gated items - Skill names resolved from SkillLine.dbc; faction names from Faction.dbc - Also fix loot window tooltip suppressing items with names starting with 'I' --- include/game/world_packets.hpp | 7 +++ src/game/packet_parsers_classic.cpp | 12 ++--- src/game/packet_parsers_tbc.cpp | 12 ++--- src/game/world_packets.cpp | 12 ++--- src/ui/game_screen.cpp | 5 ++- src/ui/inventory_screen.cpp | 68 +++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 20 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 7e0d9a41..4beab4a3 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1619,6 +1619,13 @@ struct ItemQueryResponseData { std::array socketColor{}; uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none uint32_t itemSetId = 0; // ItemSet.dbc entry; 0=not part of a set + // Requirement fields + uint32_t requiredSkill = 0; // SkillLine.dbc ID (0 = no skill required) + uint32_t requiredSkillRank = 0; // Minimum skill value + uint32_t allowableClass = 0; // Class bitmask (0 = all classes) + uint32_t allowableRace = 0; // Race bitmask (0 = all races) + uint32_t requiredReputationFaction = 0; // Faction.dbc ID (0 = none) + uint32_t requiredReputationRank = 0; // 0=Hated..8=Exalted bool valid = false; }; diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index c62567ef..acb198f6 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1394,17 +1394,17 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ return false; } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace data.itemLevel = packet.readUInt32(); data.requiredLevel = packet.readUInt32(); - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank packet.readUInt32(); // MaxCount data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ffc462ad..8bda9afb 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1011,17 +1011,17 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery return false; } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace data.itemLevel = packet.readUInt32(); data.requiredLevel = packet.readUInt32(); - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank packet.readUInt32(); // MaxCount data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 090ead75..399c9fbd 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2868,17 +2868,17 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")"); return false; } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace data.itemLevel = packet.readUInt32(); data.requiredLevel = packet.readUInt32(); - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank packet.readUInt32(); // MaxCount data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 09ba2a91..6fd071d5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12462,8 +12462,9 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Show item tooltip on hover if (hovered && info && info->valid) { inventoryScreen.renderItemTooltip(*info); - } else if (hovered && !itemName.empty() && itemName[0] != 'I') { - ImGui::SetTooltip("%s", itemName.c_str()); + } else if (hovered && info && !info->name.empty()) { + // Item info received but not yet fully valid — show name at minimum + ImGui::SetTooltip("%s", info->name.c_str()); } ImDrawList* drawList = ImGui::GetWindowDrawList(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 42090f2a..88e65163 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2795,6 +2795,74 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel); } + // Required skill (e.g. "Requires Engineering (300)") + if (info.requiredSkill != 0 && info.requiredSkillRank > 0) { + // Lazy-load SkillLine.dbc names + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetManager_) { + s_skillNamesLoaded = true; + auto dbc = assetManager_->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 2; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + if (gameHandler_) { + const auto& skills = gameHandler_->getPlayerSkills(); + auto skPit = skills.find(info.requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + } + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info.requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNames.find(info.requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info.requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info.requiredSkill, info.requiredSkillRank); + } + + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info.requiredReputationFaction != 0 && info.requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetManager_) { + s_factionNamesLoaded = true; + auto dbc = assetManager_->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 20; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info.requiredReputationRank < 8) + ? kRepRankNames[info.requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info.requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Spell effects for (const auto& sp : info.spells) { if (sp.spellId == 0) continue; From 6e8704c5204284032cdb0f68ae41ab32c47ca920 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:13:10 -0700 Subject: [PATCH 76/90] feat: show skill/reputation requirements in ItemDef tooltip path Extend the inventory item (ItemDef) tooltip to also display skill and reputation requirements by consulting the item query cache (ItemQueryResponseData) when available, matching the behavior already added to the ItemQueryResponseData tooltip path. --- src/ui/inventory_screen.cpp | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 88e65163..9c10036e 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2531,6 +2531,73 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Skill / reputation requirements from item query cache + if (gameHandler_) { + const auto* qInfo = gameHandler_->getItemInfo(item.itemId); + if (qInfo && qInfo->valid) { + if (qInfo->requiredSkill != 0 && qInfo->requiredSkillRank > 0) { + static std::unordered_map s_skillNamesB; + static bool s_skillNamesLoadedB = false; + if (!s_skillNamesLoadedB && assetManager_) { + s_skillNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 2; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNamesB[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler_->getPlayerSkills(); + auto skPit = skills.find(qInfo->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= qInfo->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNamesB.find(qInfo->requiredSkill); + if (skIt != s_skillNamesB.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), qInfo->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", qInfo->requiredSkill, qInfo->requiredSkillRank); + } + if (qInfo->requiredReputationFaction != 0 && qInfo->requiredReputationRank > 0) { + static std::unordered_map s_factionNamesB; + static bool s_factionNamesLoadedB = false; + if (!s_factionNamesLoadedB && assetManager_) { + s_factionNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 20; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNamesB[fid] = std::move(fname); + } + } + } + static const char* kRepRankNamesB[] = { + "Hated","Hostile","Unfriendly","Neutral","Friendly","Honored","Revered","Exalted" + }; + const char* rankName = (qInfo->requiredReputationRank < 8) + ? kRepRankNamesB[qInfo->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNamesB.find(qInfo->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNamesB.end() ? fIt->second.c_str() : "Unknown Faction"); + } + } + } + // "Begins a Quest" line (shown in yellow-green like the game) if (item.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); From 0741b4d9e33962c343e34fce03fb8a47d4dd4da7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:15:01 -0700 Subject: [PATCH 77/90] feat: show class restriction in item tooltip (e.g. "Classes: Paladin") Display the allowableClass bitmask parsed from SMSG_ITEM_QUERY as a human-readable "Classes: X, Y" line. Text is highlighted red when the player's own class is not in the allowed set. Hidden when all classes can use the item (no restriction). --- src/ui/inventory_screen.cpp | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 9c10036e..f4616b36 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2930,6 +2930,46 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); } + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info.allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClasses[] = { + { 1, "Warrior" }, + { 2, "Paladin" }, + { 4, "Hunter" }, + { 8, "Rogue" }, + { 16, "Priest" }, + { 32, "Death Knight" }, + { 64, "Shaman" }, + { 128, "Mage" }, + { 256, "Warlock" }, + { 1024, "Druid" }, + }; + // Count matching classes + int matchCount = 0; + for (const auto& kc : kClasses) + if (info.allowableClass & kc.mask) ++matchCount; + // Only show if restricted to a subset (not all classes) + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info.allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + // Check if player's class is allowed + bool playerAllowed = true; + if (gameHandler_) { + uint8_t pc = gameHandler_->getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0; + playerAllowed = (pmask == 0 || (info.allowableClass & pmask)); + } + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Spell effects for (const auto& sp : info.spells) { if (sp.spellId == 0) continue; From 03f8642fad175661d703f524818f54340c4797d6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:23:55 -0700 Subject: [PATCH 78/90] feat: parse and display elemental resistances and race restrictions in item tooltips - Store holyRes/fireRes/natureRes/frostRes/shadowRes/arcaneRes in ItemQueryResponseData - Parse resistance fields in WotLK, TBC, and Classic parsers (previously discarded) - Display non-zero resistances (e.g. "+40 Fire Resistance") in both tooltip paths - Add getPlayerRace() accessor to GameHandler - Show race restriction line (e.g. "Races: Blood Elf, Draenei") in both tooltip paths, highlighted red when player's race is not allowed - Useful for fire/nature/frost resist gear (Onyxia, AQ40, Naxx encounters) --- include/game/game_handler.hpp | 4 + include/game/world_packets.hpp | 6 ++ src/game/packet_parsers_classic.cpp | 12 +-- src/game/packet_parsers_tbc.cpp | 12 +-- src/game/world_packets.cpp | 12 +-- src/ui/inventory_screen.cpp | 117 ++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 18 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 224a6ae6..5e996ba9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1135,6 +1135,10 @@ public: const Character* ch = getActiveCharacter(); return ch ? static_cast(ch->characterClass) : 0; } + uint8_t getPlayerRace() const { + const Character* ch = getActiveCharacter(); + return ch ? static_cast(ch->race) : 0; + } void setPlayerGuid(uint64_t guid) { playerGuid = guid; } // Player death state diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 4beab4a3..257df817 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1594,6 +1594,12 @@ struct ItemQueryResponseData { float damageMax = 0.0f; uint32_t delayMs = 0; int32_t armor = 0; + int32_t holyRes = 0; + int32_t fireRes = 0; + int32_t natureRes = 0; + int32_t frostRes = 0; + int32_t shadowRes = 0; + int32_t arcaneRes = 0; int32_t stamina = 0; int32_t strength = 0; int32_t agility = 0; diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index acb198f6..53b07f15 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1468,12 +1468,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ // Remaining tail can vary by core. Read resistances + delay when present. if (packet.getSize() - packet.getReadPos() >= 28) { - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 8bda9afb..45ef8dde 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1087,12 +1087,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery data.armor = static_cast(packet.readUInt32()); if (packet.getSize() - packet.getReadPos() >= 28) { - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 399c9fbd..d8f2c98a 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2945,12 +2945,12 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa } data.armor = static_cast(packet.readUInt32()); - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); packet.readUInt32(); // AmmoType packet.readFloat(); // RangedModRange diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index f4616b36..3510c4a5 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2423,6 +2423,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::Text("%d Armor", item.armor); } + // Elemental resistances from item query cache (fire resist gear, nature resist gear, etc.) + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + const int32_t resValsI[6] = { qi->holyRes, qi->fireRes, qi->natureRes, + qi->frostRes, qi->shadowRes, qi->arcaneRes }; + static const char* resLabelsI[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int i = 0; i < 6; ++i) + if (resValsI[i] > 0) ImGui::Text("+%d %s", resValsI[i], resLabelsI[i]); + } + } + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { if (val <= 0) return; if (!out.empty()) out += " "; @@ -2595,6 +2610,55 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I rankName, fIt != s_factionNamesB.end() ? fIt->second.c_str() : "Unknown Faction"); } + // Class restriction + if (qInfo->allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClassesB[] = { + { 1,"Warrior" },{ 2,"Paladin" },{ 4,"Hunter" },{ 8,"Rogue" }, + { 16,"Priest" },{ 32,"Death Knight" },{ 64,"Shaman" }, + { 128,"Mage" },{ 256,"Warlock" },{ 1024,"Druid" }, + }; + int mc = 0; + for (const auto& kc : kClassesB) if (qInfo->allowableClass & kc.mask) ++mc; + if (mc > 0 && mc < 10) { + char buf[128] = "Classes: "; bool first = true; + for (const auto& kc : kClassesB) { + if (!(qInfo->allowableClass & kc.mask)) continue; + if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1); + strncat(buf, kc.name, sizeof(buf)-strlen(buf)-1); + first = false; + } + uint8_t pc = gameHandler_->getPlayerClass(); + uint32_t pm = (pc > 0 && pc <= 10) ? (1u << (pc-1)) : 0; + bool ok = (pm == 0 || (qInfo->allowableClass & pm)); + ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf); + } + } + // Race restriction + if (qInfo->allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRacesB[] = { + { 1,"Human" },{ 2,"Orc" },{ 4,"Dwarf" },{ 8,"Night Elf" }, + { 16,"Undead" },{ 32,"Tauren" },{ 64,"Gnome" },{ 128,"Troll" }, + { 512,"Blood Elf" },{ 1024,"Draenei" }, + }; + constexpr uint32_t kAll = 1|2|4|8|16|32|64|128|512|1024; + if ((qInfo->allowableRace & kAll) != kAll) { + int mc = 0; + for (const auto& kr : kRacesB) if (qInfo->allowableRace & kr.mask) ++mc; + if (mc > 0) { + char buf[160] = "Races: "; bool first = true; + for (const auto& kr : kRacesB) { + if (!(qInfo->allowableRace & kr.mask)) continue; + if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1); + strncat(buf, kr.name, sizeof(buf)-strlen(buf)-1); + first = false; + } + uint8_t pr = gameHandler_->getPlayerRace(); + uint32_t pm = (pr > 0 && pr <= 11) ? (1u << (pr-1)) : 0; + bool ok = (pm == 0 || (qInfo->allowableRace & pm)); + ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf); + } + } + } } } @@ -2810,6 +2874,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, if (info.armor > 0) ImGui::Text("%d Armor", info.armor); + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { info.holyRes, info.fireRes, info.natureRes, + info.frostRes, info.shadowRes, info.arcaneRes }; + static const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int i = 0; i < 6; ++i) + if (resVals[i] > 0) ImGui::Text("+%d %s", resVals[i], resLabels[i]); + } + auto appendBonus = [](std::string& out, int32_t val, const char* name) { if (val <= 0) return; if (!out.empty()) out += " "; @@ -2970,6 +3046,47 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } } + // Race restriction (e.g. "Races: Night Elf, Human") + if (info.allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRaces[] = { + { 1, "Human" }, + { 2, "Orc" }, + { 4, "Dwarf" }, + { 8, "Night Elf" }, + { 16, "Undead" }, + { 32, "Tauren" }, + { 64, "Gnome" }, + { 128, "Troll" }, + { 512, "Blood Elf" }, + { 1024, "Draenei" }, + }; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + // Only show if not all playable races are allowed + if ((info.allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info.allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info.allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + bool playerAllowed = true; + if (gameHandler_) { + uint8_t pr = gameHandler_->getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0; + playerAllowed = (pmask == 0 || (info.allowableRace & pmask)); + } + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Spell effects for (const auto& sp : info.spells) { if (sp.spellId == 0) continue; From ef7494700e2c81f0bb0ad863211471e0a8a0fa08 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:32:32 -0700 Subject: [PATCH 79/90] feat: parse and display Heroic/Unique/Unique-Equipped item flags in tooltips --- include/game/world_packets.hpp | 2 ++ src/game/packet_parsers_classic.cpp | 4 ++-- src/game/packet_parsers_tbc.cpp | 6 +++--- src/game/world_packets.cpp | 6 +++--- src/ui/inventory_screen.cpp | 29 +++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 257df817..d864b57e 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1587,7 +1587,9 @@ struct ItemQueryResponseData { uint32_t subClass = 0; uint32_t displayInfoId = 0; uint32_t quality = 0; + uint32_t itemFlags = 0; // Item flag bitmask (Heroic=0x8, Unique-Equipped=0x1000000) uint32_t inventoryType = 0; + int32_t maxCount = 0; // Max that can be carried (1 = Unique, 0 = unlimited) int32_t maxStack = 1; uint32_t containerSlots = 0; float damageMin = 0.0f; diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 53b07f15..041af211 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1381,7 +1381,7 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ return false; } - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags // Vanilla: NO Flags2 packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); // SellPrice @@ -1405,7 +1405,7 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ packet.readUInt32(); // RequiredCityRank data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 45ef8dde..935b34ae 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -998,7 +998,7 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery return false; } - packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2) + data.itemFlags = packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2) // TBC: NO Flags2, NO BuyCount packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); @@ -1022,8 +1022,8 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery packet.readUInt32(); // RequiredCityRank data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount - data.maxStack = static_cast(packet.readUInt32()); // Stackable + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) + data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); // TBC: statsCount prefix + exactly statsCount pairs (WotLK always sends 10) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d8f2c98a..dbcbf4c9 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2846,7 +2846,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before flags (entry=", data.entry, ")"); return false; } - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyCount packet.readUInt32(); // BuyPrice @@ -2856,7 +2856,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa if (data.inventoryType > 28) { // inventoryType out of range — BuyCount probably not present; rewind and try 4 fields packet.setReadPos(postQualityPos); - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); // SellPrice @@ -2879,7 +2879,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // RequiredCityRank data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 3510c4a5..8b7c7a57 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2315,6 +2315,23 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel); } + // Heroic / Unique / Unique-Equipped indicators + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (qi->itemFlags & kFlagHeroic) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + } + if (qi->maxCount == 1) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + } else if (qi->itemFlags & kFlagUniqueEquipped) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + } + } + } + // Binding type switch (item.bindType) { case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; @@ -2810,6 +2827,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel); } + // Unique / Heroic indicators + constexpr uint32_t kFlagHeroic = 0x8; // ITEM_FLAG_HEROIC_TOOLTIP + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; // ITEM_FLAG_UNIQUE_EQUIPPABLE + if (info.itemFlags & kFlagHeroic) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + } + if (info.maxCount == 1) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + } else if (info.itemFlags & kFlagUniqueEquipped) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + } + // Binding type switch (info.bindType) { case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; From 84d5a1125fe0e2daa32b0ba187abc3a30df6edbd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:34:48 -0700 Subject: [PATCH 80/90] feat: show gem sockets and item set bonuses in ItemDef tooltip path --- src/ui/inventory_screen.cpp | 136 ++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8b7c7a57..fb0a0df7 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2679,6 +2679,142 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Gem socket slots and item set — look up from query cache + if (gameHandler_) { + const auto* qi2 = gameHandler_->getItemInfo(item.itemId); + if (qi2 && qi2->valid) { + // Gem sockets + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + bool hasSocket = false; + for (int i = 0; i < 3; ++i) { + if (qi2->socketColor[i] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (qi2->socketColor[i] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && qi2->socketBonus != 0) { + static std::unordered_map s_enchantNamesD; + static bool s_enchantNamesLoadedD = false; + if (!s_enchantNamesLoadedD && assetManager_) { + s_enchantNamesLoadedD = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNamesD[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNamesD.find(qi2->socketBonus); + if (enchIt != s_enchantNamesD.end()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", qi2->socketBonus); + } + } + // Item set membership + if (qi2->itemSetId != 0) { + struct SetEntryD { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setDataD; + static bool s_setDataLoadedD = false; + if (!s_setDataLoadedD && assetManager_) { + s_setDataLoadedD = true; + auto dbc = assetManager_->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = { + "Item0","Item1","Item2","Item3","Item4", + "Item5","Item6","Item7","Item8","Item9" }; + static const char* spellKeys[10] = { + "Spell0","Spell1","Spell2","Spell3","Spell4", + "Spell5","Spell6","Spell7","Spell8","Spell9" }; + static const char* thrKeys[10] = { + "Threshold0","Threshold1","Threshold2","Threshold3","Threshold4", + "Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" }; + uint32_t itemFB[10], spellFB[10], thrFB[10]; + for (int i = 0; i < 10; ++i) { + itemFB[i] = 18+i; spellFB[i] = 28+i; thrFB[i] = 38+i; + } + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntryD e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFB[i]); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFB[i]); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFB[i]); + } + s_setDataD[id] = std::move(e); + } + } + } + auto setIt = s_setDataD.find(qi2->itemSetId); + ImGui::Spacing(); + if (setIt != s_setDataD.end()) { + const SetEntryD& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + if (inventory) { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& eSlot = inventory->getEquipSlot(static_cast(s)); + if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + } + if (total > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + } else if (!se.name.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + } + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler_->getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", qi2->itemSetId); + } + } + } + } + // "Begins a Quest" line (shown in yellow-green like the game) if (item.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); From 04768f41de40b3fc5dd8abaf72999710154ea4d8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:37:13 -0700 Subject: [PATCH 81/90] fix: targetEnemy uses faction isHostile() instead of targeting all non-player units --- src/game/game_handler.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 27afba4a..62535583 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11699,9 +11699,8 @@ void GameHandler::targetEnemy(bool reverse) { for (const auto& [guid, entity] : entities) { if (entity->getType() == ObjectType::UNIT) { - // Check if hostile (this is simplified - would need faction checking) auto unit = std::dynamic_pointer_cast(entity); - if (unit && guid != playerGuid) { + if (unit && guid != playerGuid && unit->isHostile()) { hostiles.push_back(guid); } } From a05abc8881476b53f07f34ba8c83cc2fca8169bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:39:22 -0700 Subject: [PATCH 82/90] feat: include spell names in MISS/DODGE/PARRY/BLOCK/IMMUNE/ABSORB/RESIST combat log entries --- src/ui/game_screen.cpp | 43 +++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6fd071d5..2c4fff48 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20187,31 +20187,60 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); break; case T::MISS: - snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); + if (spell) + snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt); + else + snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::DODGE: - snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); + if (spell) + snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::PARRY: - snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); + if (spell) + snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::BLOCK: - snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); + if (spell) + snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount); + else + snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); break; case T::IMMUNE: - snprintf(desc, sizeof(desc), "%s is immune", tgt); + if (spell) + snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); + else + snprintf(desc, sizeof(desc), "%s is immune", tgt); color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); break; case T::ABSORB: - snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + else + snprintf(desc, sizeof(desc), "Absorbed"); color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); break; case T::RESIST: - snprintf(desc, sizeof(desc), "%d resisted", e.amount); + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d resisted", e.amount); + else + snprintf(desc, sizeof(desc), "Resisted"); color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); break; case T::ENVIRONMENTAL: From d40e8f1618fd5b2e334e452ec240bb550d735c15 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:48:42 -0700 Subject: [PATCH 83/90] fix: combat log uses actual attacker/victim GUIDs instead of current target addCombatText now accepts optional srcGuid/dstGuid parameters. When provided, the persistent combat log resolves names from the actual packet GUIDs rather than always falling back to playerGuid/targetGuid. Updated handleAttackerStateUpdate, handleSpellDamageLog, handleSpellHealLog, and SMSG_PERIODICAURALOG to pass data.attackerGuid / data.targetGuid (or casterGuid/victimGuid), so the combat log correctly records the attacker name when being hit by enemies the player has not selected as their current target. All 48 existing call sites use the 0/0 default and are unaffected. --- include/game/game_handler.hpp | 3 +- src/game/game_handler.cpp | 62 +++++++++++++++++++---------------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5e996ba9..1f04eb22 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2367,7 +2367,8 @@ private: void handleLogoutResponse(network::Packet& packet); void handleLogoutComplete(network::Packet& packet); - void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0); + void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0, + uint64_t srcGuid = 0, uint64_t dstGuid = 0); void addSystemChatMessage(const std::string& message); /** diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 62535583..210828bd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3915,13 +3915,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t res = packet.readUInt32(); if (dmg > 0) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (abs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(abs), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (res > 0) addCombatText(CombatTextEntry::RESIST, static_cast(res), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); } else if (auraType == 8 || auraType == 124 || auraType == 45) { // Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes // WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes @@ -3937,10 +3937,10 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint8_t isCrit=*/ packet.readUInt8(); } addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (hotAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); } else if (auraType == 46 || auraType == 91) { // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. @@ -3949,7 +3949,7 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t amount = packet.readUInt32(); if ((isPlayerVictim || isPlayerCaster) && amount > 0) addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), - spellId, isPlayerCaster, periodicPowerType); + spellId, isPlayerCaster, periodicPowerType, casterGuid, victimGuid); } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; @@ -3959,7 +3959,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Show as periodic damage from victim's perspective (mana drained) if (isPlayerVictim && amount > 0) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), - spellId, false); + spellId, false, 0, casterGuid, victimGuid); } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -14003,7 +14003,8 @@ void GameHandler::stopAutoAttack() { LOG_INFO("Stopping auto-attack"); } -void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType) { +void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, + uint64_t srcGuid, uint64_t dstGuid) { CombatTextEntry entry; entry.type = type; entry.amount = amount; @@ -14013,17 +14014,20 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint entry.powerType = powerType; combatText.push_back(entry); - // Persistent combat log + // Persistent combat log — use explicit GUIDs if provided, else fall back to + // player/current-target (the old behaviour for events without specific participants). CombatLogEntry log; log.type = type; log.amount = amount; log.spellId = spellId; log.isPlayerSource = isPlayerSource; log.timestamp = std::time(nullptr); - std::string pname(lookupName(playerGuid)); - std::string tname((targetGuid != 0) ? lookupName(targetGuid) : std::string()); - log.sourceName = isPlayerSource ? pname : tname; - log.targetName = isPlayerSource ? tname : pname; + uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid + : (isPlayerSource ? playerGuid : targetGuid); + uint64_t effectiveDst = (dstGuid != 0) ? dstGuid + : (isPlayerSource ? targetGuid : playerGuid); + log.sourceName = lookupName(effectiveSrc); + log.targetName = (effectiveDst != 0) ? lookupName(effectiveDst) : std::string{}; if (combatLog_.size() >= MAX_COMBAT_LOG) combatLog_.pop_front(); combatLog_.push_back(std::move(log)); @@ -16210,28 +16214,28 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { } if (data.isMiss()) { - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 1) { - addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 2) { - addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 4) { // VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount if (data.totalDamage > 0) - addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker); - addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 5) { // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 6) { // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. - addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 7) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; - addCombatText(type, data.totalDamage, 0, isPlayerAttacker); + addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); // Show partial absorb/resist from sub-damage entries uint32_t totalAbsorbed = 0, totalResisted = 0; for (const auto& sub : data.subDamages) { @@ -16239,9 +16243,9 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { totalResisted += sub.resisted; } if (totalAbsorbed > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker); + addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); if (totalResisted > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker); + addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } (void)isPlayerTarget; @@ -16262,11 +16266,11 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE; if (data.damage > 0) - addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); if (data.absorbed > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); if (data.resisted > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource); + addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); } void GameHandler::handleSpellHealLog(network::Packet& packet) { @@ -16278,9 +16282,9 @@ void GameHandler::handleSpellHealLog(network::Packet& packet) { if (!isPlayerSource && !isPlayerTarget) return; // Not our combat auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; - addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource); + addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); if (data.absorbed > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); } // ============================================================ From 8213de1d0fa5a9d69a6b9872fb846b105aba18e6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:50:00 -0700 Subject: [PATCH 84/90] fix: pass actual GUIDs to combat log in SPELLLOGMISS and PROCRESIST handlers SMSG_SPELLLOGMISS and SMSG_PROCRESIST already parsed casterGuid / victimGuid from the packet but discarded them when calling addCombatText. Now pass those GUIDs so combat log entries record the actual attacker/victim names rather than falling back to current target. --- src/game/game_handler.cpp | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 210828bd..df5a0a7c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2061,13 +2061,13 @@ void GameHandler::handlePacket(network::Packet& packet) { return UpdateObjectParser::readPackedGuid(packet); }; if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; - /*uint64_t caster =*/ readPrGuid(); + uint64_t caster = readPrGuid(); if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); if (victim == playerGuid) - addCombatText(CombatTextEntry::RESIST, 0, spellId, false); + addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); packet.setReadPos(packet.getSize()); break; } @@ -2692,10 +2692,10 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; if (casterGuid == playerGuid) { // We cast a spell and it missed the target - addCombatText(ct, 0, 0, true); + addCombatText(ct, 0, 0, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) - addCombatText(ct, 0, 0, false); + addCombatText(ct, 0, 0, false, 0, casterGuid, victimGuid); } } break; @@ -6831,12 +6831,11 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); - (void)attackerGuid; // Show RESIST when player is the victim; show as caster-side MISS when player is attacker if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, false); + addCombatText(CombatTextEntry::MISS, 0, spellId, false, 0, attackerGuid, victimGuid); } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, true); + addCombatText(CombatTextEntry::MISS, 0, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; From 3bdd3f1d3f2522148060b58dbd5505cff042f436 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:51:07 -0700 Subject: [PATCH 85/90] fix: pass actual GUIDs and spellId to SPELLDAMAGESHIELD and SPELLORDAMAGE_IMMUNE combat log These handlers already had casterGuid/victimGuid available but were discarding the packet spellId and not passing GUIDs to addCombatText. Now the combat log entries show the correct attacker/victim names and the spell that caused the reflect/immune event. --- src/game/game_handler.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index df5a0a7c..7bf9af67 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6102,18 +6102,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } - /*uint32_t spellId =*/ packet.readUInt32(); - uint32_t damage = packet.readUInt32(); + uint32_t shieldSpellId = packet.readUInt32(); + uint32_t damage = packet.readUInt32(); if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect if (casterGuid == playerGuid) { // We have a damage shield that reflected damage - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, true); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // A damage shield hit us (e.g. target's Thorns) - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, false); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, false, 0, casterGuid, victimGuid); } break; } @@ -6131,13 +6131,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = immuneTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; - /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t immuneSpellId = packet.readUInt32(); /*uint8_t saveType =*/ packet.readUInt8(); // Show IMMUNE text when the player is the caster (we hit an immune target) // or the victim (we are immune) if (casterGuid == playerGuid || victimGuid == playerGuid) { - addCombatText(CombatTextEntry::IMMUNE, 0, 0, - casterGuid == playerGuid); + addCombatText(CombatTextEntry::IMMUNE, 0, immuneSpellId, + casterGuid == playerGuid, 0, casterGuid, victimGuid); } break; } From 0982f557d262017b88ccb4abf4001aa3b154b346 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:52:31 -0700 Subject: [PATCH 86/90] fix: pass actual GUIDs for environmental damage and energize combat log entries SMSG_ENVIRONMENTALDAMAGELOG and SMSG_ENVIRONMENTAL_DAMAGE_LOG now pass dstGuid=victimGuid with srcGuid=0 (no caster for env damage), ensuring the combat log shows an empty source name rather than the player's current target. SMSG_SPELLENERGIZERLOG now passes casterGuid/victimGuid so the log correctly attributes mana/energy restoration to the actual caster rather than the player's current target. --- src/game/game_handler.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7bf9af67..94d047a3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2711,12 +2711,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t absorb = packet.readUInt32(); uint32_t resist = packet.readUInt32(); if (victimGuid == playerGuid) { + // Environmental damage: no caster GUID, victim = player if (damage > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false, 0, 0, victimGuid); if (absorb > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false); + addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false, 0, 0, victimGuid); if (resist > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false); + addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false, 0, 0, victimGuid); } break; } @@ -3988,7 +3989,7 @@ void GameHandler::handlePacket(network::Packet& packet) { bool isPlayerVictim = (victimGuid == playerGuid); bool isPlayerCaster = (casterGuid == playerGuid); if ((isPlayerVictim || isPlayerCaster) && amount > 0) - addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType); + addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType, casterGuid, victimGuid); packet.setReadPos(packet.getSize()); break; } @@ -4002,12 +4003,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t envAbs = packet.readUInt32(); uint32_t envRes = packet.readUInt32(); if (victimGuid == playerGuid) { + // Environmental damage: no caster GUID, victim = player if (dmg > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, 0, 0, victimGuid); if (envAbs > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false); + addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false, 0, 0, victimGuid); if (envRes > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false); + addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false, 0, 0, victimGuid); } packet.setReadPos(packet.getSize()); break; From d48ead939be74cb1784c177fa079443e5210a370 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:54:14 -0700 Subject: [PATCH 87/90] fix: pass actual GUIDs in SMSG_SPELLINSTAKILLLOG combat log entries --- src/game/game_handler.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 94d047a3..9baf5247 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6267,10 +6267,10 @@ void GameHandler::handlePacket(network::Packet& packet) { // Show kill/death feedback for the local player if (ikCaster == playerGuid) { // We killed a target instantly — show a KILL combat text hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true); + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { // We were instantly killed — show a large incoming hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false); + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false, 0, ikCaster, ikVictim); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, From 38111fe8c03977c1c97d57760bd4478977639306 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:55:23 -0700 Subject: [PATCH 88/90] fix: pass actual GUIDs in SMSG_SPELLLOGEXECUTE power drain and health leech combat log entries --- src/game/game_handler.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9baf5247..5b293023 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6320,10 +6320,11 @@ void GameHandler::handlePacket(network::Packet& packet) { /*float drainMult =*/ packet.readFloat(); if (drainAmount > 0) { if (drainTarget == playerGuid) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false); + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false, 0, + exeCaster, drainTarget); else if (isPlayerCaster) addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, - static_cast(drainPower)); + static_cast(drainPower), exeCaster, drainTarget); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, " power=", drainPower, " amount=", drainAmount); @@ -6340,9 +6341,11 @@ void GameHandler::handlePacket(network::Packet& packet) { /*float leechMult =*/ packet.readFloat(); if (leechAmount > 0) { if (leechTarget == playerGuid) - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, + exeCaster, leechTarget); else if (isPlayerCaster) - addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true); + addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true, 0, + exeCaster, leechTarget); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); } From ffef3dda7ec12ecde1d8fb5d8cfbe488c814a385 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 11:57:45 -0700 Subject: [PATCH 89/90] fix: pass actual GUIDs in SMSG_SPELL_CHANCE_PROC_LOG combat log entries --- src/game/game_handler.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5b293023..8403aae8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6236,7 +6236,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 3) { packet.setReadPos(packet.getSize()); break; } - /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + uint64_t procTargetGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 2) { packet.setReadPos(packet.getSize()); break; } @@ -6247,7 +6247,8 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t procSpellId = packet.readUInt32(); // Show a "PROC!" floating text when the player triggers the proc if (procCasterGuid == playerGuid && procSpellId > 0) - addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true); + addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0, + procCasterGuid, procTargetGuid); packet.setReadPos(packet.getSize()); break; } From e51b215f85768706bb9d00f30385e545f803cb84 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 12:03:07 -0700 Subject: [PATCH 90/90] feat: add DISPEL and INTERRUPT combat log entries for dispel/spellsteal/interrupt events --- include/game/spell_defines.hpp | 3 +- src/game/game_handler.cpp | 69 ++++++++++++++++++++++------------ src/ui/game_screen.cpp | 22 +++++++++++ 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index d8f8c1df..a3944a0e 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -52,7 +52,8 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER + ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER, + DISPEL, INTERRUPT }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8403aae8..13e6171b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6160,20 +6160,22 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); uint32_t count = packet.readUInt32(); + // Collect first dispelled spell id/name; process all entries for combat log + // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) + uint32_t firstDispelledId = 0; + std::string firstSpellName; + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { + uint32_t dispelledId = packet.readUInt32(); + /*uint8_t isPositive =*/ packet.readUInt8(); + if (i == 0) { + firstDispelledId = dispelledId; + const std::string& nm = getSpellName(dispelledId); + firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + } + } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { const char* verb = isStolen ? "stolen" : "dispelled"; - // Collect first dispelled spell name for the message - // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) - std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { - uint32_t dispelledId = packet.readUInt32(); - /*uint8_t isPositive =*/ packet.readUInt8(); - if (i == 0) { - const std::string& nm = getSpellName(dispelledId); - firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; - } - } if (!firstSpellName.empty()) { char buf[256]; if (victimGuid == playerGuid && casterGuid != playerGuid) @@ -6184,6 +6186,12 @@ void GameHandler::handlePacket(network::Packet& packet) { std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); addSystemChatMessage(buf); } + // Add dispel event to combat log + if (firstDispelledId != 0) { + bool isPlayerCaster = (casterGuid == playerGuid); + addCombatText(CombatTextEntry::DISPEL, 0, firstDispelledId, isPlayerCaster, 0, + casterGuid, victimGuid); + } } packet.setReadPos(packet.getSize()); break; @@ -6198,7 +6206,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - /*uint64_t stealVictim =*/ stealTbcLike + uint64_t stealVictim = stealTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; @@ -6211,22 +6219,33 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t stealSpellId =*/ packet.readUInt32(); /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); - // Show feedback only when we are the caster (we stole something) - if (stealCaster == playerGuid) { - std::string stolenName; - for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { - uint32_t stolenId = packet.readUInt32(); - /*uint8_t isPos =*/ packet.readUInt8(); - if (i == 0) { - const std::string& nm = getSpellName(stolenId); - stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; - } + // Collect stolen spell info; show feedback when we are caster or victim + uint32_t firstStolenId = 0; + std::string stolenName; + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + uint32_t stolenId = packet.readUInt32(); + /*uint8_t isPos =*/ packet.readUInt8(); + if (i == 0) { + firstStolenId = stolenId; + const std::string& nm = getSpellName(stolenId); + stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; } + } + if (stealCaster == playerGuid || stealVictim == playerGuid) { if (!stolenName.empty()) { char buf[256]; - std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + if (stealCaster == playerGuid) + std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s was stolen.", stolenName.c_str()); addSystemChatMessage(buf); } + // Add dispel/steal to combat log using DISPEL type (isStolen=true for steals) + if (firstStolenId != 0) { + bool isPlayerCaster = (stealCaster == playerGuid); + addCombatText(CombatTextEntry::DISPEL, 0, firstStolenId, isPlayerCaster, 0, + stealCaster, stealVictim); + } } packet.setReadPos(packet.getSize()); break; @@ -6383,6 +6402,10 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t icSpellId = packet.readUInt32(); // Clear the interrupted unit's cast bar immediately unitCastStates_.erase(icTarget); + // Record interrupt in combat log when player is involved + if (isPlayerCaster || icTarget == playerGuid) + addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0, + exeCaster, icTarget); LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, " interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2c4fff48..2f30ee64 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20265,6 +20265,28 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "Proc triggered"); color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); break; + case T::DISPEL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); + color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); + break; + case T::INTERRUPT: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s", tgt); + else + snprintf(desc, sizeof(desc), "%s interrupted", tgt); + color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); + break; default: snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);