From 6af9f90f458fbc99d8a7ce83e9e1551567ecd15b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:22:55 -0700 Subject: [PATCH 01/31] ignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 4ddf59ab..013f805b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,10 @@ node_modules/ # Python cache artifacts tools/__pycache__/ *.pyc + +# artifacts +.codex-loop/ + +# Local agent instructions +AGENTS.md +codex-loop.sh From db681ec4c62cf57aeb45cd18b9be9762d16aed5a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:28:22 -0700 Subject: [PATCH 02/31] fix(combatlog): target drain and leech self-gain events correctly --- 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 13e6171b..b49e8c66 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6344,7 +6344,7 @@ void GameHandler::handlePacket(network::Packet& packet) { exeCaster, drainTarget); else if (isPlayerCaster) addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, - static_cast(drainPower), exeCaster, drainTarget); + static_cast(drainPower), exeCaster, exeCaster); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, " power=", drainPower, " amount=", drainAmount); @@ -6365,7 +6365,7 @@ void GameHandler::handlePacket(network::Packet& packet) { exeCaster, leechTarget); else if (isPlayerCaster) addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true, 0, - exeCaster, leechTarget); + exeCaster, exeCaster); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); } From 23023dc140c8c101b5f94228f99e023be0f3e955 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:36:42 -0700 Subject: [PATCH 03/31] fix(combatlog): keep spell-go miss metadata --- 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 b49e8c66..c5dff0ef 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16862,8 +16862,9 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Clear unit cast bar when the spell lands (for any tracked unit) unitCastStates_.erase(data.casterUnit); - // Show miss/dodge/parry/etc combat text when player's spells miss targets - if (data.casterUnit == playerGuid && !data.missTargets.empty()) { + // Preserve spellId and actual participants for spell-go miss results. + // This keeps the persistent combat log aligned with the later GUID fixes. + if (!data.missTargets.empty()) { static const CombatTextEntry::Type missTypes[] = { CombatTextEntry::MISS, // 0=MISS CombatTextEntry::DODGE, // 1=DODGE @@ -16875,10 +16876,15 @@ void GameHandler::handleSpellGo(network::Packet& packet) { CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; - // Show text for each miss (usually just 1 target per spell go) + const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; + const bool playerIsCaster = (spellCasterGuid == playerGuid); + for (const auto& m : data.missTargets) { + if (!playerIsCaster && m.targetGuid != playerGuid) { + continue; + } CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; - addCombatText(ct, 0, 0, true); + addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } From 3fa495d9ea5bf0376ecb59220da76c607dae9149 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:43:50 -0700 Subject: [PATCH 04/31] fix(combatlog): preserve spell ids in spell miss events --- src/game/game_handler.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c5dff0ef..d7a81bda 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2656,7 +2656,7 @@ void GameHandler::handlePacket(network::Packet& packet) { }; // spellId prefix present in all expansions if (packet.getSize() - packet.getReadPos() < 4) break; - /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t spellId = packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) 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, 0, casterGuid, victimGuid); + addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) - addCombatText(ct, 0, 0, false, 0, casterGuid, victimGuid); + addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid); } } break; From a48eab43b87b01e4053f24f01d81074097bf0d48 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:51:21 -0700 Subject: [PATCH 05/31] fix(combatlog): show resist entries for resist log packets --- src/game/game_handler.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d7a81bda..7fc8fb55 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6860,11 +6860,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(); - // Show RESIST when player is the victim; show as caster-side MISS when player is attacker + // Show RESIST when the player is involved on either side. if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, false, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, attackerGuid, victimGuid); } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, true, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; From c45951b368a18d565929774d19c60019866fc29e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:58:37 -0700 Subject: [PATCH 06/31] fix(combatlog): distinguish spellsteal from dispel --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 4 ++-- src/ui/game_screen.cpp | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index a3944a0e..92deadd7 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -53,7 +53,7 @@ struct CombatTextEntry { 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, - DISPEL, INTERRUPT + DISPEL, STEAL, INTERRUPT }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7fc8fb55..d9bc93c5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6240,10 +6240,10 @@ void GameHandler::handlePacket(network::Packet& packet) { 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) + // Preserve spellsteal as a distinct event so the UI wording stays accurate. if (firstStolenId != 0) { bool isPlayerCaster = (stealCaster == playerGuid); - addCombatText(CombatTextEntry::DISPEL, 0, firstStolenId, isPlayerCaster, 0, + addCombatText(CombatTextEntry::STEAL, 0, firstStolenId, isPlayerCaster, 0, stealCaster, stealVictim); } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2f30ee64..228b1b70 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8397,6 +8397,14 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc break; } + case game::CombatTextEntry::DISPEL: + snprintf(text, sizeof(text), "Dispel"); + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + snprintf(text, sizeof(text), "Spellsteal"); + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -20276,6 +20284,17 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); break; + case T::STEAL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; case T::INTERRUPT: if (spell && e.isPlayerSource) snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); From 98c195fb8eecb513f7576db1f07814e5bd432342 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:06:39 -0700 Subject: [PATCH 07/31] fix(combatlog): preserve spellsteal in dispel log handler --- src/game/game_handler.cpp | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d9bc93c5..dc456389 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6175,21 +6175,30 @@ void GameHandler::handlePacket(network::Packet& packet) { } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { - const char* verb = isStolen ? "stolen" : "dispelled"; if (!firstSpellName.empty()) { char buf[256]; - if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); - else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); - else - std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); + if (isStolen) { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s was stolen.", firstSpellName.c_str()); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You steal %s.", firstSpellName.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s was stolen.", firstSpellName.c_str()); + } else { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s was dispelled.", firstSpellName.c_str()); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You dispel %s.", firstSpellName.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s was dispelled.", firstSpellName.c_str()); + } addSystemChatMessage(buf); } - // Add dispel event to combat log + // Preserve stolen auras as spellsteal events so the log wording stays accurate. if (firstDispelledId != 0) { bool isPlayerCaster = (casterGuid == playerGuid); - addCombatText(CombatTextEntry::DISPEL, 0, firstDispelledId, isPlayerCaster, 0, + addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, + 0, firstDispelledId, isPlayerCaster, 0, casterGuid, victimGuid); } } From 3edf280e06cec4f885ec68ced78ab153ed23b9dc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:14:02 -0700 Subject: [PATCH 08/31] fix(combatlog): log all dispelled and stolen auras --- src/game/game_handler.cpp | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index dc456389..03600d42 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6160,15 +6160,18 @@ 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; + // Preserve every dispelled aura in the combat log instead of collapsing + // multi-aura packets down to the first entry only. + std::vector dispelledIds; + dispelledIds.reserve(count); 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 (dispelledId != 0) { + dispelledIds.push_back(dispelledId); + } if (i == 0) { - firstDispelledId = dispelledId; const std::string& nm = getSpellName(dispelledId); firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; } @@ -6195,11 +6198,13 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage(buf); } // Preserve stolen auras as spellsteal events so the log wording stays accurate. - if (firstDispelledId != 0) { + if (!dispelledIds.empty()) { bool isPlayerCaster = (casterGuid == playerGuid); - addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, - 0, firstDispelledId, isPlayerCaster, 0, - casterGuid, victimGuid); + for (uint32_t dispelledId : dispelledIds) { + addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, + 0, dispelledId, isPlayerCaster, 0, + casterGuid, victimGuid); + } } } packet.setReadPos(packet.getSize()); @@ -6228,14 +6233,17 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t stealSpellId =*/ packet.readUInt32(); /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); - // Collect stolen spell info; show feedback when we are caster or victim - uint32_t firstStolenId = 0; + // Preserve every stolen aura in the combat log instead of only the first. + std::vector stolenIds; + stolenIds.reserve(stealCount); 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 (stolenId != 0) { + stolenIds.push_back(stolenId); + } if (i == 0) { - firstStolenId = stolenId; const std::string& nm = getSpellName(stolenId); stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; } @@ -6250,10 +6258,12 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage(buf); } // Preserve spellsteal as a distinct event so the UI wording stays accurate. - if (firstStolenId != 0) { + if (!stolenIds.empty()) { bool isPlayerCaster = (stealCaster == playerGuid); - addCombatText(CombatTextEntry::STEAL, 0, firstStolenId, isPlayerCaster, 0, - stealCaster, stealVictim); + for (uint32_t stolenId : stolenIds) { + addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, + stealCaster, stealVictim); + } } } packet.setReadPos(packet.getSize()); From 91dc45d19efebd215f6bff212330010f6a5182d2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:23:24 -0700 Subject: [PATCH 09/31] fix(combatlog): dedupe duplicate spellsteal aura logs --- include/game/game_handler.hpp | 9 ++++ src/game/game_handler.cpp | 98 ++++++++++++++++++++++++++--------- 2 files changed, 82 insertions(+), 25 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1f04eb22..7ce07326 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2369,6 +2369,7 @@ private: 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); + bool shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId); void addSystemChatMessage(const std::string& message); /** @@ -2575,7 +2576,15 @@ private: std::unordered_set hostileAttackers_; std::vector combatText; static constexpr size_t MAX_COMBAT_LOG = 500; + struct RecentSpellstealLogEntry { + uint64_t casterGuid = 0; + uint64_t victimGuid = 0; + uint32_t spellId = 0; + std::chrono::steady_clock::time_point timestamp{}; + }; + static constexpr size_t MAX_RECENT_SPELLSTEAL_LOGS = 32; std::deque combatLog_; + std::deque recentSpellstealLogs_; std::deque areaTriggerMsgs_; // unitGuid → sorted threat list (descending by threat value) std::unordered_map> threatLists_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 03600d42..1a7bf71d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6164,43 +6164,56 @@ void GameHandler::handlePacket(network::Packet& packet) { // multi-aura packets down to the first entry only. std::vector dispelledIds; dispelledIds.reserve(count); - 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 (dispelledId != 0) { dispelledIds.push_back(dispelledId); } - if (i == 0) { - 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) { - if (!firstSpellName.empty()) { + std::vector loggedIds; + if (isStolen) { + loggedIds.reserve(dispelledIds.size()); + for (uint32_t dispelledId : dispelledIds) { + if (shouldLogSpellstealAura(casterGuid, victimGuid, dispelledId)) + loggedIds.push_back(dispelledId); + } + } else { + loggedIds = dispelledIds; + } + + const uint32_t displaySpellId = !loggedIds.empty() ? loggedIds.front() : 0; + const std::string displaySpellName = displaySpellId != 0 + ? [&]() { + const std::string& nm = getSpellName(displaySpellId); + return nm.empty() ? ("spell " + std::to_string(displaySpellId)) : nm; + }() + : std::string{}; + if (!displaySpellName.empty()) { char buf[256]; if (isStolen) { if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was stolen.", firstSpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s was stolen.", displaySpellName.c_str()); else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You steal %s.", firstSpellName.c_str()); + std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellName.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was stolen.", firstSpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s was stolen.", displaySpellName.c_str()); } else { if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was dispelled.", firstSpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s was dispelled.", displaySpellName.c_str()); else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You dispel %s.", firstSpellName.c_str()); + std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellName.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was dispelled.", firstSpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s was dispelled.", displaySpellName.c_str()); } addSystemChatMessage(buf); } // Preserve stolen auras as spellsteal events so the log wording stays accurate. - if (!dispelledIds.empty()) { + if (!loggedIds.empty()) { bool isPlayerCaster = (casterGuid == playerGuid); - for (uint32_t dispelledId : dispelledIds) { + for (uint32_t dispelledId : loggedIds) { addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, 0, dispelledId, isPlayerCaster, 0, casterGuid, victimGuid); @@ -6236,31 +6249,41 @@ void GameHandler::handlePacket(network::Packet& packet) { // Preserve every stolen aura in the combat log instead of only the first. std::vector stolenIds; stolenIds.reserve(stealCount); - 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 (stolenId != 0) { stolenIds.push_back(stolenId); } - if (i == 0) { - const std::string& nm = getSpellName(stolenId); - stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; - } } if (stealCaster == playerGuid || stealVictim == playerGuid) { - if (!stolenName.empty()) { + std::vector loggedIds; + loggedIds.reserve(stolenIds.size()); + for (uint32_t stolenId : stolenIds) { + if (shouldLogSpellstealAura(stealCaster, stealVictim, stolenId)) + loggedIds.push_back(stolenId); + } + + const uint32_t displaySpellId = !loggedIds.empty() ? loggedIds.front() : 0; + const std::string displaySpellName = displaySpellId != 0 + ? [&]() { + const std::string& nm = getSpellName(displaySpellId); + return nm.empty() ? ("spell " + std::to_string(displaySpellId)) : nm; + }() + : std::string{}; + if (!displaySpellName.empty()) { char buf[256]; if (stealCaster == playerGuid) - std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellName.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was stolen.", stolenName.c_str()); + std::snprintf(buf, sizeof(buf), "%s was stolen.", displaySpellName.c_str()); addSystemChatMessage(buf); } - // Preserve spellsteal as a distinct event so the UI wording stays accurate. - if (!stolenIds.empty()) { + // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG + // for the same aura. Keep the first event and suppress the duplicate. + if (!loggedIds.empty()) { bool isPlayerCaster = (stealCaster == playerGuid); - for (uint32_t stolenId : stolenIds) { + for (uint32_t stolenId : loggedIds) { addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, stealCaster, stealVictim); } @@ -14080,6 +14103,31 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint combatLog_.push_back(std::move(log)); } +bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { + if (spellId == 0) return false; + + const auto now = std::chrono::steady_clock::now(); + constexpr auto kRecentWindow = std::chrono::seconds(1); + while (!recentSpellstealLogs_.empty() && + now - recentSpellstealLogs_.front().timestamp > kRecentWindow) { + recentSpellstealLogs_.pop_front(); + } + + for (auto it = recentSpellstealLogs_.begin(); it != recentSpellstealLogs_.end(); ++it) { + if (it->casterGuid == casterGuid && + it->victimGuid == victimGuid && + it->spellId == spellId) { + recentSpellstealLogs_.erase(it); + return false; + } + } + + if (recentSpellstealLogs_.size() >= MAX_RECENT_SPELLSTEAL_LOGS) + recentSpellstealLogs_.pop_front(); + recentSpellstealLogs_.push_back({casterGuid, victimGuid, spellId, now}); + return true; +} + void GameHandler::updateCombatText(float deltaTime) { for (auto& entry : combatText) { entry.age += deltaTime; From 1214369755f25b0b33c5e595a6064d05a8b86495 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:30:39 -0700 Subject: [PATCH 10/31] fix(combatlog): log outgoing proc resist events --- 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 1a7bf71d..1299453b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2066,8 +2066,11 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); - if (victim == playerGuid) + if (victim == playerGuid) { addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); + } else if (caster == playerGuid) { + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); + } packet.setReadPos(packet.getSize()); break; } From d61bb036a754c86b8168f3569b94b48239063bf9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:38:59 -0700 Subject: [PATCH 11/31] fix(combatlog): parse classic dispel and steal aura entries correctly --- src/game/game_handler.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1299453b..535b2dea 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6165,11 +6165,16 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t count = packet.readUInt32(); // Preserve every dispelled aura in the combat log instead of collapsing // multi-aura packets down to the first entry only. + const size_t dispelEntrySize = dispelTbcLike ? 8u : 5u; std::vector dispelledIds; dispelledIds.reserve(count); - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) { uint32_t dispelledId = packet.readUInt32(); - /*uint8_t isPositive =*/ packet.readUInt8(); + if (dispelTbcLike) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPositive =*/ packet.readUInt8(); + } if (dispelledId != 0) { dispelledIds.push_back(dispelledId); } @@ -6250,11 +6255,16 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); // Preserve every stolen aura in the combat log instead of only the first. + const size_t stealEntrySize = stealTbcLike ? 8u : 5u; std::vector stolenIds; stolenIds.reserve(stealCount); - for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) { uint32_t stolenId = packet.readUInt32(); - /*uint8_t isPos =*/ packet.readUInt8(); + if (stealTbcLike) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPos =*/ packet.readUInt8(); + } if (stolenId != 0) { stolenIds.push_back(stolenId); } From f09913d6d20c8ad3731cf191a32d01dcb336138c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:45:26 -0700 Subject: [PATCH 12/31] fix(combatlog): render interrupt floating combat text --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 228b1b70..28aa882c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8405,6 +8405,15 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Spellsteal"); color = ImVec4(0.8f, 0.7f, 1.0f, alpha); break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); From c5e7dde93167924f98b22c55c908441ac0209023 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 20:52:34 -0700 Subject: [PATCH 13/31] fix(combatlog): parse proc log GUIDs in classic and tbc --- src/game/game_handler.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 535b2dea..186781b0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6306,15 +6306,22 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { - // Format (all expansions): PackedGuid target + PackedGuid caster + uint32 spellId + ... - if (packet.getSize() - packet.getReadPos() < 3) { + // WotLK: packed_guid target + packed_guid caster + uint32 spellId + ... + // TBC/Classic: uint64 target + uint64 caster + uint32 spellId + ... + const bool procChanceTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto readProcChanceGuid = [&]() -> uint64_t { + if (procChanceTbcLike) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (procChanceTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t procTargetGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) { + uint64_t procTargetGuid = readProcChanceGuid(); + if (packet.getSize() - packet.getReadPos() < (procChanceTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t procCasterGuid = UpdateObjectParser::readPackedGuid(packet); + uint64_t procCasterGuid = readProcChanceGuid(); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } From d4d876a563526338ea4a8bb062825e795ebfd66a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 21:00:34 -0700 Subject: [PATCH 14/31] fix(combatlog): list all dispelled and stolen auras in system messages --- src/game/game_handler.cpp | 76 ++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 186781b0..c639bd52 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -123,6 +123,40 @@ std::string formatCopperAmount(uint32_t amount) { return oss.str(); } +std::string displaySpellName(GameHandler& handler, uint32_t spellId) { + if (spellId == 0) return {}; + const std::string& name = handler.getSpellName(spellId); + if (!name.empty()) return name; + return "spell " + std::to_string(spellId); +} + +std::string formatSpellNameList(GameHandler& handler, + const std::vector& spellIds, + size_t maxShown = 3) { + if (spellIds.empty()) return {}; + + const size_t shownCount = std::min(spellIds.size(), maxShown); + std::ostringstream oss; + for (size_t i = 0; i < shownCount; ++i) { + if (i > 0) { + if (shownCount == 2) { + oss << " and "; + } else if (i == shownCount - 1) { + oss << ", and "; + } else { + oss << ", "; + } + } + oss << displaySpellName(handler, spellIds[i]); + } + + if (spellIds.size() > shownCount) { + oss << ", and " << (spellIds.size() - shownCount) << " more"; + } + + return oss.str(); +} + bool readCStringAt(const std::vector& data, size_t start, std::string& out, size_t& nextPos) { out.clear(); if (start >= data.size()) return false; @@ -6192,29 +6226,28 @@ void GameHandler::handlePacket(network::Packet& packet) { loggedIds = dispelledIds; } - const uint32_t displaySpellId = !loggedIds.empty() ? loggedIds.front() : 0; - const std::string displaySpellName = displaySpellId != 0 - ? [&]() { - const std::string& nm = getSpellName(displaySpellId); - return nm.empty() ? ("spell " + std::to_string(displaySpellId)) : nm; - }() - : std::string{}; - if (!displaySpellName.empty()) { + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; + const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were"; if (isStolen) { if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was stolen.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was stolen.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); } else { if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was dispelled.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was dispelled.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); } addSystemChatMessage(buf); } @@ -6277,19 +6310,14 @@ void GameHandler::handlePacket(network::Packet& packet) { loggedIds.push_back(stolenId); } - const uint32_t displaySpellId = !loggedIds.empty() ? loggedIds.front() : 0; - const std::string displaySpellName = displaySpellId != 0 - ? [&]() { - const std::string& nm = getSpellName(displaySpellId); - return nm.empty() ? ("spell " + std::to_string(displaySpellId)) : nm; - }() - : std::string{}; - if (!displaySpellName.empty()) { + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; if (stealCaster == playerGuid) - std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellNames.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was stolen.", displaySpellName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), + loggedIds.size() == 1 ? "was" : "were"); addSystemChatMessage(buf); } // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG From 16c8a2fd33ee664b9c7fbbcdfc3e29d7daa01cd8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 21:08:00 -0700 Subject: [PATCH 15/31] fix(combatlog): parse packed spell-go hit target GUIDs --- src/game/world_packets.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index dbcbf4c9..b135fd6b 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3660,12 +3660,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.reserve(data.hitCount); for (uint8_t i = 0; i < data.hitCount; ++i) { - if (packet.getSize() - packet.getReadPos() < 8) { + // WotLK hit targets are packed GUIDs, like the caster and miss targets. + if (packet.getSize() - packet.getReadPos() < 1) { LOG_WARNING("Spell go: truncated hit targets at index ", (int)i, "/", (int)data.hitCount); data.hitCount = i; break; } - data.hitTargets.push_back(packet.readUInt64()); + data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); } // Validate missCount field exists From cf68c156f148702340566d4fe06fbf7d40948bb5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 21:16:24 -0700 Subject: [PATCH 16/31] fix(combatlog): accept short packed spell-go packets --- src/game/world_packets.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index b135fd6b..64ae5e00 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3632,8 +3632,9 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { } bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { - // Upfront validation: packed GUID(1-8) + packed GUID(1-8) + castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1) + missCount(1) = 24 bytes minimum - if (packet.getSize() - packet.getReadPos() < 24) return false; + // Packed GUIDs are variable-length, so only require the smallest possible + // shape up front: 2 GUID masks + fixed fields through missCount. + if (packet.getSize() - packet.getReadPos() < 17) return false; size_t startPos = packet.getReadPos(); data.casterGuid = UpdateObjectParser::readPackedGuid(packet); From 5be55b1b14e0c2bbf2933da6401a2ff0bb6f13e2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 22:14:04 -0700 Subject: [PATCH 17/31] fix(combatlog): validate full TBC spell-go header --- src/game/packet_parsers_tbc.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 935b34ae..83e2511c 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1261,7 +1261,9 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& // WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags. // ============================================================================ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { - if (packet.getSize() - packet.getReadPos() < 19) return false; + // Fixed header before hit/miss lists: + // casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + if (packet.getSize() - packet.getReadPos() < 25) return false; data.casterGuid = packet.readUInt64(); // full GUID in TBC data.casterUnit = packet.readUInt64(); // full GUID in TBC From 3ef5b546fb14b0010cafeb72f45eda0378b72b1d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 22:22:00 -0700 Subject: [PATCH 18/31] fix(combatlog): render instakill events explicitly --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 6 ++---- src/ui/game_screen.cpp | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 92deadd7..5ddfaa04 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -53,7 +53,7 @@ struct CombatTextEntry { 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, - DISPEL, STEAL, INTERRUPT + DISPEL, STEAL, INTERRUPT, INSTAKILL }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c639bd52..b266de02 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6376,11 +6376,9 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; // 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, 0, ikCaster, ikVictim); + addCombatText(CombatTextEntry::INSTAKILL, 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, 0, ikCaster, ikVictim); + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 28aa882c..53e615bf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8414,6 +8414,11 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = ImVec4(1.0f, 0.6f, 0.9f, alpha); break; } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -20315,6 +20320,17 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s interrupted", tgt); color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); break; + case T::INSTAKILL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s", tgt); + else + snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); + color = ImVec4(1.0f, 0.2f, 0.2f, 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); From 21762485ea0691a0ccff1ac788c8a3fc6bfa3a45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 22:30:25 -0700 Subject: [PATCH 19/31] fix(combatlog): guard truncated spell energize packets --- src/game/game_handler.cpp | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b266de02..ca2b3262 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4012,14 +4012,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount // Classic/Vanilla: packed_guid (same as WotLK) const bool energizeTbc = isActiveExpansion("tbc"); - size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - rem = packet.getSize() - packet.getReadPos(); - if (rem < 6) { packet.setReadPos(packet.getSize()); break; } + auto readEnergizeGuid = [&]() -> uint64_t { + if (energizeTbc) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } uint32_t spellId = packet.readUInt32(); uint8_t energizePowerType = packet.readUInt8(); int32_t amount = static_cast(packet.readUInt32()); From 46b297aacc5eaebb42f08b5d6e3e32933c7b0b9b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 22:38:35 -0700 Subject: [PATCH 20/31] fix(combatlog): consume reflect payload in spell miss logs --- 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 ca2b3262..e6c0b520 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2682,9 +2682,10 @@ void GameHandler::handlePacket(network::Packet& packet) { // All expansions: uint32 spellId first. // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count // + count × (packed_guid victim + uint8 missInfo) - // [missInfo==11(REFLECT): + uint32 reflectSpellId + uint8 reflectResult] // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count // + count × (uint64 victim + uint8 missInfo) + // All expansions append uint32 reflectSpellId + uint8 reflectResult when + // missInfo==11 (REFLECT). const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { if (spellMissTbcLike) @@ -2706,7 +2707,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult - if (missInfo == 11 && !spellMissTbcLike) { + if (missInfo == 11) { if (packet.getSize() - packet.getReadPos() >= 5) { /*uint32_t reflectSpellId =*/ packet.readUInt32(); /*uint8_t reflectResult =*/ packet.readUInt8(); From 3e4708fe156531b8117d9ea56371a9f32beb367a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 22:45:59 -0700 Subject: [PATCH 21/31] fix(combatlog): parse classic spell miss GUIDs as packed --- src/game/game_handler.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e6c0b520..3ab47010 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2680,29 +2680,29 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { // All expansions: uint32 spellId first. - // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count - // + count × (packed_guid victim + uint8 missInfo) - // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count - // + count × (uint64 victim + uint8 missInfo) + // WotLK/Classic: spellId(4) + packed_guid caster + uint8 unk + uint32 count + // + count × (packed_guid victim + uint8 missInfo) + // TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count + // + count × (uint64 victim + uint8 missInfo) // All expansions append uint32 reflectSpellId + uint8 reflectResult when // missInfo==11 (REFLECT). - const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const bool spellMissUsesFullGuid = isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { - if (spellMissTbcLike) + if (spellMissUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; // spellId prefix present in all expansions if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u)) break; uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u)) break; uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); From 5392243575936b8e0c7f4b2f12dcf8a6d4f1df48 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 22:53:04 -0700 Subject: [PATCH 22/31] fix(combatlog): stop treating spell break logs as damage shield hits --- src/game/game_handler.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3ab47010..475c149b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6133,8 +6133,6 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Spell combat logs (consume) ---- - case Opcode::SMSG_AURACASTLOG: - case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { // Classic/TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) @@ -6165,6 +6163,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_AURACASTLOG: + case Opcode::SMSG_SPELLBREAKLOG: + // These packets are not damage-shield events. Consume them without + // synthesizing reflected damage entries or misattributing GUIDs. + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 From dceaf8f1ac40776fbb8de2c9d5ecc29a3d4b5b78 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:00:49 -0700 Subject: [PATCH 23/31] fix(combattext): show aura names for dispel and spellsteal --- src/ui/game_screen.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 53e615bf..9d0eafe8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8398,11 +8398,27 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { break; } case game::CombatTextEntry::DISPEL: - snprintf(text, sizeof(text), "Dispel"); + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else + snprintf(text, sizeof(text), "Dispel"); + } else { + snprintf(text, sizeof(text), "Dispel"); + } color = ImVec4(0.6f, 0.9f, 1.0f, alpha); break; case game::CombatTextEntry::STEAL: - snprintf(text, sizeof(text), "Spellsteal"); + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } color = ImVec4(0.8f, 0.7f, 1.0f, alpha); break; case game::CombatTextEntry::INTERRUPT: { From 77d53baa0973a7f389cbeb089093d2602e723456 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:08:49 -0700 Subject: [PATCH 24/31] fix(combattext): render deflect and reflect miss events --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 12 +++++++----- src/ui/game_screen.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 5ddfaa04..cb90a3e2 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -52,7 +52,7 @@ 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, DEFLECT, REFLECT, PROC_TRIGGER, DISPEL, STEAL, INTERRUPT, INSTAKILL }; Type type; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 475c149b..9667c384 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2723,11 +2723,12 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::BLOCK, // 3=BLOCK CombatTextEntry::MISS, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; + CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] + : (missInfo == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); if (casterGuid == playerGuid) { // We cast a spell and it missed the target addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid); @@ -16386,7 +16387,7 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { 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, 0, data.attackerGuid, data.targetGuid); + addCombatText(CombatTextEntry::DEFLECT, 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, 0, data.attackerGuid, data.targetGuid); @@ -16998,7 +16999,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) { CombatTextEntry::BLOCK, // 3=BLOCK CombatTextEntry::MISS, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; @@ -17009,7 +17010,8 @@ void GameHandler::handleSpellGo(network::Packet& packet) { if (!playerIsCaster && m.targetGuid != playerGuid) { continue; } - CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; + CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] + : (m.missType == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9d0eafe8..f92be623 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8388,6 +8388,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: + snprintf(text, sizeof(text), outgoing ? "Reflect" : "Reflected"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; case game::CombatTextEntry::PROC_TRIGGER: { const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; if (!procName.empty()) @@ -20281,6 +20291,20 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "Resisted"); color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); break; + case T::DEFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src); + color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f); + break; + case T::REFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; case T::ENVIRONMENTAL: snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); From 3f1083e9b5b8c377be19560f6cab2f597ac18909 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:15:56 -0700 Subject: [PATCH 25/31] fix(combatlog): consume reflect payload in spell-go miss entries --- include/game/world_packets.hpp | 2 +- src/game/packet_parsers_classic.cpp | 5 +++++ src/game/packet_parsers_tbc.cpp | 7 +++++++ src/game/world_packets.cpp | 12 +++++++++++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index d864b57e..f29eecb7 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1858,7 +1858,7 @@ public: /** SMSG_SPELL_GO data (simplified) */ struct SpellGoMissEntry { uint64_t targetGuid = 0; - uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST + uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST 11=REFLECT }; struct SpellGoData { diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 041af211..7077d0ab 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -421,6 +421,11 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da m.targetGuid = UpdateObjectParser::readPackedGuid(packet); if (rem() < 1) break; m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (rem() < 5) break; + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } // Check if we read all expected misses diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 83e2511c..d218926a 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1306,6 +1306,13 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) SpellGoMissEntry m; m.targetGuid = packet.readUInt64(); // full GUID in TBC m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (packet.getReadPos() + 5 > packet.getSize()) { + break; + } + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } // Check if we read all expected misses diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 64ae5e00..4c0a0b1e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3684,7 +3684,8 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.reserve(data.missCount); for (uint8_t i = 0; i < data.missCount; ++i) { - // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte), validate before reading + // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). + // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. if (packet.getSize() - packet.getReadPos() < 2) { LOG_WARNING("Spell go: truncated miss targets at index ", (int)i, "/", (int)data.missCount); data.missCount = i; @@ -3693,6 +3694,15 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK m.missType = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + if (m.missType == 11) { + if (packet.getSize() - packet.getReadPos() < 5) { + LOG_WARNING("Spell go: truncated reflect payload at miss index ", (int)i, "/", (int)data.missCount); + data.missCount = i; + break; + } + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } From 6095170167f05aab95535ad62f186d51492db184 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:24:09 -0700 Subject: [PATCH 26/31] fix(combattext): correct reflect miss floating text --- src/ui/game_screen.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f92be623..e51d0eb9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8394,7 +8394,7 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { : ImVec4(0.5f, 0.9f, 1.0f, alpha); break; case game::CombatTextEntry::REFLECT: - snprintf(text, sizeof(text), outgoing ? "Reflect" : "Reflected"); + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) : ImVec4(0.75f, 0.85f, 1.0f, alpha); break; From 57265bfa4f2eef08b404cc8136c82edf90797d40 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:32:57 -0700 Subject: [PATCH 27/31] fix(combattext): render evade results explicitly --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 8 ++++---- src/ui/game_screen.cpp | 12 ++++++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index cb90a3e2..7b1ee689 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,7 +51,7 @@ struct ActionBarSlot { struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, - CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, + EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, DISPEL, STEAL, INTERRUPT, INSTAKILL }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9667c384..0c229108 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2721,7 +2721,7 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::EVADE, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB @@ -16380,8 +16380,8 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { 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, 0, data.attackerGuid, data.targetGuid); + // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). + addCombatText(CombatTextEntry::EVADE, 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, 0, data.attackerGuid, data.targetGuid); @@ -16997,7 +16997,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::EVADE, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e51d0eb9..8807afe4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8342,6 +8342,11 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; case game::CombatTextEntry::PERIODIC_DAMAGE: snprintf(text, sizeof(text), "-%d", entry.amount); color = outgoing ? @@ -20262,6 +20267,13 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { 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::EVADE: + if (spell) + snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; case T::IMMUNE: if (spell) snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); From 842771cb1014fb50e0250b4e934d46a4e44929ff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:40:39 -0700 Subject: [PATCH 28/31] fix(combatlog): validate tbc spelllogexecute effect GUIDs --- src/game/game_handler.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0c229108..5d3dbec4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6432,9 +6432,12 @@ void GameHandler::handlePacket(network::Packet& packet) { if (effectType == 10) { // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; + const size_t guidBytes = exeTbcLike ? 8u : 1u; + if (packet.getSize() - packet.getReadPos() < guidBytes) { + packet.setReadPos(packet.getSize()); break; + } uint64_t drainTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint32_t drainAmount = packet.readUInt32(); @@ -6454,9 +6457,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (effectType == 11) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; + const size_t guidBytes = exeTbcLike ? 8u : 1u; + if (packet.getSize() - packet.getReadPos() < guidBytes) { + packet.setReadPos(packet.getSize()); break; + } uint64_t leechTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } uint32_t leechAmount = packet.readUInt32(); @@ -6496,9 +6502,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (effectType == 26) { // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; + const size_t guidBytes = exeTbcLike ? 8u : 1u; + if (packet.getSize() - packet.getReadPos() < guidBytes) { + packet.setReadPos(packet.getSize()); break; + } uint64_t icTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t icSpellId = packet.readUInt32(); From e9d2c431916a710b3cff406718823b0cbc0699c3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:47:57 -0700 Subject: [PATCH 29/31] fix(combatlog): validate classic spelllogexecute packed GUIDs --- src/game/game_handler.cpp | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5d3dbec4..77e76d58 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6411,9 +6411,26 @@ void GameHandler::handlePacket(network::Packet& packet) { // Effect 49 = FEED_PET: uint32 itemEntry // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const auto hasFullPackedGuid = [&packet]() -> bool { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; + }; if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + if (!exeTbcLike && !hasFullPackedGuid()) { + packet.setReadPos(packet.getSize()); break; + } uint64_t exeCaster = exeTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { @@ -6432,8 +6449,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (effectType == 10) { // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - const size_t guidBytes = exeTbcLike ? 8u : 1u; - if (packet.getSize() - packet.getReadPos() < guidBytes) { + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u) + || (!exeTbcLike && !hasFullPackedGuid())) { packet.setReadPos(packet.getSize()); break; } uint64_t drainTarget = exeTbcLike @@ -6457,8 +6474,8 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (effectType == 11) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - const size_t guidBytes = exeTbcLike ? 8u : 1u; - if (packet.getSize() - packet.getReadPos() < guidBytes) { + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u) + || (!exeTbcLike && !hasFullPackedGuid())) { packet.setReadPos(packet.getSize()); break; } uint64_t leechTarget = exeTbcLike @@ -6502,8 +6519,8 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (effectType == 26) { // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id for (uint32_t li = 0; li < effectLogCount; ++li) { - const size_t guidBytes = exeTbcLike ? 8u : 1u; - if (packet.getSize() - packet.getReadPos() < guidBytes) { + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u) + || (!exeTbcLike && !hasFullPackedGuid())) { packet.setReadPos(packet.getSize()); break; } uint64_t icTarget = exeTbcLike From 64483a31d5c191ba2ea7718389ddd321ce197a21 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 23:56:44 -0700 Subject: [PATCH 30/31] fix(combattext): show power drain separately from damage --- include/game/spell_defines.hpp | 4 ++-- src/game/game_handler.cpp | 10 +++++----- src/ui/game_screen.cpp | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 7b1ee689..ffaf6bb2 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -52,7 +52,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, + ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, DISPEL, STEAL, INTERRUPT, INSTAKILL }; Type type; @@ -60,7 +60,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 + uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 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 77e76d58..a79cdf2f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3993,13 +3993,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; - /*uint32_t powerType =*/ packet.readUInt32(); + uint8_t powerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) - // Show as periodic damage from victim's perspective (mana drained) if (isPlayerVictim && amount > 0) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), - spellId, false, 0, casterGuid, victimGuid); + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(amount), + spellId, false, powerType, casterGuid, victimGuid); } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -6462,7 +6461,8 @@ 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, 0, + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, + static_cast(drainPower), exeCaster, drainTarget); else if (isPlayerCaster) addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8807afe4..788150b6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8371,6 +8371,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue } break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; + } + break; case game::CombatTextEntry::XP_GAIN: snprintf(text, sizeof(text), "+%d XP", entry.amount); color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP @@ -20328,6 +20338,13 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; + case T::POWER_DRAIN: + if (spell) + snprintf(desc, sizeof(desc), "%s loses %d power to %s's %s", tgt, e.amount, src, spell); + else + snprintf(desc, sizeof(desc), "%s loses %d power", tgt, e.amount); + color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); + break; case T::XP_GAIN: snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); From 209f8db382cb4dcfc0daa13ae0e47e5bde3fe48a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:06:05 -0700 Subject: [PATCH 31/31] fix(combattext): honor power drain multipliers --- src/game/game_handler.cpp | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a79cdf2f..74e1782c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3995,10 +3995,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 12) break; uint8_t powerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); - /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) + float multiplier = packet.readFloat(); if (isPlayerVictim && amount > 0) addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(amount), spellId, false, powerType, casterGuid, victimGuid); + if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(amount) * static_cast(multiplier))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), + spellId, true, powerType, casterGuid, casterGuid); + } + } } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -6458,18 +6466,30 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint32_t drainAmount = packet.readUInt32(); uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic - /*float drainMult =*/ packet.readFloat(); + float drainMult = packet.readFloat(); if (drainAmount > 0) { if (drainTarget == playerGuid) addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, static_cast(drainPower), exeCaster, drainTarget); - else if (isPlayerCaster) - addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, - static_cast(drainPower), exeCaster, exeCaster); + if (isPlayerCaster) { + if (drainTarget != playerGuid) { + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, drainTarget); + } + if (drainMult > 0.0f && std::isfinite(drainMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(drainAmount) * static_cast(drainMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, exeCaster); + } + } + } } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, - " power=", drainPower, " amount=", drainAmount); + " power=", drainPower, " amount=", drainAmount, + " multiplier=", drainMult); } } else if (effectType == 11) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier