From 6af9f90f458fbc99d8a7ce83e9e1551567ecd15b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 19:22:55 -0700 Subject: [PATCH 01/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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 From a147347393a3fad09f76f7592ed0c9499b25b3af Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:16:28 -0700 Subject: [PATCH 32/59] fix(combattext): honor health leech multipliers --- src/game/game_handler.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 74e1782c..fa82fd09 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6503,16 +6503,26 @@ void GameHandler::handlePacket(network::Packet& packet) { : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } uint32_t leechAmount = packet.readUInt32(); - /*float leechMult =*/ packet.readFloat(); + float leechMult = packet.readFloat(); if (leechAmount > 0) { - if (leechTarget == playerGuid) + if (leechTarget == playerGuid) { addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, exeCaster, leechTarget); - else if (isPlayerCaster) - addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true, 0, - exeCaster, exeCaster); + } else if (isPlayerCaster) { + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, true, 0, + exeCaster, leechTarget); + } + if (isPlayerCaster && leechMult > 0.0f && std::isfinite(leechMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(leechAmount) * static_cast(leechMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::HEAL, static_cast(gainedAmount), exeSpellId, true, 0, + exeCaster, exeCaster); + } + } } - LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, + " amount=", leechAmount, " multiplier=", leechMult); } } else if (effectType == 24 || effectType == 114) { // SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry From ed5134d60115ceab56b5ffbf8682e10c192a8e94 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:24:21 -0700 Subject: [PATCH 33/59] fix(combatlog): parse classic spelllogexecute GUIDs as packed --- src/game/game_handler.cpp | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fa82fd09..ca3b453c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6408,8 +6408,8 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLLOGEXECUTE: { - // WotLK: packed_guid caster + uint32 spellId + uint32 effectCount - // TBC/Classic: uint64 caster + uint32 spellId + uint32 effectCount + // WotLK/Classic/Turtle: packed_guid caster + uint32 spellId + uint32 effectCount + // TBC: uint64 caster + uint32 spellId + uint32 effectCount // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data // Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier // Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier @@ -6417,7 +6417,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id // Effect 49 = FEED_PET: uint32 itemEntry // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) - const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const bool exeUsesFullGuid = isActiveExpansion("tbc"); const auto hasFullPackedGuid = [&packet]() -> bool { if (packet.getReadPos() >= packet.getSize()) { return false; @@ -6432,13 +6432,13 @@ void GameHandler::handlePacket(network::Packet& packet) { } return packet.getSize() - packet.getReadPos() >= guidBytes; }; - if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - if (!exeTbcLike && !hasFullPackedGuid()) { + if (!exeUsesFullGuid && !hasFullPackedGuid()) { packet.setReadPos(packet.getSize()); break; } - uint64_t exeCaster = exeTbcLike + uint64_t exeCaster = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; @@ -6456,11 +6456,11 @@ 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() < (exeTbcLike ? 8u : 1u) - || (!exeTbcLike && !hasFullPackedGuid())) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid())) { packet.setReadPos(packet.getSize()); break; } - uint64_t drainTarget = exeTbcLike + uint64_t drainTarget = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } @@ -6494,11 +6494,11 @@ 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() < (exeTbcLike ? 8u : 1u) - || (!exeTbcLike && !hasFullPackedGuid())) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid())) { packet.setReadPos(packet.getSize()); break; } - uint64_t leechTarget = exeTbcLike + uint64_t leechTarget = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } @@ -6549,11 +6549,11 @@ 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() < (exeTbcLike ? 8u : 1u) - || (!exeTbcLike && !hasFullPackedGuid())) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid())) { packet.setReadPos(packet.getSize()); break; } - uint64_t icTarget = exeTbcLike + uint64_t icTarget = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } From 9c3b5d17cf97aa7eeb540dd939b7b75874aa02f4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:31:35 -0700 Subject: [PATCH 34/59] fix(combatlog): parse classic resist log 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 ca3b453c..ff594ad6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7002,19 +7002,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { - // WotLK: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId - // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... - // TBC/Classic: same but full uint64 GUIDs + // WotLK/Classic/Turtle: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId + // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... + // TBC: same layout but full uint64 GUIDs // Show RESIST combat text when player resists an incoming spell. - const bool rlTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const bool rlUsesFullGuid = isActiveExpansion("tbc"); auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } /*uint32_t hitInfo =*/ packet.readUInt32(); - if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t attackerGuid = rlTbcLike + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t attackerGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = rlTbcLike + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); From 8ba5ca5337b5570ee75b9551277d7070cf3ca57c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:38:22 -0700 Subject: [PATCH 35/59] fix(combatlog): parse classic instakill log GUIDs as packed --- 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 ff594ad6..f7937079 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6384,15 +6384,15 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_SPELLINSTAKILLLOG: { // Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.) - // WotLK: packed_guid caster + packed_guid victim + uint32 spellId - // TBC/Classic: full uint64 caster + full uint64 victim + uint32 spellId - const bool ikTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + // TBC: full uint64 caster + full uint64 victim + uint32 spellId + const bool ikUsesFullGuid = isActiveExpansion("tbc"); auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t ikCaster = ikTbcLike + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t ikCaster = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t ikVictim = ikTbcLike + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t ikVictim = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; // Show kill/death feedback for the local player From fd8ea4e69e026b52b2ae00c19a41d17ff98988cd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:45:50 -0700 Subject: [PATCH 36/59] fix(combatlog): parse classic proc log GUIDs as packed --- src/game/game_handler.cpp | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f7937079..beba372d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2086,17 +2086,17 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { - // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + ... - // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + ... - const bool prTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + ... + // TBC: uint64 caster + uint64 victim + uint32 spellId + ... + const bool prUsesFullGuid = isActiveExpansion("tbc"); auto readPrGuid = [&]() -> uint64_t { - if (prTbcLike) + if (prUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)) break; uint64_t caster = readPrGuid(); - if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)) break; uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); @@ -6355,19 +6355,19 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { - // WotLK: packed_guid target + packed_guid caster + uint32 spellId + ... - // TBC/Classic: uint64 target + uint64 caster + uint32 spellId + ... - const bool procChanceTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ... + // TBC: uint64 target + uint64 caster + uint32 spellId + ... + const bool procChanceUsesFullGuid = isActiveExpansion("tbc"); auto readProcChanceGuid = [&]() -> uint64_t { - if (procChanceTbcLike) + if (procChanceUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (procChanceTbcLike ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } uint64_t procTargetGuid = readProcChanceGuid(); - if (packet.getSize() - packet.getReadPos() < (procChanceTbcLike ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } uint64_t procCasterGuid = readProcChanceGuid(); From 1fa2cbc64e1713d73387c9b0dd917f72d7cfe16d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 00:53:42 -0700 Subject: [PATCH 37/59] fix(combatlog): parse classic dispel and spellsteal GUIDs as packed --- src/game/game_handler.cpp | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index beba372d..a43e4c94 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6202,17 +6202,17 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLDISPELLOG: { - // WotLK: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen - // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + ... + // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // TBC: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) - const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) { + const bool dispelUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = dispelTbcLike + uint64_t casterGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) break; - uint64_t victimGuid = dispelTbcLike + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)) break; + uint64_t victimGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) break; /*uint32_t dispelSpell =*/ packet.readUInt32(); @@ -6220,12 +6220,12 @@ 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; + const size_t dispelEntrySize = dispelUsesFullGuid ? 8u : 5u; std::vector dispelledIds; dispelledIds.reserve(count); for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) { uint32_t dispelledId = packet.readUInt32(); - if (dispelTbcLike) { + if (dispelUsesFullGuid) { /*uint32_t unk =*/ packet.readUInt32(); } else { /*uint8_t isPositive =*/ packet.readUInt8(); @@ -6288,19 +6288,19 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPELLSTEALLOG: { // Sent to the CASTER (Mage) when Spellsteal succeeds. // Wire format mirrors SPELLDISPELLOG: - // WotLK: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count - // + count × (uint32 stolenSpellId + uint8 isPositive) - // TBC/Classic: full uint64 victim + full uint64 caster + same tail - const bool stealTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + // WotLK/Classic/Turtle: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count + // + count × (uint32 stolenSpellId + uint8 isPositive) + // TBC: full uint64 victim + full uint64 caster + same tail + const bool stealUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t stealVictim = stealTbcLike + uint64_t stealVictim = stealUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t stealCaster = stealTbcLike + uint64_t stealCaster = stealUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; @@ -6309,12 +6309,12 @@ 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; + const size_t stealEntrySize = stealUsesFullGuid ? 8u : 5u; std::vector stolenIds; stolenIds.reserve(stealCount); for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) { uint32_t stolenId = packet.readUInt32(); - if (stealTbcLike) { + if (stealUsesFullGuid) { /*uint32_t unk =*/ packet.readUInt32(); } else { /*uint8_t isPos =*/ packet.readUInt8(); From bd8c46fa49c702b4f529fd5e939f81eb76285695 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:00:56 -0700 Subject: [PATCH 38/59] fix(combatlog): parse classic dispel failed GUIDs as packed --- 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 a43e4c94..d528eca5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3239,12 +3239,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DISPEL_FAILED: { // WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim // [+ count × uint32 failedSpellId] - // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + // Classic: uint32 dispelSpellId + packed_guid caster + packed_guid victim // [+ count × uint32 failedSpellId] - const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // TBC: uint64 caster + uint64 victim + uint32 spellId + // [+ count × uint32 failedSpellId] + const bool dispelUsesFullGuid = isActiveExpansion("tbc"); uint32_t dispelSpellId = 0; uint64_t dispelCasterGuid = 0; - if (dispelTbcLike) { + if (dispelUsesFullGuid) { if (packet.getSize() - packet.getReadPos() < 20) break; dispelCasterGuid = packet.readUInt64(); /*uint64_t victim =*/ packet.readUInt64(); From dbdc45a8a9a9034a84920b9080c6924998db191b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:10:43 -0700 Subject: [PATCH 39/59] fix(combatlog): validate packed dispel-family GUIDs --- src/game/game_handler.cpp | 60 +++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d528eca5..a3275997 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -100,6 +100,22 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { raw[0] == 'n' || raw[0] == 'N'); } +bool hasFullPackedGuid(const network::Packet& packet) { + 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; +} + std::string formatCopperAmount(uint32_t amount) { uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; @@ -3254,9 +3270,13 @@ void GameHandler::handlePacket(network::Packet& packet) { } else { if (packet.getSize() - packet.getReadPos() < 4) break; dispelSpellId = packet.readUInt32(); - if (packet.getSize() - packet.getReadPos() < 1) break; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 1) break; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); } // Only show failure to the player who attempted the dispel @@ -6208,12 +6228,16 @@ void GameHandler::handlePacket(network::Packet& packet) { // TBC: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) const bool dispelUsesFullGuid = isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) + || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) + || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t victimGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) break; @@ -6294,12 +6318,14 @@ void GameHandler::handlePacket(network::Packet& packet) { // + count × (uint32 stolenSpellId + uint8 isPositive) // TBC: full uint64 victim + full uint64 caster + same tail const bool stealUsesFullGuid = isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) + || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t stealVictim = stealUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) + || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t stealCaster = stealUsesFullGuid @@ -6420,24 +6446,10 @@ 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 exeUsesFullGuid = 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() < (exeUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - if (!exeUsesFullGuid && !hasFullPackedGuid()) { + if (!exeUsesFullGuid && !hasFullPackedGuid(packet)) { packet.setReadPos(packet.getSize()); break; } uint64_t exeCaster = exeUsesFullGuid @@ -6459,7 +6471,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // 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() < (exeUsesFullGuid ? 8u : 1u) - || (!exeUsesFullGuid && !hasFullPackedGuid())) { + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t drainTarget = exeUsesFullGuid @@ -6497,7 +6509,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) - || (!exeUsesFullGuid && !hasFullPackedGuid())) { + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t leechTarget = exeUsesFullGuid @@ -6552,7 +6564,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) - || (!exeUsesFullGuid && !hasFullPackedGuid())) { + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t icTarget = exeUsesFullGuid From 0fc887a3d249b98c1a6cc37c91e6b95ce29b04be Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:18:28 -0700 Subject: [PATCH 40/59] fix(combatlog): validate packed proc log GUIDs --- src/game/game_handler.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a3275997..b8db9c3a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2110,9 +2110,15 @@ void GameHandler::handlePacket(network::Packet& packet) { return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) + || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t caster = readPrGuid(); - if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) + || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); @@ -6391,11 +6397,13 @@ void GameHandler::handlePacket(network::Packet& packet) { return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) + || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t procTargetGuid = readProcChanceGuid(); - if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) + || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t procCasterGuid = readProcChanceGuid(); From a48f6d1044a3bdb8d720e8cf04aa21526bb03175 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:25:47 -0700 Subject: [PATCH 41/59] fix(combatlog): parse classic immune log GUIDs as packed --- src/game/game_handler.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b8db9c3a..fee5dd0c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6206,17 +6206,23 @@ void GameHandler::handlePacket(network::Packet& packet) { 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 - const bool immuneTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - const size_t minSz = immuneTbcLike ? 21u : 2u; + // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType + // TBC: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 + const bool immuneUsesFullGuid = isActiveExpansion("tbc"); + const size_t minSz = immuneUsesFullGuid ? 21u : 2u; if (packet.getSize() - packet.getReadPos() < minSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = immuneTbcLike + if (!immuneUsesFullGuid && !hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (immuneTbcLike ? 8u : 2u)) break; - uint64_t victimGuid = immuneTbcLike + if (packet.getSize() - packet.getReadPos() < (immuneUsesFullGuid ? 8u : 2u) + || (!immuneUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t immuneSpellId = packet.readUInt32(); From 0968a11234fa837e1e0403131638162b845662df Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:32:45 -0700 Subject: [PATCH 42/59] fix(combatlog): validate packed instakill GUIDs --- src/game/game_handler.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fee5dd0c..0b849230 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6430,10 +6430,16 @@ void GameHandler::handlePacket(network::Packet& packet) { // TBC: full uint64 caster + full uint64 victim + uint32 spellId const bool ikUsesFullGuid = isActiveExpansion("tbc"); auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) + || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t ikCaster = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) + || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t ikVictim = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; From 468880e2c89a170d839b44aeba68b5b73473dc3d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:39:53 -0700 Subject: [PATCH 43/59] fix(combatlog): validate packed resist log GUIDs --- src/game/game_handler.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0b849230..54ca6119 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7044,10 +7044,16 @@ void GameHandler::handlePacket(network::Packet& packet) { auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } /*uint32_t hitInfo =*/ packet.readUInt32(); - if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) + || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t attackerGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) + || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t victimGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } From b059bbcf891b17fdf1d20209465263681bdb2ed7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:47:06 -0700 Subject: [PATCH 44/59] fix(combatlog): parse classic spell damage shield GUIDs as packed --- 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 54ca6119..844bafd6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6170,23 +6170,32 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell combat logs (consume) ---- 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) - const bool shieldClassicLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - const size_t shieldMinSz = shieldClassicLike ? 24u : 2u; + // Classic: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + schoolMask(4) + // 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) + const bool shieldTbc = isActiveExpansion("tbc"); + const bool shieldWotlkLike = !isClassicLikeExpansion() && !shieldTbc; + const size_t shieldMinSz = shieldTbc ? 24u : 2u; if (packet.getSize() - packet.getReadPos() < shieldMinSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = shieldClassicLike + if (!shieldTbc && (!hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = shieldClassicLike + if (packet.getSize() - packet.getReadPos() < (shieldTbc ? 8u : 1u) + || (!shieldTbc && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint32_t shieldSpellId = packet.readUInt32(); uint32_t damage = packet.readUInt32(); - if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) + if (shieldWotlkLike && packet.getSize() - packet.getReadPos() >= 4) /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect From f6d8c01779d18a2ec7e78fa1068ef15f5cad2aa9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 01:54:01 -0700 Subject: [PATCH 45/59] fix(combatlog): validate packed spell miss GUIDs --- src/game/game_handler.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 844bafd6..ca2edcd8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2717,14 +2717,20 @@ void GameHandler::handlePacket(network::Packet& packet) { // spellId prefix present in all expansions if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); - if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u) + || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); 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() < (spellMissUsesFullGuid ? 9u : 2u)) break; + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u) + || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); From 011a148105123da253e45a1133596d6ae00c68da Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 02:01:07 -0700 Subject: [PATCH 46/59] fix(combatlog): validate packed damage shield GUIDs --- src/game/game_handler.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ca2edcd8..30e91ccb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6181,6 +6181,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) const bool shieldTbc = isActiveExpansion("tbc"); const bool shieldWotlkLike = !isClassicLikeExpansion() && !shieldTbc; + const auto shieldRem = [&]() { return packet.getSize() - packet.getReadPos(); }; const size_t shieldMinSz = shieldTbc ? 24u : 2u; if (packet.getSize() - packet.getReadPos() < shieldMinSz) { packet.setReadPos(packet.getSize()); break; @@ -6196,12 +6197,13 @@ void GameHandler::handlePacket(network::Packet& packet) { } uint64_t casterGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 12) { + const size_t shieldTailSize = shieldWotlkLike ? 16u : 12u; + if (shieldRem() < shieldTailSize) { packet.setReadPos(packet.getSize()); break; } uint32_t shieldSpellId = packet.readUInt32(); uint32_t damage = packet.readUInt32(); - if (shieldWotlkLike && packet.getSize() - packet.getReadPos() >= 4) + if (shieldWotlkLike) /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect From 6a7071fd64963e25dd5032236dfbdd75a42ca7aa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 02:10:14 -0700 Subject: [PATCH 47/59] fix(combatlog): validate classic spell damage and heal GUIDs --- src/game/packet_parsers_classic.cpp | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 7077d0ab..046839d0 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -4,6 +4,26 @@ namespace wowee { namespace game { +namespace { + +bool hasFullPackedGuid(const network::Packet& packet) { + 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; +} + +} // namespace + // ============================================================================ // Classic 1.12.1 movement flag constants // Key differences from TBC: @@ -497,10 +517,10 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att // ============================================================================ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (rem() < 2) return false; + if (rem() < 2 || !hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (rem() < 1 || !hasFullPackedGuid(packet)) return false; data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla // uint32(spellId) + uint32(damage) + uint8(schoolMask) + uint32(absorbed) @@ -532,10 +552,10 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam // ============================================================================ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (rem() < 2) return false; + if (rem() < 2 || !hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (rem() < 1 || !hasFullPackedGuid(packet)) return false; data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla if (rem() < 13) return false; // uint32 + uint32 + uint32 + uint8 = 13 bytes From c1b66f73c5a1b47392c82c86fb3c9c57da820599 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:25:52 -0700 Subject: [PATCH 48/59] fix(vulkan): defer resource frees until frame fence --- include/rendering/vk_context.hpp | 12 +++++ src/rendering/terrain_renderer.cpp | 76 +++++++++++++++++++----------- src/rendering/vk_context.cpp | 21 +++++++++ src/rendering/water_renderer.cpp | 58 ++++++++++++++--------- 4 files changed, 118 insertions(+), 49 deletions(-) diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp index 154a4f98..654729b3 100644 --- a/include/rendering/vk_context.hpp +++ b/include/rendering/vk_context.hpp @@ -57,6 +57,15 @@ public: void pollUploadBatches(); // Check completed async uploads, free staging buffers void waitAllUploads(); // Block until all in-flight uploads complete + // Defer resource destruction until it is safe with multiple frames in flight. + // + // This queues work to run after the fence for the *current frame slot* has + // signaled the next time we enter beginFrame() for that slot (i.e. after + // MAX_FRAMES_IN_FLIGHT submissions). Use this for resources that may still + // be referenced by command buffers submitted in the previous frame(s), + // such as descriptor sets and buffers freed during streaming/unload. + void deferAfterFrameFence(std::function&& fn); + // Accessors VkInstance getInstance() const { return instance; } VkPhysicalDevice getPhysicalDevice() const { return physicalDevice; } @@ -173,6 +182,9 @@ private: }; std::vector inFlightBatches_; + void runDeferredCleanup(uint32_t frameIndex); + std::vector> deferredCleanup_[MAX_FRAMES_IN_FLIGHT]; + // Depth buffer (shared across all framebuffers) VkImage depthImage = VK_NULL_HANDLE; VkImageView depthImageView = VK_NULL_HANDLE; diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 75ca41c9..a8b518a2 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -990,42 +990,64 @@ void TerrainRenderer::clear() { } chunks.clear(); renderedChunks = 0; - - if (materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { + if (!vkCtx) return; + + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (chunk.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.vertexBuffer; ab.allocation = chunk.vertexAlloc; - destroyBuffer(allocator, ab); - chunk.vertexBuffer = VK_NULL_HANDLE; - } - if (chunk.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.indexBuffer; ab.allocation = chunk.indexAlloc; - destroyBuffer(allocator, ab); - chunk.indexBuffer = VK_NULL_HANDLE; - } - if (chunk.paramsUBO) { - AllocatedBuffer ab{}; ab.buffer = chunk.paramsUBO; ab.allocation = chunk.paramsAlloc; - destroyBuffer(allocator, ab); - chunk.paramsUBO = VK_NULL_HANDLE; - } - // Return material descriptor set to the pool so it can be reused by new chunks - if (chunk.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &chunk.materialSet); - } - chunk.materialSet = VK_NULL_HANDLE; + // These resources may still be referenced by in-flight command buffers from + // previous frames. Defer actual destruction until this frame slot is safe. + ::VkBuffer vertexBuffer = chunk.vertexBuffer; + VmaAllocation vertexAlloc = chunk.vertexAlloc; + ::VkBuffer indexBuffer = chunk.indexBuffer; + VmaAllocation indexAlloc = chunk.indexAlloc; + ::VkBuffer paramsUBO = chunk.paramsUBO; + VmaAllocation paramsAlloc = chunk.paramsAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = chunk.materialSet; - // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) - VkDevice device = vkCtx->getDevice(); + std::vector alphaTextures; + alphaTextures.reserve(chunk.ownedAlphaTextures.size()); for (auto& tex : chunk.ownedAlphaTextures) { - if (tex) tex->destroy(device, allocator); + alphaTextures.push_back(tex.release()); } + + chunk.vertexBuffer = VK_NULL_HANDLE; + chunk.vertexAlloc = VK_NULL_HANDLE; + chunk.indexBuffer = VK_NULL_HANDLE; + chunk.indexAlloc = VK_NULL_HANDLE; + chunk.paramsUBO = VK_NULL_HANDLE; + chunk.paramsAlloc = VK_NULL_HANDLE; + chunk.materialSet = VK_NULL_HANDLE; chunk.ownedAlphaTextures.clear(); + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + paramsUBO, paramsAlloc, pool, materialSet, alphaTextures]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (paramsUBO) { + AllocatedBuffer ab{}; ab.buffer = paramsUBO; ab.allocation = paramsAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + for (VkTexture* tex : alphaTextures) { + if (!tex) continue; + tex->destroy(device, allocator); + delete tex; + } + }); } int TerrainRenderer::getTriangleCount() const { diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index fdd07d8e..51781a3c 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -55,6 +55,11 @@ void VkContext::shutdown() { vkDeviceWaitIdle(device); } + // With the device idle, it is safe to run any deferred per-frame cleanup. + for (uint32_t fi = 0; fi < MAX_FRAMES_IN_FLIGHT; fi++) { + runDeferredCleanup(fi); + } + LOG_WARNING("VkContext::shutdown - destroyImGuiResources..."); destroyImGuiResources(); @@ -103,6 +108,19 @@ void VkContext::shutdown() { LOG_WARNING("Vulkan context shutdown complete"); } +void VkContext::deferAfterFrameFence(std::function&& fn) { + deferredCleanup_[currentFrame].push_back(std::move(fn)); +} + +void VkContext::runDeferredCleanup(uint32_t frameIndex) { + auto& q = deferredCleanup_[frameIndex]; + if (q.empty()) return; + for (auto& fn : q) { + if (fn) fn(); + } + q.clear(); +} + bool VkContext::createInstance(SDL_Window* window) { // Get required SDL extensions unsigned int sdlExtCount = 0; @@ -1349,6 +1367,9 @@ VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { return VK_NULL_HANDLE; } + // Any work queued for this frame slot is now guaranteed to be unused by the GPU. + runDeferredCleanup(currentFrame); + // Acquire next swapchain image VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, frame.imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex); diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index d79e53f7..2bbff1a3 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1029,10 +1029,6 @@ void WaterRenderer::clear() { destroyWaterMesh(surface); } surfaces.clear(); - - if (vkCtx && materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } // ============================================================== @@ -1358,27 +1354,45 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { void WaterRenderer::destroyWaterMesh(WaterSurface& surface) { if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (surface.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.vertexBuffer; ab.allocation = surface.vertexAlloc; - destroyBuffer(allocator, ab); - surface.vertexBuffer = VK_NULL_HANDLE; - } - if (surface.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.indexBuffer; ab.allocation = surface.indexAlloc; - destroyBuffer(allocator, ab); - surface.indexBuffer = VK_NULL_HANDLE; - } - if (surface.materialUBO) { - AllocatedBuffer ab{}; ab.buffer = surface.materialUBO; ab.allocation = surface.materialAlloc; - destroyBuffer(allocator, ab); - surface.materialUBO = VK_NULL_HANDLE; - } - if (surface.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &surface.materialSet); - } + ::VkBuffer vertexBuffer = surface.vertexBuffer; + VmaAllocation vertexAlloc = surface.vertexAlloc; + ::VkBuffer indexBuffer = surface.indexBuffer; + VmaAllocation indexAlloc = surface.indexAlloc; + ::VkBuffer materialUBO = surface.materialUBO; + VmaAllocation materialAlloc = surface.materialAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = surface.materialSet; + + surface.vertexBuffer = VK_NULL_HANDLE; + surface.vertexAlloc = VK_NULL_HANDLE; + surface.indexBuffer = VK_NULL_HANDLE; + surface.indexAlloc = VK_NULL_HANDLE; + surface.materialUBO = VK_NULL_HANDLE; + surface.materialAlloc = VK_NULL_HANDLE; surface.materialSet = VK_NULL_HANDLE; + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + materialUBO, materialAlloc, pool, materialSet]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (materialUBO) { + AllocatedBuffer ab{}; ab.buffer = materialUBO; ab.allocation = materialAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + }); } // ============================================================== From 6cfb439fd607d9998b6f9b635e81922652268e44 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:25:52 -0700 Subject: [PATCH 49/59] fix(vulkan): defer resource frees until frame fence --- include/rendering/vk_context.hpp | 12 +++++ src/rendering/terrain_renderer.cpp | 76 +++++++++++++++++++----------- src/rendering/vk_context.cpp | 21 +++++++++ src/rendering/water_renderer.cpp | 58 ++++++++++++++--------- 4 files changed, 118 insertions(+), 49 deletions(-) diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp index 154a4f98..654729b3 100644 --- a/include/rendering/vk_context.hpp +++ b/include/rendering/vk_context.hpp @@ -57,6 +57,15 @@ public: void pollUploadBatches(); // Check completed async uploads, free staging buffers void waitAllUploads(); // Block until all in-flight uploads complete + // Defer resource destruction until it is safe with multiple frames in flight. + // + // This queues work to run after the fence for the *current frame slot* has + // signaled the next time we enter beginFrame() for that slot (i.e. after + // MAX_FRAMES_IN_FLIGHT submissions). Use this for resources that may still + // be referenced by command buffers submitted in the previous frame(s), + // such as descriptor sets and buffers freed during streaming/unload. + void deferAfterFrameFence(std::function&& fn); + // Accessors VkInstance getInstance() const { return instance; } VkPhysicalDevice getPhysicalDevice() const { return physicalDevice; } @@ -173,6 +182,9 @@ private: }; std::vector inFlightBatches_; + void runDeferredCleanup(uint32_t frameIndex); + std::vector> deferredCleanup_[MAX_FRAMES_IN_FLIGHT]; + // Depth buffer (shared across all framebuffers) VkImage depthImage = VK_NULL_HANDLE; VkImageView depthImageView = VK_NULL_HANDLE; diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 75ca41c9..a8b518a2 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -990,42 +990,64 @@ void TerrainRenderer::clear() { } chunks.clear(); renderedChunks = 0; - - if (materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { + if (!vkCtx) return; + + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (chunk.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.vertexBuffer; ab.allocation = chunk.vertexAlloc; - destroyBuffer(allocator, ab); - chunk.vertexBuffer = VK_NULL_HANDLE; - } - if (chunk.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.indexBuffer; ab.allocation = chunk.indexAlloc; - destroyBuffer(allocator, ab); - chunk.indexBuffer = VK_NULL_HANDLE; - } - if (chunk.paramsUBO) { - AllocatedBuffer ab{}; ab.buffer = chunk.paramsUBO; ab.allocation = chunk.paramsAlloc; - destroyBuffer(allocator, ab); - chunk.paramsUBO = VK_NULL_HANDLE; - } - // Return material descriptor set to the pool so it can be reused by new chunks - if (chunk.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &chunk.materialSet); - } - chunk.materialSet = VK_NULL_HANDLE; + // These resources may still be referenced by in-flight command buffers from + // previous frames. Defer actual destruction until this frame slot is safe. + ::VkBuffer vertexBuffer = chunk.vertexBuffer; + VmaAllocation vertexAlloc = chunk.vertexAlloc; + ::VkBuffer indexBuffer = chunk.indexBuffer; + VmaAllocation indexAlloc = chunk.indexAlloc; + ::VkBuffer paramsUBO = chunk.paramsUBO; + VmaAllocation paramsAlloc = chunk.paramsAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = chunk.materialSet; - // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) - VkDevice device = vkCtx->getDevice(); + std::vector alphaTextures; + alphaTextures.reserve(chunk.ownedAlphaTextures.size()); for (auto& tex : chunk.ownedAlphaTextures) { - if (tex) tex->destroy(device, allocator); + alphaTextures.push_back(tex.release()); } + + chunk.vertexBuffer = VK_NULL_HANDLE; + chunk.vertexAlloc = VK_NULL_HANDLE; + chunk.indexBuffer = VK_NULL_HANDLE; + chunk.indexAlloc = VK_NULL_HANDLE; + chunk.paramsUBO = VK_NULL_HANDLE; + chunk.paramsAlloc = VK_NULL_HANDLE; + chunk.materialSet = VK_NULL_HANDLE; chunk.ownedAlphaTextures.clear(); + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + paramsUBO, paramsAlloc, pool, materialSet, alphaTextures]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (paramsUBO) { + AllocatedBuffer ab{}; ab.buffer = paramsUBO; ab.allocation = paramsAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + for (VkTexture* tex : alphaTextures) { + if (!tex) continue; + tex->destroy(device, allocator); + delete tex; + } + }); } int TerrainRenderer::getTriangleCount() const { diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index fdd07d8e..51781a3c 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -55,6 +55,11 @@ void VkContext::shutdown() { vkDeviceWaitIdle(device); } + // With the device idle, it is safe to run any deferred per-frame cleanup. + for (uint32_t fi = 0; fi < MAX_FRAMES_IN_FLIGHT; fi++) { + runDeferredCleanup(fi); + } + LOG_WARNING("VkContext::shutdown - destroyImGuiResources..."); destroyImGuiResources(); @@ -103,6 +108,19 @@ void VkContext::shutdown() { LOG_WARNING("Vulkan context shutdown complete"); } +void VkContext::deferAfterFrameFence(std::function&& fn) { + deferredCleanup_[currentFrame].push_back(std::move(fn)); +} + +void VkContext::runDeferredCleanup(uint32_t frameIndex) { + auto& q = deferredCleanup_[frameIndex]; + if (q.empty()) return; + for (auto& fn : q) { + if (fn) fn(); + } + q.clear(); +} + bool VkContext::createInstance(SDL_Window* window) { // Get required SDL extensions unsigned int sdlExtCount = 0; @@ -1349,6 +1367,9 @@ VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { return VK_NULL_HANDLE; } + // Any work queued for this frame slot is now guaranteed to be unused by the GPU. + runDeferredCleanup(currentFrame); + // Acquire next swapchain image VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, frame.imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex); diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index d79e53f7..2bbff1a3 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1029,10 +1029,6 @@ void WaterRenderer::clear() { destroyWaterMesh(surface); } surfaces.clear(); - - if (vkCtx && materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } // ============================================================== @@ -1358,27 +1354,45 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { void WaterRenderer::destroyWaterMesh(WaterSurface& surface) { if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (surface.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.vertexBuffer; ab.allocation = surface.vertexAlloc; - destroyBuffer(allocator, ab); - surface.vertexBuffer = VK_NULL_HANDLE; - } - if (surface.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.indexBuffer; ab.allocation = surface.indexAlloc; - destroyBuffer(allocator, ab); - surface.indexBuffer = VK_NULL_HANDLE; - } - if (surface.materialUBO) { - AllocatedBuffer ab{}; ab.buffer = surface.materialUBO; ab.allocation = surface.materialAlloc; - destroyBuffer(allocator, ab); - surface.materialUBO = VK_NULL_HANDLE; - } - if (surface.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &surface.materialSet); - } + ::VkBuffer vertexBuffer = surface.vertexBuffer; + VmaAllocation vertexAlloc = surface.vertexAlloc; + ::VkBuffer indexBuffer = surface.indexBuffer; + VmaAllocation indexAlloc = surface.indexAlloc; + ::VkBuffer materialUBO = surface.materialUBO; + VmaAllocation materialAlloc = surface.materialAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = surface.materialSet; + + surface.vertexBuffer = VK_NULL_HANDLE; + surface.vertexAlloc = VK_NULL_HANDLE; + surface.indexBuffer = VK_NULL_HANDLE; + surface.indexAlloc = VK_NULL_HANDLE; + surface.materialUBO = VK_NULL_HANDLE; + surface.materialAlloc = VK_NULL_HANDLE; surface.materialSet = VK_NULL_HANDLE; + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + materialUBO, materialAlloc, pool, materialSet]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (materialUBO) { + AllocatedBuffer ab{}; ab.buffer = materialUBO; ab.allocation = materialAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + }); } // ============================================================== From 565c78d1419d7834d925926d70980a9c2a4fa5d9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:37:24 -0700 Subject: [PATCH 50/59] fix(input): reserve Q for strafe-left --- src/rendering/performance_hud.cpp | 2 +- src/ui/keybinding_manager.cpp | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index a5a76ab2..d6119e74 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -461,7 +461,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Turn left/right"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Strafe left/right"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index fbe70e33..cb7093b0 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -47,6 +47,10 @@ ImGuiKey KeybindingManager::getKeyForAction(Action action) const { } void KeybindingManager::setKeyForAction(Action action, ImGuiKey key) { + // Q is reserved for movement (strafe-left) and must never open quest log. + if (action == Action::TOGGLE_QUEST_LOG && key == ImGuiKey_Q) { + key = ImGuiKey_None; + } bindings_[static_cast(action)] = key; } @@ -175,9 +179,14 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { } } - if (key != ImGuiKey_None) { - bindings_[actionIdx] = key; + if (key == ImGuiKey_None) continue; + + // Q is reserved for movement (strafe-left) and must never open quest log. + if (actionIdx == static_cast(Action::TOGGLE_QUEST_LOG) && key == ImGuiKey_Q) { + continue; } + + bindings_[actionIdx] = key; } file.close(); From 8391f93ca6094beafdb955b37bed277aafc3e46a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:39:49 -0700 Subject: [PATCH 51/59] fix(input): block WASD and Q/E movement --- src/rendering/camera_controller.cpp | 12 ++++++------ src/rendering/performance_hud.cpp | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 22c5304f..07d8ac12 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -225,13 +225,17 @@ void CameraController::update(float deltaTime) { bool keyD = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_D); bool keyQ = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_Q); bool keyE = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_E); + + // Keyboard movement keys are blocked (movement only). + // Mouse autorun (L+R) and other non-movement keys remain functional. + keyW = keyS = keyA = keyD = keyQ = keyE = false; bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); bool spaceDown = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan - bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; + bool anyInput = leftMouseDown || rightMouseDown || nowJump; if (anyInput) { idleTimer_ = 0.0f; } else if (!introActive) { @@ -397,11 +401,7 @@ void CameraController::update(float deltaTime) { // Stand up on any movement key or jump while sitting (WoW behaviour) if (!uiWantsKeyboard && sitting && !movementSuppressed) { - bool anyMoveKey = - input.isKeyPressed(SDL_SCANCODE_W) || input.isKeyPressed(SDL_SCANCODE_S) || - input.isKeyPressed(SDL_SCANCODE_A) || input.isKeyPressed(SDL_SCANCODE_D) || - input.isKeyPressed(SDL_SCANCODE_Q) || input.isKeyPressed(SDL_SCANCODE_E) || - input.isKeyPressed(SDL_SCANCODE_SPACE); + bool anyMoveKey = input.isKeyPressed(SDL_SCANCODE_SPACE); if (anyMoveKey) sitting = false; } diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index d6119e74..f5e70080 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -460,8 +460,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Strafe left/right"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD/QE: Blocked"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); From a09a24e58ec41b1fc4132b8830245f6dd2357a0c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:46:10 -0700 Subject: [PATCH 52/59] Revert "fix(input): block WASD and Q/E movement" This reverts commit 8391f93ca6094beafdb955b37bed277aafc3e46a. --- src/rendering/camera_controller.cpp | 12 ++++++------ src/rendering/performance_hud.cpp | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 07d8ac12..22c5304f 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -225,17 +225,13 @@ void CameraController::update(float deltaTime) { bool keyD = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_D); bool keyQ = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_Q); bool keyE = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_E); - - // Keyboard movement keys are blocked (movement only). - // Mouse autorun (L+R) and other non-movement keys remain functional. - keyW = keyS = keyA = keyD = keyQ = keyE = false; bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); bool spaceDown = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan - bool anyInput = leftMouseDown || rightMouseDown || nowJump; + bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; if (anyInput) { idleTimer_ = 0.0f; } else if (!introActive) { @@ -401,7 +397,11 @@ void CameraController::update(float deltaTime) { // Stand up on any movement key or jump while sitting (WoW behaviour) if (!uiWantsKeyboard && sitting && !movementSuppressed) { - bool anyMoveKey = input.isKeyPressed(SDL_SCANCODE_SPACE); + bool anyMoveKey = + input.isKeyPressed(SDL_SCANCODE_W) || input.isKeyPressed(SDL_SCANCODE_S) || + input.isKeyPressed(SDL_SCANCODE_A) || input.isKeyPressed(SDL_SCANCODE_D) || + input.isKeyPressed(SDL_SCANCODE_Q) || input.isKeyPressed(SDL_SCANCODE_E) || + input.isKeyPressed(SDL_SCANCODE_SPACE); if (anyMoveKey) sitting = false; } diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index f5e70080..d6119e74 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -460,7 +460,8 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD/QE: Blocked"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Strafe left/right"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); From aed1f2ad2179a9bab58628b77a0ba01786710f9c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:47:14 -0700 Subject: [PATCH 53/59] fix(input): reserve movement keys for movement --- src/ui/keybinding_manager.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index cb7093b0..4b3b4535 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -5,6 +5,11 @@ namespace wowee::ui { +static bool isReservedMovementKey(ImGuiKey key) { + return key == ImGuiKey_W || key == ImGuiKey_A || key == ImGuiKey_S || + key == ImGuiKey_D || key == ImGuiKey_Q || key == ImGuiKey_E; +} + KeybindingManager& KeybindingManager::getInstance() { static KeybindingManager instance; return instance; @@ -47,8 +52,9 @@ ImGuiKey KeybindingManager::getKeyForAction(Action action) const { } void KeybindingManager::setKeyForAction(Action action, ImGuiKey key) { - // Q is reserved for movement (strafe-left) and must never open quest log. - if (action == Action::TOGGLE_QUEST_LOG && key == ImGuiKey_Q) { + // Reserve movement keys so they cannot be used as UI shortcuts. + (void)action; + if (isReservedMovementKey(key)) { key = ImGuiKey_None; } bindings_[static_cast(action)] = key; @@ -181,8 +187,8 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { if (key == ImGuiKey_None) continue; - // Q is reserved for movement (strafe-left) and must never open quest log. - if (actionIdx == static_cast(Action::TOGGLE_QUEST_LOG) && key == ImGuiKey_Q) { + // Reserve movement keys so they cannot be used as UI shortcuts. + if (isReservedMovementKey(key)) { continue; } From 14e58eaa01a4ae3d7ef73325af2ea39de4e55838 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:49:42 -0700 Subject: [PATCH 54/59] fix(chat): disable shortcuts while typing --- src/ui/game_screen.cpp | 114 ++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 788150b6..cfbbf1ae 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2274,10 +2274,12 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto& io = ImGui::GetIO(); auto& input = core::Input::getInstance(); + const bool textFocus = chatInputActive || io.WantTextInput; // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { - if (input.isKeyJustPressed(SDL_SCANCODE_TAB)) { + // When typing in chat (or any text input), never treat keys as gameplay/UI shortcuts. + if (!textFocus && input.isKeyJustPressed(SDL_SCANCODE_TAB)) { const auto& movement = gameHandler.getMovementInfo(); gameHandler.tabTarget(movement.x, movement.y, movement.z); } @@ -2300,69 +2302,70 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } - // Toggle character screen (C) and inventory/bags (I) - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { - inventoryScreen.toggleCharacter(); - } + if (!textFocus) { + // Toggle character screen (C) and inventory/bags (I) + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { + inventoryScreen.toggleCharacter(); + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { - inventoryScreen.toggle(); - } - - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { - if (inventoryScreen.isSeparateBags()) { - inventoryScreen.openAllBags(); - } else { + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { inventoryScreen.toggle(); } - } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { - showNameplates_ = !showNameplates_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { + if (inventoryScreen.isSeparateBags()) { + inventoryScreen.openAllBags(); + } else { + inventoryScreen.toggle(); + } + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { - showWorldMap_ = !showWorldMap_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { + showNameplates_ = !showNameplates_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { - showMinimap_ = !showMinimap_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { + showWorldMap_ = !showWorldMap_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { - showRaidFrames_ = !showRaidFrames_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { + showMinimap_ = !showMinimap_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { - questLogScreen.toggle(); - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { + showRaidFrames_ = !showRaidFrames_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { - showAchievementWindow_ = !showAchievementWindow_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { + questLogScreen.toggle(); + } - // Toggle Titles window with H (hero/title screen — no conflicting keybinding) - if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { - showTitlesWindow_ = !showTitlesWindow_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { + showAchievementWindow_ = !showAchievementWindow_; + } + // Toggle Titles window with H (hero/title screen — no conflicting keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + showTitlesWindow_ = !showTitlesWindow_; + } - // Action bar keys (1-9, 0, -, =) - static const SDL_Scancode actionBarKeys[] = { - SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, - SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, - SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS - }; - const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); - const auto& bar = gameHandler.getActionBar(); - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (input.isKeyJustPressed(actionBarKeys[i])) { - int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; - if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(bar[slotIdx].id, target); - } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { - gameHandler.useItemById(bar[slotIdx].id); + // Action bar keys (1-9, 0, -, =) + static const SDL_Scancode actionBarKeys[] = { + SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, + SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, + SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS + }; + const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const auto& bar = gameHandler.getActionBar(); + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (input.isKeyJustPressed(actionBarKeys[i])) { + int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; + if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(bar[slotIdx].id, target); + } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { + gameHandler.useItemById(bar[slotIdx].id); + } } } } @@ -11224,7 +11227,9 @@ void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Guild Roster toggle (customizable keybind) - if (!ImGui::GetIO().WantCaptureKeyboard && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + !ImGui::GetIO().WantCaptureKeyboard && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { // Open friends tab directly if not in guild @@ -19543,7 +19548,8 @@ void GameScreen::renderZoneText() { // --------------------------------------------------------------------------- void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // Toggle Dungeon Finder (customizable keybind) - if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { showDungeonFinder_ = !showDungeonFinder_; } From a5b877de676eb840f07abe636247acb61430b4e2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 03:54:26 -0700 Subject: [PATCH 55/59] fix(chat): suppress alnum hotkeys while typing --- src/ui/game_screen.cpp | 29 +++++++++++++++-------------- src/ui/keybinding_manager.cpp | 14 +++++++++++++- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cfbbf1ae..a9efe484 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2274,7 +2274,21 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto& io = ImGui::GetIO(); auto& input = core::Input::getInstance(); - const bool textFocus = chatInputActive || io.WantTextInput; + + // If the user is typing (or about to focus chat this frame), do not allow + // A-Z or 1-0 shortcuts to fire. + if (!io.WantTextInput && !chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { + refocusChatInput = true; + chatInputBuffer[0] = '/'; + chatInputBuffer[1] = '\0'; + chatInputMoveCursorToEnd = true; + } + if (!io.WantTextInput && !chatInputActive && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { + refocusChatInput = true; + } + + const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput; // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { @@ -2372,19 +2386,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } - // Slash key: focus chat input — always works unless already typing in chat - if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { - refocusChatInput = true; - chatInputBuffer[0] = '/'; - chatInputBuffer[1] = '\0'; - chatInputMoveCursorToEnd = true; - } - - // Enter key: focus chat input (empty) — always works unless already typing - if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { - refocusChatInput = true; - } - // Cursor affordance: show hand cursor over interactable game objects. if (!io.WantCaptureMouse) { auto* renderer = core::Application::getInstance().getRenderer(); diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index 4b3b4535..a96a040e 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -42,7 +42,19 @@ void KeybindingManager::initializeDefaults() { bool KeybindingManager::isActionPressed(Action action, bool repeat) { auto it = bindings_.find(static_cast(action)); if (it == bindings_.end()) return false; - return ImGui::IsKeyPressed(it->second, repeat); + ImGuiKey key = it->second; + if (key == ImGuiKey_None) return false; + + // When typing in a text field (e.g. chat input), never treat A-Z or 0-9 as shortcuts. + const ImGuiIO& io = ImGui::GetIO(); + if (io.WantTextInput) { + if ((key >= ImGuiKey_A && key <= ImGuiKey_Z) || + (key >= ImGuiKey_0 && key <= ImGuiKey_9)) { + return false; + } + } + + return ImGui::IsKeyPressed(key, repeat); } ImGuiKey KeybindingManager::getKeyForAction(Action action) const { From 94e5855d53b329b119487834737f46fcfbf32817 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 04:33:46 -0700 Subject: [PATCH 56/59] fix(ui): prefer quest objective gameobjects on right-click --- src/ui/game_screen.cpp | 54 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a9efe484..6c4f8f52 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2538,6 +2538,25 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto* camera = renderer ? renderer->getCamera() : nullptr; auto* window = core::Application::getInstance().getWindow(); if (camera && window) { + // If a quest objective gameobject is under the cursor, prefer it over + // hostile units so quest pickups (e.g. "Bundle of Wood") are reliable. + std::unordered_set questObjectiveGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + questObjectiveGoEntries.reserve(32); + for (const auto& q : ql) { + if (q.complete) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId >= 0 || obj.required == 0) continue; + uint32_t entry = static_cast(-obj.npcOrGoId); + uint32_t cur = 0; + auto it = q.killCounts.find(entry); + if (it != q.killCounts.end()) cur = it->second.first; + if (cur < obj.required) questObjectiveGoEntries.insert(entry); + } + } + } + glm::vec2 mousePos = input.getMousePosition(); float screenW = static_cast(window->getWidth()); float screenH = static_cast(window->getHeight()); @@ -2547,13 +2566,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { game::ObjectType closestType = game::ObjectType::OBJECT; float closestHostileUnitT = 1e30f; uint64_t closestHostileUnitGuid = 0; + float closestQuestGoT = 1e30f; + uint64_t closestQuestGoGuid = 0; const uint64_t myGuid = gameHandler.getPlayerGuid(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER && - t != game::ObjectType::GAMEOBJECT) continue; + t != game::ObjectType::GAMEOBJECT) + continue; if (guid == myGuid) continue; + glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); @@ -2568,10 +2591,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } else if (t == game::ObjectType::GAMEOBJECT) { // For GOs with no renderer instance yet, use a tight fallback - // sphere (not 2.5f) so invisible/unloaded GOs (chairs, doodads) - // are not accidentally clicked during camera right-drag. + // sphere so invisible/unloaded doodads aren't accidentally clicked. hitRadius = 1.2f; heightOffset = 1.0f; + // Quest objective GOs should be easier to click. + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + hitRadius = 2.2f; + heightOffset = 1.2f; + } } hitCenter = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); @@ -2579,6 +2607,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else { hitRadius = std::max(hitRadius * 1.1f, 0.6f); } + float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) { if (t == game::ObjectType::UNIT) { @@ -2589,6 +2618,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { closestHostileUnitGuid = guid; } } + if (t == game::ObjectType::GAMEOBJECT && !questObjectiveGoEntries.empty()) { + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + if (hitT < closestQuestGoT) { + closestQuestGoT = hitT; + closestQuestGoGuid = guid; + } + } + } if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -2596,11 +2634,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } } - // Prefer hostile monsters over nearby gameobjects/others when right-click picking. - if (closestHostileUnitGuid != 0) { + + // Prefer quest objective GOs over hostile monsters when both are hittable. + if (closestQuestGoGuid != 0) { + closestGuid = closestQuestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else if (closestHostileUnitGuid != 0) { + // Prefer hostile monsters over nearby gameobjects/others when right-click picking. closestGuid = closestHostileUnitGuid; closestType = game::ObjectType::UNIT; } + if (closestGuid != 0) { if (closestType == game::ObjectType::GAMEOBJECT) { gameHandler.setTarget(closestGuid); From 5be80a9cc631303a644d14654a1d01603df138b1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 04:36:25 -0700 Subject: [PATCH 57/59] fix(chat): suppress hotkeys while UI captures keyboard --- src/ui/game_screen.cpp | 2 +- src/ui/keybinding_manager.cpp | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6c4f8f52..f9108e1a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2288,7 +2288,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { refocusChatInput = true; } - const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput; + const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput || io.WantCaptureKeyboard; // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index a96a040e..ca2a6cd5 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -47,7 +47,9 @@ bool KeybindingManager::isActionPressed(Action action, bool repeat) { // When typing in a text field (e.g. chat input), never treat A-Z or 0-9 as shortcuts. const ImGuiIO& io = ImGui::GetIO(); - if (io.WantTextInput) { + // Note: WantTextInput may not be set until the text widget is processed later in the + // frame, but WantCaptureKeyboard remains true while an ImGui widget is active. + if (io.WantTextInput || io.WantCaptureKeyboard) { if ((key >= ImGuiKey_A && key <= ImGuiKey_Z) || (key >= ImGuiKey_0 && key <= ImGuiKey_9)) { return false; From 5a63d3799c29ccd721bb239defeb0c8e78c0eef8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 04:41:46 -0700 Subject: [PATCH 58/59] fix(loot): retry delayed loot for gameobject use --- 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 30e91ccb..f4c2bf6c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -831,6 +831,13 @@ void GameHandler::update(float deltaTime) { it->timer -= deltaTime; if (it->timer <= 0.0f) { if (state == WorldState::IN_WORLD && socket) { + // Avoid sending CMSG_LOOT while a timed cast is active (e.g. gathering). + // handleSpellGo will trigger loot after the cast completes. + if (casting && currentCastSpellId != 0) { + it->timer = 0.20f; + ++it; + continue; + } lootTarget(it->guid); } it = pendingGameObjectLootOpens_.erase(it); @@ -18373,6 +18380,13 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { } if (shouldSendLoot) { lootTarget(guid); + // Some servers/scripts only make certain quest/chest GOs lootable after a short delay + // (use animation, state change). Queue one delayed loot attempt to catch that case. + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == guid; }), + pendingGameObjectLootOpens_.end()); + pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f}); } 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 @@ -19545,6 +19559,10 @@ void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; lootWindowOpen = true; lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }), + pendingGameObjectLootOpens_.end()); localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false}; // Query item info so loot window can show names instead of IDs From 251ed7733b5d7640c1c3f16dc61177eeb70a1db8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 04:47:34 -0700 Subject: [PATCH 59/59] fix(loot): send GAMEOBJ_REPORT_USE when supported --- src/game/game_handler.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f4c2bf6c..efaee286 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18372,11 +18372,15 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { lower.find("coffer") != std::string::npos || lower.find("cache") != std::string::npos); } - // For WotLK, CMSG_GAMEOBJ_REPORT_USE is required for chests (and is harmless for others). - if (!isMailbox && isActiveExpansion("wotlk")) { - network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); - reportUse.writeUInt64(guid); - socket->send(reportUse); + // Some servers require CMSG_GAMEOBJ_REPORT_USE for lootable gameobjects. + // Only send it when the active opcode table actually supports it. + if (!isMailbox) { + const auto* table = getActiveOpcodeTable(); + if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); + } } if (shouldSendLoot) { lootTarget(guid);