From b6ea78dfabdcf43e65329a4649ec8d627a3b2374 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:26:10 -0700 Subject: [PATCH 01/64] fix: show spell name in REFLECT floating combat text REFLECT entries already stored the reflected spell ID but the floating text display showed only "Reflected"/"You Reflect" without the name. Now shows "Reflected: Fireball" or "Reflect: Frost Nova", matching the pattern already used by INTERRUPT, DISPEL, and STEAL entries. --- src/ui/game_screen.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 771a1ba2..c2cee9da 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8574,11 +8574,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { 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 ? "Reflected" : "You Reflect"); + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + 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; + } case game::CombatTextEntry::PROC_TRIGGER: { const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; if (!procName.empty()) From 8b57e6fa4544f7b8e502fe8eacada781b10987e3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:38:57 -0700 Subject: [PATCH 02/64] feat: add HONOR_GAIN floating combat text for PvP honor gains Show '+X Honor' floating text in gold when SMSG_PVP_CREDIT is received, matching WoW's native behavior. Also add HONOR_GAIN to the combat log panel for a complete record. Previously only a chat message was added. --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 2 ++ src/ui/game_screen.cpp | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 10c4a5cd..55759128 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, EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, - DISPEL, STEAL, INTERRUPT, INSTAKILL + DISPEL, STEAL, INTERRUPT, INSTAKILL, HONOR_GAIN }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7decf08c..2c8b704c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2168,6 +2168,8 @@ void GameHandler::handlePacket(network::Packet& packet) { std::dec, " rank=", rank); std::string msg = "You gain " + std::to_string(honor) + " honor points."; addSystemChatMessage(msg); + if (honor > 0) + addCombatText(CombatTextEntry::HONOR_GAIN, static_cast(honor), 0, true); if (pvpHonorCallback_) { pvpHonorCallback_(honor, victimGuid, rank); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c2cee9da..0b09c847 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8631,6 +8631,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) : ImVec4(1.0f, 0.1f, 0.1f, alpha); break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for honor + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -20723,6 +20727,10 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); break; + case T::HONOR_GAIN: + snprintf(desc, sizeof(desc), "You gain %d honor", e.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, 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 f04875514e4f8c512145a17947d9d508d9d0afe2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:41:00 -0700 Subject: [PATCH 03/64] feat: improve item tooltip with bind type, item level, weapon damage range, and required level Add standard WoW tooltip fields that were previously missing: - Bind type (Binds when picked up/equipped/used, Quest Item) - Unique indicator - Item Level XX - Weapon damage range (e.g. '22 - 41 Damage Speed 2.20') replacing bare DPS - Damage per second sub-line in dimmed text - Requires Level XX --- src/ui/game_screen.cpp | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0b09c847..27e2f72d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1380,6 +1380,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique + if (info->maxCount == 1) + ImGui::TextDisabled("Unique"); + // Slot type if (info->inventoryType > 0) { const char* slotName = ""; @@ -1432,10 +1443,23 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { }; const bool isWeapon = isWeaponInventoryType(info->inventoryType); + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { float speed = static_cast(info->delayMs) / 1000.0f; float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + // WoW-style: "22 - 41 Damage" with speed right-aligned on same row + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); } ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { @@ -1456,6 +1480,9 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (info->armor > 0) { ImGui::Text("%d Armor", info->armor); } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); if (info->sellPrice > 0) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; @@ -1478,7 +1505,15 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { float speed = static_cast(eq->item.delayMs) / 1000.0f; float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); } if (eq->item.armor > 0) { ImGui::Text("%d Armor", eq->item.armor); From 03397ec23c5886f9cd7e7cecc3791a9a10918ee5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:42:00 -0700 Subject: [PATCH 04/64] feat: show extra item stats in tooltip (hit/crit/haste/sp/ap/expertise) Display all ExtraStat entries from SMSG_ITEM_QUERY_SINGLE_RESPONSE in the item tooltip (hit rating, crit rating, haste, spell power, attack power, expertise, resilience, etc.). These were previously silently discarded, making WotLK/TBC gear tooltips incomplete. --- src/ui/game_screen.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 27e2f72d..c9079987 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1480,6 +1480,36 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (info->armor > 0) { ImGui::Text("%d Armor", info->armor); } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } // Required level if (info->requiredLevel > 1) ImGui::TextDisabled("Requires Level %u", info->requiredLevel); From 020ba134cd06052ab24f86aac75d71b3c968465f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:43:22 -0700 Subject: [PATCH 05/64] feat: show item spell effects (Use/Equip/Teaches) in item tooltip Display Use, Equip, Chance on Hit, and Teaches spell effects from SMSG_ITEM_QUERY_SINGLE_RESPONSE in item tooltips. Looks up spell name from Spell.dbc via SpellbookScreen for readable descriptions. --- src/ui/game_screen.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c9079987..629dba96 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1510,6 +1510,22 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextColored(green, "+%d %s", es.statValue, nm); } } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + std::string spName = spellbookScreen.lookupSpellName(sp.spellId, assetMgr); + if (!spName.empty()) + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), + "%s: %s", triggerLabel, spName.c_str()); + } // Required level if (info->requiredLevel > 1) ImGui::TextDisabled("Requires Level %u", info->requiredLevel); From 3b79f44b543e04fb683e1a678424d85f167a7bbf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:44:15 -0700 Subject: [PATCH 06/64] feat: show item flavor/lore text in item tooltip Display item description (flavor text) from SMSG_ITEM_QUERY_SINGLE_RESPONSE at the bottom of item tooltips in gold color, matching WoW's standard tooltip layout where lore text appears below stats and effects. --- src/ui/game_screen.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 629dba96..97cac5c2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1529,6 +1529,13 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Required level if (info->requiredLevel > 1) ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } if (info->sellPrice > 0) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; From 4edc4017edceb03844dfca223e35456a4d51e208 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:50:28 -0700 Subject: [PATCH 07/64] feat: show extra stats in equipped item comparison panel (shift-hover) When shift-hovering an item link in chat to compare with equipped gear, also display extra stats (hit/crit/haste/AP/SP/expertise) for the currently-equipped item, matching what is shown for the hovered item. --- src/ui/game_screen.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 97cac5c2..bcd920ef 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1580,6 +1580,28 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (!eqBonusLine.empty()) { ImGui::TextColored(green, "%s", eqBonusLine.c_str()); } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } } } ImGui::EndTooltip(); From 6d83027226eba6a36dac7654b9f88c74a8a8b6e9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 15:12:58 -0700 Subject: [PATCH 08/64] feat: add stance/form/presence bar for Warriors, Druids, Death Knights, Rogues, Priests Renders a stance bar to the left of the main action bar showing the player's known stance spells filtered to only those they have learned: - Warrior: Battle Stance, Defensive Stance, Berserker Stance - Death Knight: Blood Presence, Frost Presence, Unholy Presence - Druid: Bear/Dire Bear, Cat, Travel, Aquatic, Moonkin, Tree, Flight forms - Rogue: Stealth - Priest: Shadowform Active form detected from permanent player auras (maxDurationMs == -1). Clicking an inactive stance casts the corresponding spell. Active stance shown with green border/tint; inactive stances are slightly dimmed. Spell name tooltips shown on hover using existing SpellbookScreen lookup. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 137 +++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 75502dee..49db6881 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -317,6 +317,7 @@ private: // ---- New UI renders ---- void renderActionBar(game::GameHandler& gameHandler); + void renderStanceBar(game::GameHandler& gameHandler); void renderBagBar(game::GameHandler& gameHandler); void renderXpBar(game::GameHandler& gameHandler); void renderRepBar(game::GameHandler& gameHandler); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bcd920ef..12684805 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -657,6 +657,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // ---- New UI elements ---- renderActionBar(gameHandler); + renderStanceBar(gameHandler); renderBagBar(gameHandler); renderXpBar(gameHandler); renderRepBar(gameHandler); @@ -7447,6 +7448,142 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } +// ============================================================ +// Stance / Form / Presence Bar +// Shown for Warriors (stances), Death Knights (presences), +// Druids (shapeshift forms), Rogues (stealth), Priests (Shadowform). +// Buttons display the player's known stance/form spells. +// Active form is detected by checking permanent player auras. +// ============================================================ + +void GameScreen::renderStanceBar(game::GameHandler& gameHandler) { + uint8_t playerClass = gameHandler.getPlayerClass(); + + // Stance/form spell IDs per class (ordered by display priority) + // Class IDs: 1=Warrior, 4=Rogue, 5=Priest, 6=DeathKnight, 11=Druid + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; // Battle, Defensive, Berserker + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; // Blood, Frost, Unholy + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + // Bear, DireBear, Cat, Travel, Aquatic, Moonkin, Tree, Flight, SwiftFlight + static const uint32_t rogueForms[] = { 1784 }; // Stealth + static const uint32_t priestForms[] = { 15473 }; // Shadowform + + const uint32_t* stanceArr = nullptr; + int stanceCount = 0; + switch (playerClass) { + case 1: stanceArr = warriorStances; stanceCount = 3; break; + case 6: stanceArr = dkPresences; stanceCount = 3; break; + case 11: stanceArr = druidForms; stanceCount = 9; break; + case 4: stanceArr = rogueForms; stanceCount = 1; break; + case 5: stanceArr = priestForms; stanceCount = 1; break; + default: return; + } + + // Filter to spells the player actually knows + const auto& known = gameHandler.getKnownSpells(); + std::vector available; + available.reserve(stanceCount); + for (int i = 0; i < stanceCount; ++i) + if (known.count(stanceArr[i])) available.push_back(stanceArr[i]); + + if (available.empty()) return; + + // Detect active stance from permanent player auras (maxDurationMs == -1) + uint32_t activeStance = 0; + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.isEmpty() || aura.maxDurationMs != -1) continue; + for (uint32_t sid : available) { + if (aura.spellId == sid) { activeStance = sid; break; } + } + if (activeStance) break; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + // Match the action bar slot size so they align neatly + float slotSize = 38.0f; + float spacing = 4.0f; + float padding = 6.0f; + int count = static_cast(available.size()); + + float barW = count * slotSize + (count - 1) * spacing + padding * 2.0f; + float barH = slotSize + padding * 2.0f; + + // Position the stance bar immediately to the left of the action bar + float actionSlot = 48.0f * pendingActionBarScale; + float actionBarW = 12.0f * actionSlot + 11.0f * 4.0f + 8.0f * 2.0f; + float actionBarX = (screenW - actionBarW) / 2.0f; + float actionBarH = actionSlot + 24.0f; + float actionBarY = screenH - actionBarH; + + float barX = actionBarX - barW - 8.0f; + float barY = actionBarY + (actionBarH - barH) / 2.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##StanceBar", nullptr, flags)) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < count; ++i) { + if (i > 0) ImGui::SameLine(0.0f, spacing); + ImGui::PushID(i); + + uint32_t spellId = available[i]; + bool isActive = (spellId == activeStance); + + VkDescriptorSet iconTex = assetMgr ? getSpellIcon(spellId, assetMgr) : VK_NULL_HANDLE; + + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImVec2 posEnd = ImVec2(pos.x + slotSize, pos.y + slotSize); + + // Background — green tint when active + ImU32 bgCol = isActive ? IM_COL32(30, 70, 30, 230) : IM_COL32(20, 20, 20, 220); + ImU32 borderCol = isActive ? IM_COL32(80, 220, 80, 255) : IM_COL32(80, 80, 80, 200); + dl->AddRectFilled(pos, posEnd, bgCol, 4.0f); + + if (iconTex) { + dl->AddImage((ImTextureID)(uintptr_t)iconTex, pos, posEnd); + // Darken inactive buttons slightly + if (!isActive) + dl->AddRectFilled(pos, posEnd, IM_COL32(0, 0, 0, 70), 4.0f); + } + dl->AddRect(pos, posEnd, borderCol, 4.0f, 0, 2.0f); + + ImGui::InvisibleButton("##btn", ImVec2(slotSize, slotSize)); + + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) + gameHandler.castSpell(spellId); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) ImGui::TextUnformatted(name.c_str()); + else ImGui::Text("Spell #%u", spellId); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); +} + // ============================================================ // Bag Bar // ============================================================ From c15ef915bf392e567b4f433e6beaa3dbfde17fad Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 15:18:04 -0700 Subject: [PATCH 09/64] feat: add Ctrl+1..3 keyboard shortcuts for stance/form/presence switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+1, Ctrl+2, Ctrl+3 (up to Ctrl+8 for Druids with many forms) now cast the Nth available stance spell for classes that use a stance bar. Ordering matches the stance bar UI so visual and keyboard positions align. Normal action bar keys 1–= are skipped when Ctrl is held to prevent accidental spell casts instead of stance switches. --- src/ui/game_screen.cpp | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 12684805..96102639 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2499,9 +2499,43 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { 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 bool ctrlDown = input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL); const auto& bar = gameHandler.getActionBar(); + + // Ctrl+1..Ctrl+8 → switch stance/form/presence (WoW default bindings). + // Only fires for classes that use a stance bar; same slot ordering as + // renderStanceBar: Warrior, DK, Druid, Rogue, Priest. + if (ctrlDown) { + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + static const uint32_t rogueForms[] = { 1784 }; + static const uint32_t priestForms[] = { 15473 }; + const uint32_t* stArr = nullptr; int stCnt = 0; + switch (gameHandler.getPlayerClass()) { + case 1: stArr = warriorStances; stCnt = 3; break; + case 6: stArr = dkPresences; stCnt = 3; break; + case 11: stArr = druidForms; stCnt = 9; break; + case 4: stArr = rogueForms; stCnt = 1; break; + case 5: stArr = priestForms; stCnt = 1; break; + } + if (stArr) { + const auto& known = gameHandler.getKnownSpells(); + // Build available list (same order as UI) + std::vector avail; + avail.reserve(stCnt); + for (int i = 0; i < stCnt; ++i) + if (known.count(stArr[i])) avail.push_back(stArr[i]); + // Ctrl+1 = first stance, Ctrl+2 = second, … + for (int i = 0; i < static_cast(avail.size()) && i < 8; ++i) { + if (input.isKeyJustPressed(actionBarKeys[i])) + gameHandler.castSpell(avail[i]); + } + } + } + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (input.isKeyJustPressed(actionBarKeys[i])) { + if (!ctrlDown && 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; From 98dc2a0dc789129010bc53f9c8f1fe8e2cbdb808 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 15:28:33 -0700 Subject: [PATCH 10/64] feat: show Max Level bar at max level instead of hiding XP bar When nextLevelXp==0 and playerLevel>0, render a gold fully-filled bar with centered "Max Level" text instead of hiding the XP bar entirely. Fixes missing closing brace that caused all subsequent methods to fail compilation. --- src/ui/game_screen.cpp | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 96102639..0d587f84 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7903,8 +7903,12 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { - uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); - if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) + uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); + uint32_t playerLevel = gameHandler.getPlayerLevel(); + // At max level, server sends nextLevelXp=0. Only skip entirely when we have + // no level info at all (not yet logged in / no update-field data). + const bool isMaxLevel = (nextLevelXp == 0 && playerLevel > 0); + if (nextLevelXp == 0 && !isMaxLevel) return; uint32_t currentXp = gameHandler.getPlayerXp(); uint32_t restedXp = gameHandler.getPlayerRestedXp(); @@ -7950,15 +7954,32 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); if (ImGui::Begin("##XpBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* drawList = ImGui::GetWindowDrawList(); + + if (isMaxLevel) { + // Max-level bar: fully filled in muted gold with "Max Level" label + ImU32 bgML = IM_COL32(15, 12, 5, 220); + ImU32 fgML = IM_COL32(180, 140, 40, 200); + drawList->AddRectFilled(barMin, barMax, bgML, 2.0f); + drawList->AddRectFilled(barMin, barMax, fgML, 2.0f); + drawList->AddRect(barMin, barMax, IM_COL32(100, 80, 20, 220), 2.0f); + const char* mlLabel = "Max Level"; + ImVec2 mlSz = ImGui::CalcTextSize(mlLabel); + drawList->AddText( + ImVec2(barMin.x + (barSize.x - mlSz.x) * 0.5f, + barMin.y + (barSize.y - mlSz.y) * 0.5f), + IM_COL32(255, 230, 120, 255), mlLabel); + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Level %u — Maximum level reached", playerLevel); + } else { float pct = static_cast(currentXp) / static_cast(nextLevelXp); if (pct > 1.0f) pct = 1.0f; // Custom segmented XP bar (20 bubbles) - ImVec2 barMin = ImGui::GetCursorScreenPos(); - ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); - ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); - auto* drawList = ImGui::GetWindowDrawList(); - ImU32 bg = IM_COL32(15, 15, 20, 220); ImU32 fg = IM_COL32(148, 51, 238, 255); ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion @@ -8034,6 +8055,7 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } } + } ImGui::End(); ImGui::PopStyleColor(2); From 1f1925797ff6cbd03ea5775fa51569d374c1a85f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 15:59:27 -0700 Subject: [PATCH 11/64] feat: show cooldown overlay on pet action bar spell buttons Pet spell buttons now dim and display remaining cooldown time when a spell is on cooldown, matching the feedback available on the player action bar. Clicking a pet spell while it is on cooldown is also suppressed to prevent spam-sending CMSG_PET_ACTION to the server. Cooldown time appears as a text overlay (seconds or "Nm" for minutes) and is also shown in the hover tooltip. --- src/ui/game_screen.cpp | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0d587f84..5ef476e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3508,6 +3508,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. bool autocastOn = gameHandler.isPetSpellAutocast(actionId); + // Cooldown tracking for pet spells (actionId > 6 are spell IDs) + float petCd = (actionId > 6) ? gameHandler.getSpellCooldown(actionId) : 0.0f; + bool petOnCd = (petCd > 0.0f); + ImGui::PushID(i); if (rendered > 0) ImGui::SameLine(0.0f, spacing); @@ -3522,9 +3526,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { else if (actionId == 6) builtinLabel = "Agg"; else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); - // Tint green when autocast is on. - ImVec4 tint = autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) - : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + // Dim when on cooldown; tint green when autocast is on + ImVec4 tint = petOnCd + ? ImVec4(0.35f, 0.35f, 0.35f, 0.7f) + : (autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 1.0f, 1.0f, 1.0f)); bool clicked = false; if (iconTex) { clicked = ImGui::ImageButton("##pa", @@ -3542,14 +3547,35 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100); else snprintf(label, sizeof(label), "%.3s", nm.c_str()); } - ImGui::PushStyleColor(ImGuiCol_Button, - autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) - : ImVec4(0.2f,0.2f,0.3f,0.9f)); + ImVec4 btnCol = petOnCd ? ImVec4(0.1f,0.1f,0.15f,0.9f) + : (autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) + : ImVec4(0.2f,0.2f,0.3f,0.9f)); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); ImGui::PopStyleColor(); } - if (clicked) { + // Cooldown overlay: dark fill + time text centered on the button + if (petOnCd && !builtinLabel) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* cdDL = ImGui::GetWindowDrawList(); + cdDL->AddRectFilled(bMin, bMax, IM_COL32(0, 0, 0, 140)); + char cdTxt[8]; + if (petCd >= 60.0f) + snprintf(cdTxt, sizeof(cdTxt), "%dm", static_cast(petCd / 60.0f)); + else if (petCd >= 1.0f) + snprintf(cdTxt, sizeof(cdTxt), "%d", static_cast(petCd)); + else + snprintf(cdTxt, sizeof(cdTxt), "%.1f", petCd); + ImVec2 tsz = ImGui::CalcTextSize(cdTxt); + float cx = (bMin.x + bMax.x) * 0.5f; + float cy = (bMin.y + bMax.y) * 0.5f; + cdDL->AddText(ImVec2(cx - tsz.x * 0.5f, cy - tsz.y * 0.5f), + IM_COL32(255, 255, 255, 230), cdTxt); + } + + if (clicked && !petOnCd) { // Send pet action; use current target for spells. uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; gameHandler.sendPetAction(slotVal, targetGuid); @@ -3577,6 +3603,15 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { } if (autocastOn) ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); + if (petOnCd) { + if (petCd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", + static_cast(petCd) / 60, static_cast(petCd) % 60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %.1f sec", petCd); + } ImGui::EndTooltip(); } } From 973db1665806ea7404eae4fe362266631713ce72 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 16:34:39 -0700 Subject: [PATCH 12/64] feat: add screen-space weather particle overlay (rain/snow/storm) Weather type and intensity are already tracked from SMSG_WEATHER, but only an icon was shown next to the zone name. This adds a fullscreen ImDrawList overlay that renders: - Rain (type 1): diagonal rain streaks proportional to intensity - Snow (type 2): gently swaying snowflakes with two-tone highlight - Storm (type 3): heavy rain + dark fog-vignette on screen edges Particles wrap at screen boundaries and are re-seeded on type or resolution change. Delta time is capped at 50 ms to prevent teleporting after focus loss. No heap allocations at runtime (static local arrays). --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 124 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 49db6881..6a7212b9 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -635,6 +635,7 @@ private: std::string zoneTextName_; std::string lastKnownZoneName_; void renderZoneText(); + void renderWeatherOverlay(game::GameHandler& gameHandler); // Cooldown tracker bool showCooldownTracker_ = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5ef476e8..fdf2f3d2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -745,6 +745,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderItemLootToasts(); renderResurrectFlash(); renderZoneText(); + renderWeatherOverlay(gameHandler); // World map (M key toggle handled inside) renderWorldMap(gameHandler); @@ -20172,6 +20173,129 @@ void GameScreen::renderZoneText() { IM_COL32(255, 255, 255, (int)(alpha * 255)), zoneTextName_.c_str()); } +// --------------------------------------------------------------------------- +// Screen-space weather overlay (rain / snow / storm) +// --------------------------------------------------------------------------- +void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { + uint32_t wType = gameHandler.getWeatherType(); + float intensity = gameHandler.getWeatherIntensity(); + if (wType == 0 || intensity < 0.05f) return; + + const ImGuiIO& io = ImGui::GetIO(); + float sw = io.DisplaySize.x; + float sh = io.DisplaySize.y; + if (sw <= 0.0f || sh <= 0.0f) return; + + ImDrawList* dl = ImGui::GetForegroundDrawList(); + const float dt = std::min(io.DeltaTime, 0.05f); // cap delta at 50ms to avoid teleporting particles + + if (wType == 1 || wType == 3) { + // ── Rain / Storm ───────────────────────────────────────────────────── + constexpr int MAX_DROPS = 300; + struct RainState { + float x[MAX_DROPS], y[MAX_DROPS]; + bool initialized = false; + uint32_t lastType = 0; + float lastW = 0.0f, lastH = 0.0f; + }; + static RainState rs; + + // Re-seed if weather type or screen size changed + if (!rs.initialized || rs.lastType != wType || + rs.lastW != sw || rs.lastH != sh) { + for (int i = 0; i < MAX_DROPS; ++i) { + rs.x[i] = static_cast(std::rand() % (static_cast(sw) + 200)) - 100.0f; + rs.y[i] = static_cast(std::rand() % static_cast(sh)); + } + rs.initialized = true; + rs.lastType = wType; + rs.lastW = sw; + rs.lastH = sh; + } + + const float fallSpeed = (wType == 3) ? 680.0f : 440.0f; + const float windSpeed = (wType == 3) ? 110.0f : 65.0f; + const int numDrops = static_cast(MAX_DROPS * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.28f + intensity * 0.38f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const ImU32 dropCol = IM_COL32(175, 195, 225, alphaU8); + const float dropLen = 7.0f + intensity * 7.0f; + // Normalised wind direction for the trail endpoint + const float invSpeed = 1.0f / std::sqrt(fallSpeed * fallSpeed + windSpeed * windSpeed); + const float trailDx = -windSpeed * invSpeed * dropLen; + const float trailDy = -fallSpeed * invSpeed * dropLen; + + for (int i = 0; i < numDrops; ++i) { + rs.x[i] += windSpeed * dt; + rs.y[i] += fallSpeed * dt; + if (rs.y[i] > sh + 10.0f) { + rs.y[i] = -10.0f; + rs.x[i] = static_cast(std::rand() % (static_cast(sw) + 200)) - 100.0f; + } + if (rs.x[i] > sw + 100.0f) rs.x[i] -= sw + 200.0f; + dl->AddLine(ImVec2(rs.x[i], rs.y[i]), + ImVec2(rs.x[i] + trailDx, rs.y[i] + trailDy), + dropCol, 1.0f); + } + + // Storm: dark fog-vignette at screen edges + if (wType == 3) { + const float vigAlpha = std::min(1.0f, 0.12f + intensity * 0.18f); + const ImU32 vigCol = IM_COL32(60, 65, 80, static_cast(vigAlpha * 255.0f)); + const float vigW = sw * 0.22f; + const float vigH = sh * 0.22f; + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vigW, sh), vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol); + dl->AddRectFilledMultiColor(ImVec2(sw-vigW, 0), ImVec2(sw, sh), IM_COL32_BLACK_TRANS, vigCol, vigCol, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(sw, vigH), vigCol, vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, sh-vigH),ImVec2(sw, sh), IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol, vigCol); + } + + } else if (wType == 2) { + // ── Snow ───────────────────────────────────────────────────────────── + constexpr int MAX_FLAKES = 120; + struct SnowState { + float x[MAX_FLAKES], y[MAX_FLAKES], phase[MAX_FLAKES]; + bool initialized = false; + float lastW = 0.0f, lastH = 0.0f; + }; + static SnowState ss; + + if (!ss.initialized || ss.lastW != sw || ss.lastH != sh) { + for (int i = 0; i < MAX_FLAKES; ++i) { + ss.x[i] = static_cast(std::rand() % static_cast(sw)); + ss.y[i] = static_cast(std::rand() % static_cast(sh)); + ss.phase[i] = static_cast(std::rand() % 628) * 0.01f; + } + ss.initialized = true; + ss.lastW = sw; + ss.lastH = sh; + } + + const float fallSpeed = 45.0f + intensity * 45.0f; + const int numFlakes = static_cast(MAX_FLAKES * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.5f + intensity * 0.3f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const float radius = 1.5f + intensity * 1.5f; + const float time = static_cast(ImGui::GetTime()); + + for (int i = 0; i < numFlakes; ++i) { + float sway = std::sin(time * 0.7f + ss.phase[i]) * 18.0f; + ss.x[i] += sway * dt; + ss.y[i] += fallSpeed * dt; + ss.phase[i] += dt * 0.25f; + if (ss.y[i] > sh + 5.0f) { + ss.y[i] = -5.0f; + ss.x[i] = static_cast(std::rand() % static_cast(sw)); + } + if (ss.x[i] < -5.0f) ss.x[i] += sw + 10.0f; + if (ss.x[i] > sw + 5.0f) ss.x[i] -= sw + 10.0f; + // Two-tone: bright centre dot + transparent outer ring for depth + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius, IM_COL32(220, 235, 255, alphaU8)); + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius * 0.45f, IM_COL32(245, 250, 255, std::min(255, alphaU8 + 30))); + } + } +} + // --------------------------------------------------------------------------- // Dungeon Finder window (toggle with hotkey or bag-bar button) // --------------------------------------------------------------------------- From dee33db0aa337978f83a1203a67f79676f3eb03c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 16:42:19 -0700 Subject: [PATCH 13/64] feat: show gem socket slots and socket bonus in chat item link tooltips Item tooltips shown when hovering chat links already displayed all stats, spells, and flavor text, but gem sockets were missing. Add the same socket rendering used in the inventory tooltip: - Iterate socketColor[0..2]; for each non-zero slot show a colored label (Meta / Red / Yellow / Blue Socket) in the socket's faction color - Lazy-load SpellItemEnchantment.dbc to resolve the socketBonus enchant name; fall back to "(id N)" when the record is not found - Consistent with InventoryScreen::renderItemTooltip formatting --- src/ui/game_screen.cpp | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fdf2f3d2..bef79214 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1512,6 +1512,54 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextColored(green, "+%d %s", es.statValue, nm); } } + // Gem sockets (WotLK only — socketColor != 0 means socket present) + // socketColor bitmask: 1=Meta, 2=Red, 4=Yellow, 8=Blue + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + // Socket bonus ID maps to SpellItemEnchantment.dbc — lazy-load names + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info->socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info->socketBonus); + } + } // Item spell effects (Use / Equip / Chance on Hit / Teaches) for (const auto& sp : info->spells) { if (sp.spellId == 0) continue; From dab03f2729dcb95af7eae27b59cb965ce439ac47 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 16:43:57 -0700 Subject: [PATCH 14/64] feat: show item set name and bonuses in chat item link tooltips Chat link tooltips (hover over item links in chat) were missing item set information already shown in the inventory tooltip. Now shows: - Set name with equipped/total piece count (e.g. "Tier 9 (2/5)") - Each set bonus with its piece-threshold, colored green when active and grey when inactive - Falls back to "Set (id N)" when ItemSet.dbc is unavailable Lazy-loads ItemSet.dbc on first hover; consistent with InventoryScreen::renderItemTooltip formatting. --- src/ui/game_screen.cpp | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bef79214..5bd66187 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1560,6 +1560,76 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info->socketBonus); } } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = {"Item0","Item1","Item2","Item3","Item4","Item5","Item6","Item7","Item8","Item9"}; + static const char* spellKeys[10] = {"Spell0","Spell1","Spell2","Spell3","Spell4","Spell5","Spell6","Spell7","Spell8","Spell9"}; + static const char* thrKeys[10] = {"Threshold0","Threshold1","Threshold2","Threshold3","Threshold4","Threshold5","Threshold6","Threshold7","Threshold8","Threshold9"}; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info->itemSetId); + } + } // Item spell effects (Use / Equip / Chance on Hit / Teaches) for (const auto& sp : info->spells) { if (sp.spellId == 0) continue; From 7e6de75e8ab1e6f8138401b4375cb3b76d5459a3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 16:47:33 -0700 Subject: [PATCH 15/64] feat: show skill, reputation, class and race requirements in chat link tooltips Chat item link tooltips now match InventoryScreen for required-skill (SkillLine.dbc), required-reputation (Faction.dbc), class restriction, and race restriction. Red text when the player does not meet the requirement, grey otherwise. --- src/ui/game_screen.cpp | 132 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5bd66187..32edd51c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1649,6 +1649,138 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Required level if (info->requiredLevel > 1) ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill (e.g. "Requires Blacksmithing (300)") + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info->allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClasses[] = { + { 1, "Warrior" }, + { 2, "Paladin" }, + { 4, "Hunter" }, + { 8, "Rogue" }, + { 16, "Priest" }, + { 32, "Death Knight" }, + { 64, "Shaman" }, + { 128, "Mage" }, + { 256, "Warlock" }, + { 1024, "Druid" }, + }; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction (e.g. "Races: Night Elf, Human") + if (info->allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRaces[] = { + { 1, "Human" }, + { 2, "Orc" }, + { 4, "Dwarf" }, + { 8, "Night Elf" }, + { 16, "Undead" }, + { 32, "Tauren" }, + { 64, "Gnome" }, + { 128, "Troll" }, + { 512, "Blood Elf" }, + { 1024, "Draenei" }, + }; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) if (!info->description.empty()) { ImGui::Spacing(); From cb99dbaea4048fa37bb0ccbb3791cfad1464e4ba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 16:54:40 -0700 Subject: [PATCH 16/64] feat: add elemental resistances and full spell descriptions to chat link tooltips Chat item link tooltips now show per-school elemental resistances (Holy/Fire/Nature/Frost/Shadow/Arcane) when non-zero, matching the inventory tooltip. Spell effect text (Use/Equip/Chance on Hit) now shows the full spell description instead of just the spell name, consistent with InventoryScreen::renderItemTooltip. --- src/ui/game_screen.cpp | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 32edd51c..bed50def 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1482,6 +1482,19 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (info->armor > 0) { ImGui::Text("%d Armor", info->armor); } + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) if (!info->extraStats.empty()) { auto statName = [](uint32_t t) -> const char* { @@ -1641,10 +1654,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { case 5: triggerLabel = "Teaches"; break; } if (!triggerLabel) continue; - std::string spName = spellbookScreen.lookupSpellName(sp.spellId, assetMgr); - if (!spName.empty()) - ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), - "%s: %s", triggerLabel, spName.c_str()); + // Use full spell description if available (matches inventory tooltip style) + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } } // Required level if (info->requiredLevel > 1) From 1e80e294f0045083f107a5e12a25213b9c2eac61 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 16:56:37 -0700 Subject: [PATCH 17/64] feat: add Heroic and Unique-Equipped indicators to chat link tooltips Chat item link tooltips now show "Heroic" (green) for items with ITEM_FLAG_HEROIC_TOOLTIP (0x8) and "Unique-Equipped" for items with ITEM_FLAG_UNIQUE_EQUIPPABLE (0x1000000), matching InventoryScreen. "Unique" text is now gold-colored to match as well. --- src/ui/game_screen.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bed50def..8d65c074 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1382,6 +1382,12 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + // Bind type (appears right under name in WoW) switch (info->bindType) { case 1: ImGui::TextDisabled("Binds when picked up"); break; @@ -1389,9 +1395,11 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { case 3: ImGui::TextDisabled("Binds when used"); break; case 4: ImGui::TextDisabled("Quest Item"); break; } - // Unique + // Unique / Unique-Equipped if (info->maxCount == 1) - ImGui::TextDisabled("Unique"); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); // Slot type if (info->inventoryType > 0) { From d1a392cd0e179cc9e6d81a4a51ce761100bd4c73 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:00:46 -0700 Subject: [PATCH 18/64] feat: add colors for SKILL, LOOT, BG system, and monster chat types Added distinct colors for chat types that previously fell through to the gray default: SKILL (cyan), LOOT (light purple), GUILD_ACHIEVEMENT (gold), MONSTER_WHISPER/RAID_BOSS_WHISPER (pink), RAID_BOSS_EMOTE (orange), MONSTER_PARTY (blue), BG_SYSTEM_NEUTRAL/ALLIANCE/HORDE (gold/blue/red), and AFK/DND (light gray). --- src/ui/game_screen.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8d65c074..277ecf7b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6682,6 +6682,28 @@ ImVec4 GameScreen::getChatTypeColor(game::ChatType type) const { return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink case game::ChatType::ACHIEVEMENT: return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow + case game::ChatType::GUILD_ACHIEVEMENT: + return ImVec4(1.0f, 0.84f, 0.0f, 1.0f); // Gold + case game::ChatType::SKILL: + return ImVec4(0.0f, 0.8f, 1.0f, 1.0f); // Cyan + case game::ChatType::LOOT: + return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple + case game::ChatType::MONSTER_WHISPER: + case game::ChatType::RAID_BOSS_WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) + case game::ChatType::RAID_BOSS_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::MONSTER_PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) + case game::ChatType::BG_SYSTEM_NEUTRAL: + return ImVec4(1.0f, 0.84f, 0.0f, 1.0f); // Gold + case game::ChatType::BG_SYSTEM_ALLIANCE: + return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue + case game::ChatType::BG_SYSTEM_HORDE: + return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red + case game::ChatType::AFK: + case game::ChatType::DND: + return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray default: return ImVec4(0.7f, 0.7f, 0.7f, 1.0f); // Gray } From caad20285b22277696cba530b13a305813cdec73 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:16:56 -0700 Subject: [PATCH 19/64] feat: add hover tooltips to character sheet stats panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering over Armor, primary stats (Strength/Agility/Stamina/ Intellect/Spirit), and secondary rating stats now shows a brief description of the stat's in-game effect — matching WoW's native character screen behavior. Uses ImGui::BeginGroup/EndGroup to make multi-widget rows (stat + green bonus) respond to a single IsItemHovered check. --- src/ui/inventory_screen.cpp | 82 ++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 09022ec5..42c03e8e 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1738,12 +1738,27 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); ImVec4 gray(0.6f, 0.6f, 0.6f, 1.0f); + static const char* kStatTooltips[5] = { + "Increases your melee attack power by 2.\nIncreases your block value.", + "Increases your Armor.\nIncreases ranged attack power by 2.\nIncreases your chance to dodge attacks and score critical strikes.", + "Increases Health by 10 per point.", + "Increases your Mana pool.\nIncreases your chance to score a critical strike with spells.", + "Increases Health and Mana regeneration." + }; + // Armor (no base) + ImGui::BeginGroup(); if (totalArmor > 0) { ImGui::TextColored(gold, "Armor: %d", totalArmor); } else { ImGui::TextColored(gray, "Armor: 0"); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("Reduces damage taken from physical attacks."); + ImGui::EndTooltip(); + } if (serverStats) { // Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus. @@ -1753,6 +1768,7 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play for (int i = 0; i < 5; ++i) { int32_t total = serverStats[i]; int32_t bonus = itemBonuses[i]; + ImGui::BeginGroup(); if (bonus > 0) { ImGui::TextColored(white, "%s: %d", statNames[i], total); ImGui::SameLine(); @@ -1760,12 +1776,19 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } else { ImGui::TextColored(gray, "%s: %d", statNames[i], total); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", kStatTooltips[i]); + ImGui::EndTooltip(); + } } } else { // Fallback: estimated base (20 + level) plus item query bonuses. int32_t baseStat = 20 + static_cast(playerLevel); - auto renderStat = [&](const char* name, int32_t equipBonus) { + auto renderStat = [&](const char* name, int32_t equipBonus, const char* tooltip) { int32_t total = baseStat + equipBonus; + ImGui::BeginGroup(); if (equipBonus > 0) { ImGui::TextColored(white, "%s: %d", name, total); ImGui::SameLine(); @@ -1773,12 +1796,18 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } else { ImGui::TextColored(gray, "%s: %d", name, total); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", tooltip); + ImGui::EndTooltip(); + } }; - renderStat("Strength", itemStr); - renderStat("Agility", itemAgi); - renderStat("Stamina", itemSta); - renderStat("Intellect", itemInt); - renderStat("Spirit", itemSpi); + renderStat("Strength", itemStr, kStatTooltips[0]); + renderStat("Agility", itemAgi, kStatTooltips[1]); + renderStat("Stamina", itemSta, kStatTooltips[2]); + renderStat("Intellect", itemInt, kStatTooltips[3]); + renderStat("Spirit", itemSpi, kStatTooltips[4]); } // Secondary stats from equipped items @@ -1789,27 +1818,34 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play if (hasSecondary) { ImGui::Spacing(); ImGui::Separator(); - auto renderSecondary = [&](const char* name, int32_t val) { + auto renderSecondary = [&](const char* name, int32_t val, const char* tooltip) { if (val > 0) { + ImGui::BeginGroup(); ImGui::TextColored(green, "+%d %s", val, name); + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", tooltip); + ImGui::EndTooltip(); + } } }; - renderSecondary("Attack Power", itemAP); - renderSecondary("Spell Power", itemSP); - renderSecondary("Hit Rating", itemHit); - renderSecondary("Crit Rating", itemCrit); - renderSecondary("Haste Rating", itemHaste); - renderSecondary("Resilience", itemResil); - renderSecondary("Expertise", itemExpertise); - renderSecondary("Defense Rating", itemDefense); - renderSecondary("Dodge Rating", itemDodge); - renderSecondary("Parry Rating", itemParry); - renderSecondary("Block Rating", itemBlock); - renderSecondary("Block Value", itemBlockVal); - renderSecondary("Armor Penetration",itemArmorPen); - renderSecondary("Spell Penetration",itemSpellPen); - renderSecondary("Mana per 5 sec", itemMp5); - renderSecondary("Health per 5 sec", itemHp5); + renderSecondary("Attack Power", itemAP, "Increases the damage of your melee and ranged attacks."); + renderSecondary("Spell Power", itemSP, "Increases the damage and healing of your spells."); + renderSecondary("Hit Rating", itemHit, "Reduces the chance your attacks will miss."); + renderSecondary("Crit Rating", itemCrit, "Increases your critical strike chance."); + renderSecondary("Haste Rating", itemHaste, "Increases attack speed and spell casting speed."); + renderSecondary("Resilience", itemResil, "Reduces the chance you will be critically hit.\nReduces damage taken from critical hits."); + renderSecondary("Expertise", itemExpertise,"Reduces the chance your attacks will be dodged or parried."); + renderSecondary("Defense Rating", itemDefense, "Reduces the chance enemies will critically hit you."); + renderSecondary("Dodge Rating", itemDodge, "Increases your chance to dodge attacks."); + renderSecondary("Parry Rating", itemParry, "Increases your chance to parry attacks."); + renderSecondary("Block Rating", itemBlock, "Increases your chance to block attacks with your shield."); + renderSecondary("Block Value", itemBlockVal, "Increases the amount of damage your shield blocks."); + renderSecondary("Armor Penetration",itemArmorPen, "Reduces the armor of your target."); + renderSecondary("Spell Penetration",itemSpellPen, "Reduces your target's resistance to your spells."); + renderSecondary("Mana per 5 sec", itemMp5, "Restores mana every 5 seconds, even while casting."); + renderSecondary("Health per 5 sec", itemHp5, "Restores health every 5 seconds."); } // Elemental resistances from server update fields From dcf9aeed92fefe5132acacfaf9832476588feb8f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:20:24 -0700 Subject: [PATCH 20/64] fix: show SMSG_CAST_FAILED reason in UIError overlay handleCastFailed was only posting to chat; now also calls addUIError so mid-cast server rejections (e.g. "Interrupted") show the same red on-screen overlay as SMSG_CAST_RESULT failures already did. --- src/game/game_handler.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2c8b704c..32eb8bb3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18157,21 +18157,20 @@ void GameHandler::handleCastFailed(network::Packet& packet) { } } - // Add system message about failed cast with readable reason + // Show failure reason in the UIError overlay and in chat int powerType = -1; auto playerEntity = entityManager.getEntity(playerGuid); if (auto playerUnit = std::dynamic_pointer_cast(playerEntity)) { powerType = playerUnit->getPowerType(); } const char* reason = getSpellCastResultString(data.result, powerType); + std::string errMsg = reason ? reason + : ("Spell cast failed (error " + std::to_string(data.result) + ")"); + addUIError(errMsg); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - if (reason) { - msg.message = reason; - } else { - msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")"; - } + msg.message = errMsg; addLocalChatMessage(msg); } From fba6aba80d772bbe2e2d4adebbe510ed25c66969 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:24:23 -0700 Subject: [PATCH 21/64] fix: show inventory, mount, and socket errors in UIError overlay Several server-reported action failures were posting to chat only without firing the red on-screen UIError overlay: - SMSG_INVENTORY_CHANGE_FAILURE: bag full, wrong slot, can't equip, etc. - SMSG_MOUNTRESULT / SMSG_DISMOUNTRESULT: mount denied errors - SMSG_QUESTLOG_FULL: quest log at capacity - SMSG_SOCKET_GEMS_RESULT: gem socketing failure All now call addUIError() in addition to addSystemChatMessage() so players see the error immediately on screen without looking at chat. --- src/game/game_handler.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 32eb8bb3..a80777c4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2817,7 +2817,9 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); if (result != 4) { const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." }; - addSystemChatMessage(result < 4 ? msgs[result] : "Cannot mount."); + std::string mountErr = result < 4 ? msgs[result] : "Cannot mount."; + addUIError(mountErr); + addSystemChatMessage(mountErr); } break; } @@ -2825,7 +2827,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 result: 0=ok, others=error if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t result = packet.readUInt32(); - if (result != 0) addSystemChatMessage("Cannot dismount here."); + if (result != 0) { addUIError("Cannot dismount here."); addSystemChatMessage("Cannot dismount here."); } break; } @@ -4677,6 +4679,7 @@ void GameHandler::handlePacket(network::Packet& packet) { default: break; } std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ")."; + addUIError(msg); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) @@ -4913,6 +4916,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Gems socketed successfully."); } else { + addUIError("Failed to socket gems."); addSystemChatMessage("Failed to socket gems."); } LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result); @@ -5440,6 +5444,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_QUESTLOG_FULL: // Zero-payload notification: the player's quest log is full (25 quests). + addUIError("Your quest log is full."); addSystemChatMessage("Your quest log is full."); LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity"); break; From 495dfb7aae51911c4ee9156b03588f33c0dc9e45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:25:27 -0700 Subject: [PATCH 22/64] fix: show buy/sell vendor failures in UIError overlay SMSG_BUY_FAILED ("Not enough money", "Sold out", etc.) and SMSG_SELL_ITEM non-zero results now call addUIError() so the error appears on screen alongside the chat message. --- src/game/game_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a80777c4..3f4db8c9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4582,6 +4582,7 @@ void GameHandler::handlePacket(network::Packet& packet) { "Unknown error", "Only empty bag" }; const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error"; + addUIError(std::string("Sell failed: ") + msg); addSystemChatMessage(std::string("Sell failed: ") + msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) @@ -4744,6 +4745,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case 6: msg = "You can't carry any more items."; break; default: break; } + addUIError(msg); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) From 220f1b177cbcc25611ffa2e6e0ba6bd99149c7b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:36:25 -0700 Subject: [PATCH 23/64] fix: surface trainer/resurrect/innkeeper/difficulty errors in UIError overlay - SMSG_TRAINER_BUY_FAILED: "Cannot learn [spell]" now appears as red overlay - SMSG_RESURRECT_FAILED: all resurrection failure reasons shown as UIError - SMSG_BINDZONEREPLY error: "Too far from innkeeper" shown as UIError - SMSG_CHANGEPLAYER_DIFFICULTY_RESULT error: reason shown as UIError - SMSG_INVENTORY_CHANGE_FAILURE case 1: level-gated equip error now calls addUIError before the early break, matching all other inventory error paths --- src/game/game_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3f4db8c9..c56619a2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2541,6 +2541,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Your home is now set to this location."); } else { + addUIError("You are too far from the innkeeper."); addSystemChatMessage("You are too far from the innkeeper."); } } @@ -2559,6 +2560,7 @@ void GameHandler::handlePacket(network::Packet& packet) { "You must be in a raid group", "Player not in group" }; const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; + addUIError(std::string("Cannot change difficulty: ") + msg); addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); } } @@ -3959,6 +3961,7 @@ void GameHandler::handlePacket(network::Packet& packet) { else if (errorCode == 2) msg += " (already known)"; else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")"; + addUIError(msg); addSystemChatMessage(msg); // Play error sound so the player notices the failure if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -4616,8 +4619,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (requiredLevel > 0) { std::snprintf(levelBuf, sizeof(levelBuf), "You must reach level %u to use that item.", requiredLevel); + addUIError(levelBuf); addSystemChatMessage(levelBuf); } else { + addUIError("You must reach a higher level to use that item."); addSystemChatMessage("You must reach a higher level to use that item."); } break; @@ -4955,6 +4960,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const char* msg = (reason == 1) ? "The target cannot be resurrected right now." : (reason == 2) ? "Cannot resurrect in this area." : "Resurrection failed."; + addUIError(msg); addSystemChatMessage(msg); LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason); } From b22183b00012dc224aefe6fdd0ed526c833e6cf0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:39:02 -0700 Subject: [PATCH 24/64] fix: surface fishing/BG/party/instance/zone notifications in UIError overlay - Fishing bobber splash: "A fish is on your line!" shown as UIError (time-critical) - SMSG_BATTLEFIELD_PORT_DENIED: shown as UIError - SMSG_INSTANCE_RESET_FAILED: failure reason shown as UIError - SMSG_GROUP_DESTROYED: "Party disbanded" shown as UIError - SMSG_CORPSE_NOT_IN_INSTANCE: shown as UIError - SMSG_ZONE_UNDER_ATTACK: "[Zone] is under attack!" shown as UIError - SMSG_AREA_TRIGGER_MESSAGE: zone/area entry messages shown as UIError --- src/game/game_handler.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c56619a2..79037c9c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2567,6 +2567,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE: + addUIError("Your corpse is outside this instance."); addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); break; case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: { @@ -3461,6 +3462,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::string msg = areaName.empty() ? std::string("A zone is under attack!") : (areaName + " is under attack!"); + addUIError(msg); addSystemChatMessage(msg); } break; @@ -3592,6 +3594,7 @@ void GameHandler::handlePacket(network::Packet& packet) { partyData.members.clear(); partyData.memberCount = 0; partyData.leaderGuid = 0; + addUIError("Your party has been disbanded."); addSystemChatMessage("Your party has been disbanded."); LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); break; @@ -4480,6 +4483,7 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t len =*/ packet.readUInt32(); std::string msg = packet.readString(); if (!msg.empty()) { + addUIError(msg); addSystemChatMessage(msg); areaTriggerMsgs_.push_back(msg); } @@ -4988,6 +4992,7 @@ void GameHandler::handlePacket(network::Packet& packet) { auto go = std::static_pointer_cast(goEnt); auto* info = getCachedGameObjectInfo(go->getEntry()); if (info && info->type == 17) { // GO_TYPE_FISHINGNODE + addUIError("A fish is on your line!"); addSystemChatMessage("A fish is on your line!"); // Play a distinctive UI sound to alert the player if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -5555,6 +5560,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleBattlefieldList(packet); break; case Opcode::SMSG_BATTLEFIELD_PORT_DENIED: + addUIError("Battlefield port denied."); addSystemChatMessage("Battlefield port denied."); break; case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: @@ -5650,6 +5656,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addUIError("Cannot reset " + mapLabel + ": " + reasonMsg); addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason); } From 0f2f9ff78d73354750c76e160f24ed905188e2c3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:43:10 -0700 Subject: [PATCH 25/64] fix: show group kick/party command failures in UIError overlay - handleGroupUninvite: "You have been removed from the group." now shown as UIError - handlePartyCommandResult: all party errors (group full, not leader, wrong faction, ignoring you, etc.) now also shown as UIError overlay --- src/game/game_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 79037c9c..19f75de4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18894,6 +18894,7 @@ void GameHandler::handleGroupUninvite(network::Packet& packet) { msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = "You have been removed from the group."; + addUIError("You have been removed from the group."); addLocalChatMessage(msg); } @@ -18928,6 +18929,7 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { static_cast(data.result)); } + addUIError(buf); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; From 5ad849666d12bcb75742648f93fead2810d925d1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:45:45 -0700 Subject: [PATCH 26/64] fix: surface pet/raid/talent/instakill errors in UIError overlay - SMSG_PET_TAME_FAILURE: "Failed to tame: [reason]" shown as UIError - SMSG_RAID_GROUP_ONLY: "Must be in raid group" shown as UIError - SMSG_RAID_READY_CHECK_ERROR: all ready check failures shown as UIError - SMSG_RESET_FAILED_NOTIFY: instance reset failure shown as UIError - SMSG_TALENTS_INVOLUNTARILY_RESET: talents reset notification shown as UIError - SMSG_EQUIPMENT_SET_USE_RESULT failure: shown as UIError - SMSG_SPELLINSTAKILLLOG (player victim): instakill notification shown as UIError --- src/game/game_handler.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 19f75de4..7f61edf7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2049,6 +2049,7 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t reason = packet.readUInt8(); const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason"; std::string s = std::string("Failed to tame: ") + msg; + addUIError(s); addSystemChatMessage(s); } break; @@ -6111,6 +6112,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Clear cached talent data so the talent screen reflects the reset. learnedTalents_[0].clear(); learnedTalents_[1].clear(); + addUIError("Your talents have been reset by the server."); addSystemChatMessage("Your talents have been reset by the server."); packet.setReadPos(packet.getSize()); break; @@ -6205,7 +6207,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); - if (result != 0) addSystemChatMessage("Failed to equip item set."); + if (result != 0) { addUIError("Failed to equip item set."); addSystemChatMessage("Failed to equip item set."); } } break; } @@ -6759,6 +6761,7 @@ void GameHandler::handlePacket(network::Packet& packet) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); + addUIError("You were killed by an instant-kill effect."); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, @@ -7288,6 +7291,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Instance/raid errors ---- case Opcode::SMSG_RAID_GROUP_ONLY: { + addUIError("You must be in a raid group to enter this instance."); addSystemChatMessage("You must be in a raid group to enter this instance."); packet.setReadPos(packet.getSize()); break; @@ -7295,13 +7299,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_RAID_READY_CHECK_ERROR: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t err = packet.readUInt8(); - if (err == 0) addSystemChatMessage("Ready check failed: not in a group."); - else if (err == 1) addSystemChatMessage("Ready check failed: in instance."); - else addSystemChatMessage("Ready check failed."); + if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); } + else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); } + else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); } } break; } case Opcode::SMSG_RESET_FAILED_NOTIFY: { + addUIError("Cannot reset instance: another player is still inside."); addSystemChatMessage("Cannot reset instance: another player is still inside."); packet.setReadPos(packet.getSize()); break; From 8411c39eafa7074382457213abb6fb0ef3d2a8fd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:48:04 -0700 Subject: [PATCH 27/64] fix: surface rename/stable/durability loss errors in UIError overlay - SMSG_CHAR_RENAME error: "Rename failed: [reason]" shown as UIError - SMSG_STABLE_RESULT failure (0x09): stable error shown as UIError - SMSG_DURABILITY_DAMAGE_DEATH: durability loss % shown as UIError overlay --- src/game/game_handler.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7f61edf7..b594c8e8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2372,7 +2372,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case 0x06: msg = "Pet retrieved from stable."; break; case 0x07: msg = "Stable slot purchased."; break; case 0x08: msg = "Stable list updated."; break; - case 0x09: msg = "Stable failed: not enough money or other error."; break; + case 0x09: msg = "Stable failed: not enough money or other error."; + addUIError(msg); break; default: break; } if (msg) addSystemChatMessage(msg); @@ -2528,8 +2529,10 @@ void GameHandler::handlePacket(network::Packet& packet) { "Character name does not meet requirements.", // 7 }; const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; - addSystemChatMessage(errMsg ? std::string("Rename failed: ") + errMsg - : "Character rename failed."); + std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg + : "Character rename failed."; + addUIError(renameErr); + addSystemChatMessage(renameErr); } LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); } @@ -3558,6 +3561,7 @@ void GameHandler::handlePacket(network::Packet& packet) { char buf[80]; std::snprintf(buf, sizeof(buf), "You have lost %u%% of your gear's durability due to death.", pct); + addUIError(buf); addSystemChatMessage(buf); } break; From cd39cd821f1b6628ac9d8d1fa39ef18b10bec320 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:49:06 -0700 Subject: [PATCH 28/64] fix: show zone transfer failures and rename errors in UIError overlay - SMSG_TRANSFER_ABORTED: all zone/instance portal rejection reasons shown as UIError (expansion required, instance full, too many instances, zone in combat, etc.) --- src/game/game_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b594c8e8..a5e47d64 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5528,6 +5528,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case 0x0C: abortMsg = "Transfer aborted."; break; default: abortMsg = "Transfer aborted."; break; } + addUIError(abortMsg); addSystemChatMessage(abortMsg); break; } From 2d00f00261a793a35fd5580ab35ac6c6bc40706f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 17:56:53 -0700 Subject: [PATCH 29/64] fix: surface LFG/auction/chat/pet errors in UIError overlay Add addUIError() alongside addSystemChatMessage() for: - SMSG_CHAT_WRONG_FACTION / SMSG_CHAT_NOT_IN_PARTY / SMSG_CHAT_RESTRICTED - SMSG_LFG_JOIN_RESULT failure, LFG proposal failure (state=0), LFG role check missing-role failure - SMSG_AUCTION_COMMAND_RESULT error cases (bid/post/cancel/buyout) - SMSG_PLAYERBINDERROR (hearthstone not bound / bind failed) - SMSG_READ_ITEM_FAILED - SMSG_PET_NAME_INVALID Consistent with the rest of the error-overlay pass: players now see these failures as the red on-screen overlay text, not just in chat. --- src/game/game_handler.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a5e47d64..ba36d474 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1849,12 +1849,15 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CHAT_WRONG_FACTION: + addUIError("You cannot send messages to members of that faction."); addSystemChatMessage("You cannot send messages to members of that faction."); break; case Opcode::SMSG_CHAT_NOT_IN_PARTY: + addUIError("You are not in a party."); addSystemChatMessage("You are not in a party."); break; case Opcode::SMSG_CHAT_RESTRICTED: + addUIError("You cannot send chat messages in this area."); addSystemChatMessage("You cannot send chat messages in this area."); break; @@ -7286,10 +7289,13 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PLAYERBINDERROR: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t error = packet.readUInt32(); - if (error == 0) + if (error == 0) { + addUIError("Your hearthstone is not bound."); addSystemChatMessage("Your hearthstone is not bound."); - else + } else { + addUIError("Hearthstone bind failed."); addSystemChatMessage("Hearthstone bind failed."); + } } break; } @@ -7439,6 +7445,7 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: + addUIError("You cannot read this item."); addSystemChatMessage("You cannot read this item."); packet.setReadPos(packet.getSize()); break; @@ -7607,6 +7614,7 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PET_NAME_INVALID: + addUIError("That pet name is invalid. Please choose a different name."); addSystemChatMessage("That pet name is invalid. Please choose a different name."); packet.setReadPos(packet.getSize()); break; @@ -16211,6 +16219,7 @@ void GameHandler::handleLfgJoinResult(network::Packet& packet) { } else { const char* msg = lfgJoinResultString(result); std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); + addUIError(errMsg); addSystemChatMessage(errMsg); LOG_INFO("SMSG_LFG_JOIN_RESULT: result=", static_cast(result), " state=", static_cast(state)); @@ -16256,6 +16265,7 @@ void GameHandler::handleLfgProposalUpdate(network::Packet& packet) { case 0: lfgState_ = LfgState::Queued; lfgProposalId_ = 0; + addUIError("Dungeon Finder: Group proposal failed."); addSystemChatMessage("Dungeon Finder: Group proposal failed."); break; case 1: { @@ -16299,6 +16309,7 @@ void GameHandler::handleLfgRoleCheckUpdate(network::Packet& packet) { LOG_INFO("LFG role check finished"); } else if (roleCheckState == 3) { lfgState_ = LfgState::None; + addUIError("Dungeon Finder: Role check failed — missing required role."); addSystemChatMessage("Dungeon Finder: Role check failed — missing required role."); } else if (roleCheckState == 2) { lfgState_ = LfgState::RoleCheck; @@ -23969,6 +23980,7 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) { "DB error", "Restricted account"}; const char* errName = (result.errorCode < 9) ? errors[result.errorCode] : "Unknown"; std::string msg = std::string("Auction ") + actionName + " failed: " + errName; + addUIError(msg); addSystemChatMessage(msg); } LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName, From 06ad676be18cd5c9c95b4b94eeee47b17e2ba56c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 18:08:27 -0700 Subject: [PATCH 30/64] fix: surface barber shop, NPC, and LFG autojoin errors in UIError overlay Add addUIError() for remaining error-only chat-message cases: - SMSG_BARBER_SHOP_RESULT non-zero result (not enough money, wrong location, must stand up) - SMSG_NPC_WONT_TALK ("That creature can't talk to you right now") - SMSG_LFG_AUTOJOIN_FAILED and SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER Completes the UIError improvement pass: all server-reported failure events now surface as the red on-screen overlay, not chat-only. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ba36d474..fe593980 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4851,6 +4851,7 @@ void GameHandler::handlePacket(network::Packet& packet) { : (result == 2) ? "You are not at a barber shop." : (result == 3) ? "You must stand up to use the barber shop." : "Barber shop unavailable."; + addUIError(msg); addSystemChatMessage(msg); } LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); @@ -6250,12 +6251,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); (void)result; } + addUIError("Dungeon Finder: Auto-join failed."); addSystemChatMessage("Dungeon Finder: Auto-join failed."); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: // No eligible players found for auto-join + addUIError("Dungeon Finder: No players available for auto-join."); addSystemChatMessage("Dungeon Finder: No players available for auto-join."); packet.setReadPos(packet.getSize()); break; @@ -7513,6 +7516,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- NPC not responding ---- case Opcode::SMSG_NPC_WONT_TALK: + addUIError("That creature can't talk to you right now."); addSystemChatMessage("That creature can't talk to you right now."); packet.setReadPos(packet.getSize()); break; From 315adfbe938cdf0450b7f5ec8c8228f6c0a5342b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 18:23:05 -0700 Subject: [PATCH 31/64] feat: implement SMSG_PLAY_SPELL_VISUAL with SpellVisual DBC chain lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse SMSG_PLAY_SPELL_VISUAL (casterGuid + visualId) and spawn a transient M2 spell effect at the caster's world position. DBC chain: SpellVisual.dbc → SpellVisualKit.dbc → SpellVisualEffectName.dbc Lookup priority: CastKit.SpecialEffect0, fallback to MissileModel. Models are lazy-loaded and cached by path; instances auto-expire after 3.5s. DBC layouts added to all four expansion layout files (Classic/TBC/WotLK/Turtle). --- Data/expansions/classic/dbc_layouts.json | 9 ++ Data/expansions/tbc/dbc_layouts.json | 9 ++ Data/expansions/turtle/dbc_layouts.json | 9 ++ Data/expansions/wotlk/dbc_layouts.json | 9 ++ include/rendering/renderer.hpp | 16 +++ src/game/game_handler.cpp | 20 ++- src/rendering/renderer.cpp | 176 +++++++++++++++++++++++ 7 files changed, 245 insertions(+), 3 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 5c550c81..4ee5ffb1 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -95,5 +95,14 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 26ac235e..d9ad4351 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -111,5 +111,14 @@ "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, "Threshold8": 46, "Threshold9": 47 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index c5a3948e..a76d065c 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -108,5 +108,14 @@ "Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33, "Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37, "Threshold8": 38, "Threshold9": 39 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 73d50a87..a332f041 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -116,5 +116,14 @@ }, "LFGDungeons": { "ID": 0, "Name": 1 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 07c6ebd6..7c08a738 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -152,6 +153,9 @@ public: void playEmote(const std::string& emoteName); void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); + + // Spell visual effects (SMSG_PLAY_SPELL_VISUAL) + void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static uint32_t getEmoteDbcId(const std::string& emoteName); @@ -323,6 +327,18 @@ private: glm::mat4 computeLightSpaceMatrix(); pipeline::AssetManager* cachedAssetManager = nullptr; + + // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL + struct SpellVisualInstance { uint32_t instanceId; float elapsed; }; + std::vector activeSpellVisuals_; + std::unordered_map spellVisualModelPath_; // visualId → resolved M2 path + std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId + uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799 + bool spellVisualDbcLoaded_ = false; + void loadSpellVisualDbc(); + void updateSpellVisuals(float deltaTime); + static constexpr float SPELL_VISUAL_DURATION = 3.5f; + uint32_t currentZoneId = 0; std::string currentZoneName; bool inTavern_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fe593980..6709e6c3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3265,10 +3265,24 @@ void GameHandler::handlePacket(network::Packet& packet) { handleSpellDamageLog(packet); break; case Opcode::SMSG_PLAY_SPELL_VISUAL: { - // Minimal parse: uint64 casterGuid, uint32 visualId + // uint64 casterGuid + uint32 visualId if (packet.getSize() - packet.getReadPos() < 12) break; - packet.readUInt64(); - packet.readUInt32(); + uint64_t casterGuid = packet.readUInt64(); + uint32_t visualId = packet.readUInt32(); + if (visualId == 0) break; + // Resolve caster world position and spawn the effect + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (casterGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(casterGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(visualId, spawnPos); break; } case Opcode::SMSG_SPELLHEALLOG: diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 67a637bb..e04ca9ea 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2627,6 +2627,180 @@ void Renderer::stopChargeEffect() { } } +// ─── Spell Visual Effects ──────────────────────────────────────────────────── + +void Renderer::loadSpellVisualDbc() { + if (spellVisualDbcLoaded_) return; + spellVisualDbcLoaded_ = true; // Set early to prevent re-entry on failure + + if (!cachedAssetManager) { + cachedAssetManager = core::Application::getInstance().getAssetManager(); + } + if (!cachedAssetManager) return; + + auto* layout = pipeline::getActiveDBCLayout(); + const pipeline::DBCFieldMap* svLayout = layout ? layout->getLayout("SpellVisual") : nullptr; + const pipeline::DBCFieldMap* kitLayout = layout ? layout->getLayout("SpellVisualKit") : nullptr; + const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr; + + uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2; + uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8; + uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11; + uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5; + uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2; + + // Load SpellVisualEffectName.dbc — ID → M2 path + auto fxDbc = cachedAssetManager->loadDBC("SpellVisualEffectName.dbc"); + if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) { + LOG_DEBUG("SpellVisual: SpellVisualEffectName.dbc unavailable (fc=", + fxDbc ? fxDbc->getFieldCount() : 0, ")"); + return; + } + std::unordered_map effectPaths; // effectNameId → path + for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) { + uint32_t id = fxDbc->getUInt32(i, 0); + std::string p = fxDbc->getString(i, fxFilePathField); + if (id && !p.empty()) effectPaths[id] = p; + } + + // Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID + auto kitDbc = cachedAssetManager->loadDBC("SpellVisualKit.dbc"); + std::unordered_map kitToEffectName; // kitId → effectNameId + if (kitDbc && kitDbc->isLoaded()) { + uint32_t fc = kitDbc->getFieldCount(); + for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) { + uint32_t kitId = kitDbc->getUInt32(i, 0); + if (!kitId) continue; + // Prefer SpecialEffect0, fall back to BaseEffect + uint32_t eff = 0; + if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field); + if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField); + if (eff) kitToEffectName[kitId] = eff; + } + } + + // Load SpellVisual.dbc — visualId → M2 path via kit chain + auto svDbc = cachedAssetManager->loadDBC("SpellVisual.dbc"); + if (!svDbc || !svDbc->isLoaded()) { + LOG_DEBUG("SpellVisual: SpellVisual.dbc unavailable"); + return; + } + uint32_t svFc = svDbc->getFieldCount(); + uint32_t loaded = 0; + for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) { + uint32_t vid = svDbc->getUInt32(i, 0); + if (!vid) continue; + + std::string path; + + // Try CastKit → SpellVisualKit → SpecialEffect0 path + if (svCastKitField < svFc) { + uint32_t kitId = svDbc->getUInt32(i, svCastKitField); + if (kitId) { + auto kitIt = kitToEffectName.find(kitId); + if (kitIt != kitToEffectName.end()) { + auto fxIt = effectPaths.find(kitIt->second); + if (fxIt != effectPaths.end()) path = fxIt->second; + } + } + } + // Fallback: MissileModel directly references SpellVisualEffectName + if (path.empty() && svMissileField < svFc) { + uint32_t missileEff = svDbc->getUInt32(i, svMissileField); + if (missileEff) { + auto fxIt = effectPaths.find(missileEff); + if (fxIt != effectPaths.end()) path = fxIt->second; + } + } + + if (!path.empty()) { + spellVisualModelPath_[vid] = path; + ++loaded; + } + } + LOG_INFO("SpellVisual: loaded ", loaded, " visual→M2 mappings (of ", + svDbc->getRecordCount(), " records)"); +} + +void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition) { + if (!m2Renderer || visualId == 0) return; + + if (!cachedAssetManager) + cachedAssetManager = core::Application::getInstance().getAssetManager(); + if (!cachedAssetManager) return; + + if (!spellVisualDbcLoaded_) loadSpellVisualDbc(); + + // Find the M2 path for this visual + auto pathIt = spellVisualModelPath_.find(visualId); + if (pathIt == spellVisualModelPath_.end()) return; // No model for this visual + + const std::string& modelPath = pathIt->second; + + // Get or assign a model ID for this path + auto midIt = spellVisualModelIds_.find(modelPath); + uint32_t modelId = 0; + if (midIt != spellVisualModelIds_.end()) { + modelId = midIt->second; + } else { + if (nextSpellVisualModelId_ >= 999800) { + LOG_WARNING("SpellVisual: model ID pool exhausted"); + return; + } + modelId = nextSpellVisualModelId_++; + spellVisualModelIds_[modelPath] = modelId; + } + + // Load the M2 model if not already loaded + if (!m2Renderer->hasModel(modelId)) { + auto m2Data = cachedAssetManager->readFile(modelPath); + if (m2Data.empty()) { + LOG_DEBUG("SpellVisual: could not read model: ", modelPath); + return; + } + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty() && model.particleEmitters.empty()) { + LOG_DEBUG("SpellVisual: empty model: ", modelPath); + return; + } + // Load skin file for WotLK-format M2s + if (model.version >= 264) { + std::string skinPath = modelPath.substr(0, modelPath.rfind('.')) + "00.skin"; + auto skinData = cachedAssetManager->readFile(skinPath); + if (!skinData.empty()) pipeline::M2Loader::loadSkin(skinData, model); + } + if (!m2Renderer->loadModel(model, modelId)) { + LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath); + return; + } + LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath); + } + + // Spawn instance at world position + uint32_t instanceId = m2Renderer->createInstance(modelId, worldPosition, + glm::vec3(0.0f), 1.0f); + if (instanceId == 0) { + LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId); + return; + } + activeSpellVisuals_.push_back({instanceId, 0.0f}); + LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId, + " model=", modelPath); +} + +void Renderer::updateSpellVisuals(float deltaTime) { + if (activeSpellVisuals_.empty() || !m2Renderer) return; + for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { + it->elapsed += deltaTime; + if (it->elapsed >= SPELL_VISUAL_DURATION) { + m2Renderer->removeInstance(it->instanceId); + it = activeSpellVisuals_.erase(it); + } else { + ++it; + } + } +} + void Renderer::triggerMeleeSwing() { if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown > 0.0f) return; @@ -3012,6 +3186,8 @@ void Renderer::update(float deltaTime) { if (chargeEffect) { chargeEffect->update(deltaTime); } + // Update transient spell visual instances + updateSpellVisuals(deltaTime); // Launch M2 doodad animation on background thread (overlaps with character animation + audio) From d558e3a927c4e22436987307432d377113a60ddc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 18:26:55 -0700 Subject: [PATCH 32/64] fix: separate SMSG_PLAY_SPELL_IMPACT from SMSG_PLAY_OBJECT_SOUND and spawn impact visual SMSG_PLAY_SPELL_IMPACT has a different wire format from SMSG_PLAY_OBJECT_SOUND: it carries uint64 targetGuid + uint32 visualId (same as SMSG_PLAY_SPELL_VISUAL), not uint32 soundId + uint64 sourceGuid. Previously both were handled together, causing the target GUID low-bytes to be misread as a sound ID and the visualId to be missed entirely. Now each handler parses its own format correctly. SMSG_PLAY_SPELL_IMPACT resolves the target entity position and calls playSpellVisual() to spawn the M2 impact effect at that location. --- src/game/game_handler.cpp | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6709e6c3..d7432666 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7399,12 +7399,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Play object/spell sounds ---- case Opcode::SMSG_PLAY_OBJECT_SOUND: - case Opcode::SMSG_PLAY_SPELL_IMPACT: if (packet.getSize() - packet.getReadPos() >= 12) { // uint32 soundId + uint64 sourceGuid - uint32_t soundId = packet.readUInt32(); - uint64_t srcGuid = packet.readUInt64(); - LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND/SPELL_IMPACT id=", soundId, " src=0x", std::hex, srcGuid, std::dec); + uint32_t soundId = packet.readUInt32(); + uint64_t srcGuid = packet.readUInt64(); + LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec); if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); else if (playSoundCallback_) playSoundCallback_(soundId); } else if (packet.getSize() - packet.getReadPos() >= 4) { @@ -7413,6 +7412,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PLAY_SPELL_IMPACT: { + // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t impTargetGuid = packet.readUInt64(); + uint32_t impVisualId = packet.readUInt32(); + if (impVisualId == 0) break; + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (impTargetGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(impTargetGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(impVisualId, spawnPos); + break; + } // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { From 36fed15d43d70a3743b295eb89d7cf65bcd120f3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 18:30:11 -0700 Subject: [PATCH 33/64] feat: separate cast/impact kit paths in spell visual DBC lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadSpellVisualDbc() now builds two distinct maps: spellVisualCastPath_ — visualId → M2 via SpellVisual.CastKit chain spellVisualImpactPath_ — visualId → M2 via SpellVisual.ImpactKit chain playSpellVisual() accepts useImpactKit=false (default, cast) / true (impact). SMSG_PLAY_SPELL_IMPACT passes useImpactKit=true so impact effects (explosions, debuff indicators) use the ImpactKit model instead of the CastKit model. Added ImpactKit field to all four dbc_layouts.json files. --- include/rendering/renderer.hpp | 13 +++--- src/game/game_handler.cpp | 2 +- src/rendering/renderer.cpp | 74 +++++++++++++++++++--------------- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 7c08a738..1f33d2f4 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -154,8 +154,10 @@ public: void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); - // Spell visual effects (SMSG_PLAY_SPELL_VISUAL) - void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition); + // Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT) + // useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path + void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, + bool useImpactKit = false); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static uint32_t getEmoteDbcId(const std::string& emoteName); @@ -328,11 +330,12 @@ private: pipeline::AssetManager* cachedAssetManager = nullptr; - // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL + // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL/IMPACT struct SpellVisualInstance { uint32_t instanceId; float elapsed; }; std::vector activeSpellVisuals_; - std::unordered_map spellVisualModelPath_; // visualId → resolved M2 path - std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId + std::unordered_map spellVisualCastPath_; // visualId → cast M2 path + std::unordered_map spellVisualImpactPath_; // visualId → impact M2 path + std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799 bool spellVisualDbcLoaded_ = false; void loadSpellVisualDbc(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d7432666..a4ec7081 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7431,7 +7431,7 @@ void GameHandler::handlePacket(network::Packet& packet) { glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); spawnPos = core::coords::canonicalToRender(canonical); } - renderer->playSpellVisual(impVisualId, spawnPos); + renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true); break; } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index e04ca9ea..4da8bad7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2644,11 +2644,13 @@ void Renderer::loadSpellVisualDbc() { const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr; uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2; + uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3; uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8; uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11; uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5; uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2; + // Helper to look up effectName path from a kit ID // Load SpellVisualEffectName.dbc — ID → M2 path auto fxDbc = cachedAssetManager->loadDBC("SpellVisualEffectName.dbc"); if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) { @@ -2679,50 +2681,57 @@ void Renderer::loadSpellVisualDbc() { } } - // Load SpellVisual.dbc — visualId → M2 path via kit chain + // Helper: resolve path for a given kit ID + auto kitPath = [&](uint32_t kitId) -> std::string { + if (!kitId) return {}; + auto kitIt = kitToEffectName.find(kitId); + if (kitIt == kitToEffectName.end()) return {}; + auto fxIt = effectPaths.find(kitIt->second); + return (fxIt != effectPaths.end()) ? fxIt->second : std::string{}; + }; + auto missilePath = [&](uint32_t effId) -> std::string { + if (!effId) return {}; + auto fxIt = effectPaths.find(effId); + return (fxIt != effectPaths.end()) ? fxIt->second : std::string{}; + }; + + // Load SpellVisual.dbc — visualId → cast/impact M2 paths via kit chain auto svDbc = cachedAssetManager->loadDBC("SpellVisual.dbc"); if (!svDbc || !svDbc->isLoaded()) { LOG_DEBUG("SpellVisual: SpellVisual.dbc unavailable"); return; } uint32_t svFc = svDbc->getFieldCount(); - uint32_t loaded = 0; + uint32_t loadedCast = 0, loadedImpact = 0; for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) { uint32_t vid = svDbc->getUInt32(i, 0); if (!vid) continue; - std::string path; - - // Try CastKit → SpellVisualKit → SpecialEffect0 path - if (svCastKitField < svFc) { - uint32_t kitId = svDbc->getUInt32(i, svCastKitField); - if (kitId) { - auto kitIt = kitToEffectName.find(kitId); - if (kitIt != kitToEffectName.end()) { - auto fxIt = effectPaths.find(kitIt->second); - if (fxIt != effectPaths.end()) path = fxIt->second; - } - } + // Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel + { + std::string path; + if (svCastKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svCastKitField)); + if (path.empty() && svMissileField < svFc) + path = missilePath(svDbc->getUInt32(i, svMissileField)); + if (!path.empty()) { spellVisualCastPath_[vid] = path; ++loadedCast; } } - // Fallback: MissileModel directly references SpellVisualEffectName - if (path.empty() && svMissileField < svFc) { - uint32_t missileEff = svDbc->getUInt32(i, svMissileField); - if (missileEff) { - auto fxIt = effectPaths.find(missileEff); - if (fxIt != effectPaths.end()) path = fxIt->second; - } - } - - if (!path.empty()) { - spellVisualModelPath_[vid] = path; - ++loaded; + // Impact path: ImpactKit → SpecialEffect0/BaseEffect, fallback to MissileModel + { + std::string path; + if (svImpactKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svImpactKitField)); + if (path.empty() && svMissileField < svFc) + path = missilePath(svDbc->getUInt32(i, svMissileField)); + if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; } } } - LOG_INFO("SpellVisual: loaded ", loaded, " visual→M2 mappings (of ", - svDbc->getRecordCount(), " records)"); + LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact, + " visual→M2 mappings (of ", svDbc->getRecordCount(), " records)"); } -void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition) { +void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, + bool useImpactKit) { if (!m2Renderer || visualId == 0) return; if (!cachedAssetManager) @@ -2731,9 +2740,10 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition if (!spellVisualDbcLoaded_) loadSpellVisualDbc(); - // Find the M2 path for this visual - auto pathIt = spellVisualModelPath_.find(visualId); - if (pathIt == spellVisualModelPath_.end()) return; // No model for this visual + // Select cast or impact path map + auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_; + auto pathIt = pathMap.find(visualId); + if (pathIt == pathMap.end()) return; // No model for this visual const std::string& modelPath = pathIt->second; From 488ec945b6aae3dd6ec86703c7c9ce9eea06cb77 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 18:51:48 -0700 Subject: [PATCH 34/64] feat: display glancing and crushing blows in combat text and log Add GLANCING (hitInfo 0x800) and CRUSHING (hitInfo 0x1000) as distinct combat text types so players see mechanics feedback they expect from Classic/TBC content: - Glancing: shown as "~{amount}" in muted yellow/red; "glances for N" in the combat log - Crushing: shown as "{amount}!" in bright orange/red; "crushes for N!" in the combat log Both types are counted toward DPS meter accumulation. AttackerStateUpdateData gains isGlancing()/isCrushing() helpers alongside the existing isCrit()/isMiss(). --- include/game/spell_defines.hpp | 2 +- include/game/world_packets.hpp | 6 ++++-- src/game/game_handler.cpp | 10 +++++++++- src/ui/game_screen.cpp | 28 +++++++++++++++++++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 55759128..67d94c2d 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, EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, - DISPEL, STEAL, INTERRUPT, INSTAKILL, HONOR_GAIN + DISPEL, STEAL, INTERRUPT, INSTAKILL, HONOR_GAIN, GLANCING, CRUSHING }; Type type; int32_t amount = 0; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c2e92f06..a30194f4 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1719,8 +1719,10 @@ struct AttackerStateUpdateData { uint32_t blocked = 0; bool isValid() const { return attackerGuid != 0; } - bool isCrit() const { return (hitInfo & 0x200) != 0; } - bool isMiss() const { return (hitInfo & 0x10) != 0; } + bool isCrit() const { return (hitInfo & 0x0200) != 0; } + bool isMiss() const { return (hitInfo & 0x0010) != 0; } + bool isGlancing() const { return (hitInfo & 0x0800) != 0; } + bool isCrushing() const { return (hitInfo & 0x1000) != 0; } }; class AttackerStateUpdateParser { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a4ec7081..2a16211f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17772,7 +17772,15 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { - auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; + CombatTextEntry::Type type; + if (data.isCrit()) + type = CombatTextEntry::CRIT_DAMAGE; + else if (data.isCrushing()) + type = CombatTextEntry::CRUSHING; + else if (data.isGlancing()) + type = CombatTextEntry::GLANCING; + else + type = CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); // Show partial absorb/resist from sub-damage entries uint32_t totalAbsorbed = 0, totalResisted = 0; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 277ecf7b..556f3917 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9273,6 +9273,18 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "+%d Honor", entry.amount); color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for honor break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : // Outgoing glancing = muted yellow + ImVec4(0.75f, 0.35f, 0.35f, alpha); // Incoming glancing = muted red + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : // Outgoing crushing = orange + ImVec4(1.0f, 0.15f, 0.15f, alpha); // Incoming crushing = bright red + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -9343,6 +9355,8 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { case game::CombatTextEntry::SPELL_DAMAGE: case game::CombatTextEntry::CRIT_DAMAGE: case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: dpsEncounterDamage_ += static_cast(e.amount); break; case game::CombatTextEntry::HEAL: @@ -9367,6 +9381,8 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { case game::CombatTextEntry::SPELL_DAMAGE: case game::CombatTextEntry::CRIT_DAMAGE: case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: totalDamage += static_cast(e.amount); break; case game::CombatTextEntry::HEAL: @@ -21218,7 +21234,7 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { using T = game::CombatTextEntry; return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || - t == T::ENVIRONMENTAL; + t == T::ENVIRONMENTAL || t == T::GLANCING || t == T::CRUSHING; }; auto isHealType = [](game::CombatTextEntry::Type t) { using T = game::CombatTextEntry; @@ -21492,6 +21508,16 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "You gain %d honor", e.amount); color = ImVec4(1.0f, 0.85f, 0.0f, 1.0f); break; + case T::GLANCING: + snprintf(desc, sizeof(desc), "%s glances %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.75f, 0.75f, 0.5f, 1.0f) + : ImVec4(0.75f, 0.4f, 0.4f, 1.0f); + break; + case T::CRUSHING: + snprintf(desc, sizeof(desc), "%s crushes %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.55f, 0.1f, 1.0f) + : ImVec4(1.0f, 0.15f, 0.15f, 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 d60d296b77a0f1c2a45b86619b7a09213c549787 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:01:03 -0700 Subject: [PATCH 35/64] feat: show discovered taxi nodes as markers on the world map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add gold diamond markers for every flight master the player has already discovered (knownTaxiMask_), read from TaxiNodes.dbc and filtered to the current continent/map being displayed: - WorldMapTaxiNode struct carries canonical WoW coords + known flag - WorldMap::setTaxiNodes() accepts the per-frame list from game_screen - renderImGuiOverlay() projects each known node to UV, draws a gold diamond (AddQuadFilled) with a dark outline, and shows the node name as a tooltip on hover - GameHandler::isKnownTaxiNode(id) checks knownTaxiMask_[] efficiently - Markers update live — newly discovered nodes appear without reopening the map --- include/game/game_handler.hpp | 5 +++++ include/rendering/world_map.hpp | 14 +++++++++++++ src/rendering/world_map.cpp | 37 +++++++++++++++++++++++++++++++++ src/ui/game_screen.cpp | 19 +++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e8762167..5abce0ed 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1919,6 +1919,11 @@ public: float x = 0, y = 0, z = 0; }; const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } + bool isKnownTaxiNode(uint32_t nodeId) const { + if (nodeId == 0 || nodeId > 384) return false; + uint32_t idx = nodeId - 1; + return (knownTaxiMask_[idx / 32] & (1u << (idx % 32))) != 0; + } uint32_t getTaxiCostTo(uint32_t destNodeId) const; bool taxiNpcHasRoutes(uint64_t guid) const { auto it = taxiNpcHasRoutes_.find(guid); diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index e908ffaa..cfbf5595 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -25,6 +25,15 @@ struct WorldMapPartyDot { std::string name; ///< Member name (shown as tooltip on hover) }; +/// Taxi (flight master) node passed from the UI layer for world map overlay. +struct WorldMapTaxiNode { + uint32_t id = 0; ///< TaxiNodes.dbc ID + uint32_t mapId = 0; ///< WoW internal map ID (0=EK,1=Kal,530=Outland,571=Northrend) + float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates + std::string name; ///< Node name (shown as tooltip) + bool known = false; ///< Player has discovered this node +}; + struct WorldMapZone { uint32_t wmaID = 0; uint32_t areaID = 0; // 0 = continent level @@ -57,6 +66,7 @@ public: void setMapName(const std::string& name); void setServerExplorationMask(const std::vector& masks, bool hasData); void setPartyDots(std::vector dots) { partyDots_ = std::move(dots); } + void setTaxiNodes(std::vector nodes) { taxiNodes_ = std::move(nodes); } bool isOpen() const { return open; } void close() { open = false; } @@ -127,6 +137,10 @@ private: // Party member dots (set each frame from the UI layer) std::vector partyDots_; + // Taxi node markers (set each frame from the UI layer) + std::vector taxiNodes_; + int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC) + // Exploration / fog of war std::vector serverExplorationMask; bool hasServerExplorationMask = false; diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 8163628d..2257ded9 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -371,6 +371,7 @@ void WorldMap::loadZonesFromDBC() { } } + currentMapId_ = mapID; LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID, ", continentIdx=", continentIdx); } @@ -1059,6 +1060,42 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Taxi node markers — flight master icons on the map + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !taxiNodes_.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + for (const auto& node : taxiNodes_) { + if (!node.known) continue; + if (static_cast(node.mapId) != currentMapId_) continue; + + glm::vec3 rPos = core::coords::canonicalToRender( + glm::vec3(node.wowX, node.wowY, node.wowZ)); + glm::vec2 uv = renderPosToMapUV(rPos, currentIdx); + if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; + + float px = imgMin.x + uv.x * displayW; + float py = imgMin.y + uv.y * displayH; + + // Flight-master icon: yellow diamond with dark border + constexpr float H = 5.0f; // half-size of diamond + ImVec2 top2(px, py - H); + ImVec2 right2(px + H, py ); + ImVec2 bot2(px, py + H); + ImVec2 left2(px - H, py ); + drawList->AddQuadFilled(top2, right2, bot2, left2, + IM_COL32(255, 215, 0, 230)); + drawList->AddQuad(top2, right2, bot2, left2, + IM_COL32(80, 50, 0, 200), 1.2f); + + // Tooltip on hover + if (!node.name.empty()) { + float mdx = mp.x - px, mdy = mp.y - py; + if (mdx * mdx + mdy * mdy < 49.0f) { + ImGui::SetTooltip("%s\n(Flight Master)", node.name.c_str()); + } + } + } + } + // Hover coordinate display — show WoW coordinates under cursor if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { auto& io = ImGui::GetIO(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 556f3917..ea755177 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7033,6 +7033,25 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { wm->setPartyDots(std::move(dots)); } + // Taxi node markers on world map + { + std::vector taxiNodes; + const auto& nodes = gameHandler.getTaxiNodes(); + taxiNodes.reserve(nodes.size()); + for (const auto& [id, node] : nodes) { + rendering::WorldMapTaxiNode wtn; + wtn.id = node.id; + wtn.mapId = node.mapId; + wtn.wowX = node.x; + wtn.wowY = node.y; + wtn.wowZ = node.z; + wtn.name = node.name; + wtn.known = gameHandler.isKnownTaxiNode(id); + taxiNodes.push_back(std::move(wtn)); + } + wm->setTaxiNodes(std::move(taxiNodes)); + } + glm::vec3 playerPos = renderer->getCharacterPosition(); float playerYaw = renderer->getCharacterYaw(); auto* window = app.getWindow(); From 4a439fb0d1b049a0e400eae9b3237d53dc26bb30 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:04:40 -0700 Subject: [PATCH 36/64] feat: add clock-sweep arc to buff bar and target aura icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aura icons on the player buff bar and the target frame now display a WoW-style dark fan overlay that sweeps clockwise as the buff/debuff elapses, providing instant visual feedback on remaining duration. The sweep uses AuraSlot::maxDurationMs / getRemainingMs() — the same data that already drives the numeric countdown — so no new state is required. Only temporary auras (maxDurationMs > 0) show a sweep; permanent buffs remain unaffected. --- src/ui/game_screen.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ea755177..7358a59c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4461,6 +4461,32 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { std::chrono::steady_clock::now().time_since_epoch()).count()); int32_t tRemainMs = aura.getRemainingMs(tNowMs); + // Clock-sweep overlay (elapsed = dark area, WoW style) + if (tRemainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 tIconMin = ImGui::GetItemRectMin(); + ImVec2 tIconMax = ImGui::GetItemRectMax(); + float tcx = (tIconMin.x + tIconMax.x) * 0.5f; + float tcy = (tIconMin.y + tIconMax.y) * 0.5f; + float tR = (tIconMax.x - tIconMin.x) * 0.5f; + float tTot = static_cast(aura.maxDurationMs); + float tFrac = std::clamp( + 1.0f - static_cast(tRemainMs) / tTot, 0.0f, 1.0f); + if (tFrac > 0.005f) { + constexpr int TSEGS = 24; + float tSa = -IM_PI * 0.5f; + float tEa = tSa + tFrac * 2.0f * IM_PI; + ImVec2 tPts[TSEGS + 2]; + tPts[0] = ImVec2(tcx, tcy); + for (int s = 0; s <= TSEGS; ++s) { + float a = tSa + (tEa - tSa) * s / static_cast(TSEGS); + tPts[s + 1] = ImVec2(tcx + std::cos(a) * tR, + tcy + std::sin(a) * tR); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + tPts, TSEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + // Duration countdown overlay if (tRemainMs > 0) { ImVec2 iconMin = ImGui::GetItemRectMin(); @@ -13167,6 +13193,32 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { std::chrono::steady_clock::now().time_since_epoch()).count()); int32_t remainMs = aura.getRemainingMs(nowMs); + // Clock-sweep overlay: dark fan shows elapsed time (WoW style) + if (remainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 iconMin2 = ImGui::GetItemRectMin(); + ImVec2 iconMax2 = ImGui::GetItemRectMax(); + float cx2 = (iconMin2.x + iconMax2.x) * 0.5f; + float cy2 = (iconMin2.y + iconMax2.y) * 0.5f; + float fanR2 = (iconMax2.x - iconMin2.x) * 0.5f; + float total2 = static_cast(aura.maxDurationMs); + float elapsedFrac2 = std::clamp( + 1.0f - static_cast(remainMs) / total2, 0.0f, 1.0f); + if (elapsedFrac2 > 0.005f) { + constexpr int SWEEP_SEGS = 24; + float sa = -IM_PI * 0.5f; + float ea = sa + elapsedFrac2 * 2.0f * IM_PI; + ImVec2 pts[SWEEP_SEGS + 2]; + pts[0] = ImVec2(cx2, cy2); + for (int s = 0; s <= SWEEP_SEGS; ++s) { + float a = sa + (ea - sa) * s / static_cast(SWEEP_SEGS); + pts[s + 1] = ImVec2(cx2 + std::cos(a) * fanR2, + cy2 + std::sin(a) * fanR2); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + pts, SWEEP_SEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + // Duration countdown overlay — always visible on the icon bottom if (remainMs > 0) { ImVec2 iconMin = ImGui::GetItemRectMin(); From f9947300da7c6236c1029c0a6582dc4b7390a655 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:14:17 -0700 Subject: [PATCH 37/64] feat: show zone entry text on every zone crossing via SMSG_INIT_WORLD_STATES Previously the "Entering: [Zone]" overlay only triggered when the terrain renderer loaded a new map. Now it also fires whenever worldStateZoneId_ changes (sent by the server via SMSG_INIT_WORLD_STATES on each zone crossing), giving correct "Entering: Ironforge", "Entering: Wailing Caverns" etc. display for sub-zones and dungeon entries without requiring a full map reload. - Added lastKnownWorldStateZoneId_ to track server-reported zone changes - renderZoneText() now takes GameHandler& to access getWorldStateZoneId() and getWhoAreaName() for name lookup via WorldMapArea.dbc cache - Renderer zone name still checked as a fallback for map-level transitions - Both sources de-duplicate to avoid triggering the same text twice --- include/ui/game_screen.hpp | 3 ++- src/ui/game_screen.cpp | 26 +++++++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 6a7212b9..3acd13b0 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -634,7 +634,8 @@ private: float zoneTextTimer_ = 0.0f; std::string zoneTextName_; std::string lastKnownZoneName_; - void renderZoneText(); + uint32_t lastKnownWorldStateZoneId_ = 0; + void renderZoneText(game::GameHandler& gameHandler); void renderWeatherOverlay(game::GameHandler& gameHandler); // Cooldown tracker diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7358a59c..e26815e5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -744,7 +744,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPvpHonorToasts(); renderItemLootToasts(); renderResurrectFlash(); - renderZoneText(); + renderZoneText(gameHandler); renderWeatherOverlay(gameHandler); // World map (M key toggle handled inside) @@ -20496,15 +20496,31 @@ void GameScreen::renderWhisperToasts() { // Zone discovery text — "Entering: " fades in/out at screen centre // --------------------------------------------------------------------------- -void GameScreen::renderZoneText() { - // Poll the renderer for zone name changes +void GameScreen::renderZoneText(game::GameHandler& gameHandler) { + // Poll worldStateZoneId for server-driven zone changes (fires on every zone crossing, + // including sub-zones like Ironforge within Dun Morogh). + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0 && wsZoneId != lastKnownWorldStateZoneId_) { + lastKnownWorldStateZoneId_ = wsZoneId; + std::string wsName = gameHandler.getWhoAreaName(wsZoneId); + if (!wsName.empty()) { + zoneTextName_ = wsName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } + } + + // Also poll the renderer for zone name changes (covers map-level transitions + // where worldStateZoneId may not change immediately). auto* appRenderer = core::Application::getInstance().getRenderer(); if (appRenderer) { const std::string& zoneName = appRenderer->getCurrentZoneName(); if (!zoneName.empty() && zoneName != lastKnownZoneName_) { lastKnownZoneName_ = zoneName; - zoneTextName_ = zoneName; - zoneTextTimer_ = ZONE_TEXT_DURATION; + // Only override if the worldState hasn't already queued this zone + if (zoneTextName_ != zoneName) { + zoneTextName_ = zoneName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } } } From b8712f380dc6833bebc68f7f83e96668f0cc643a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:16:02 -0700 Subject: [PATCH 38/64] fix: show sub-zone name in minimap label using server-reported zone ID The zone label above the minimap now preferentially uses the zone/area name from getWorldStateZoneId() (populated via SMSG_INIT_WORLD_STATES) rather than the renderer's map-level zone name. This means the label correctly shows "Ironforge", "Wailing Caverns", etc. instead of always showing the parent continent zone name. --- src/ui/game_screen.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e26815e5..03a027dd 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17572,8 +17572,15 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { }; // Zone name label above the minimap (centered, WoW-style) + // Prefer the server-reported zone/area name (from SMSG_INIT_WORLD_STATES) so sub-zones + // like Ironforge or Wailing Caverns display correctly; fall back to renderer zone name. { - const std::string& zoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + std::string wsZoneName; + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0) + wsZoneName = gameHandler.getWhoAreaName(wsZoneId); + const std::string& rendererZoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + const std::string& zoneName = !wsZoneName.empty() ? wsZoneName : rendererZoneName; if (!zoneName.empty()) { auto* fgDl = ImGui::GetForegroundDrawList(); float zoneTextY = centerY - mapRadius - 16.0f; From 279b4de09a787dc0cc76733ce8cdd3f75b62be76 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:43:19 -0700 Subject: [PATCH 39/64] feat: color cast bars green/red by spell interruptibility from Spell.dbc Load AttributesEx from Spell.dbc for all expansions (Classic/TBC/WotLK/ Turtle). Check SPELL_ATTR_EX_NOT_INTERRUPTIBLE (bit 4 = 0x10) to classify each cast as interruptible or not when SMSG_SPELL_START arrives. Target frame and nameplate cast bars now use: - Green: spell can be interrupted by Kick/Counterspell/Pummel etc. - Red: spell is immune to interrupt (boss abilities, instant-cast effects) Both colors pulse faster at >80% completion to signal the closing window. Adds GameHandler::isSpellInterruptible() and UnitCastState::interruptible. --- Data/expansions/classic/dbc_layouts.json | 2 +- Data/expansions/tbc/dbc_layouts.json | 2 +- Data/expansions/turtle/dbc_layouts.json | 2 +- Data/expansions/wotlk/dbc_layouts.json | 2 +- include/game/game_handler.hpp | 10 +++++++- src/game/game_handler.cpp | 31 ++++++++++++++++++++---- src/ui/game_screen.cpp | 23 ++++++++++++------ 7 files changed, 55 insertions(+), 17 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 4ee5ffb1..e5d0793f 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 117, + "ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, "DispelType": 4 diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index d9ad4351..da2fb9a5 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 124, + "ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 124, "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215, "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40, "DispelType": 3 diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index a76d065c..cb44c54a 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 117, + "ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, "DispelType": 4 diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index a332f041..4ecbfc32 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 4, "IconID": 133, + "ID": 0, "Attributes": 4, "AttributesEx": 5, "IconID": 133, "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49, "DispelType": 2 diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5abce0ed..58d73e37 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -798,6 +798,7 @@ public: uint32_t spellId = 0; float timeRemaining = 0.0f; float timeTotal = 0.0f; + bool interruptible = true; ///< false when SPELL_ATTR_EX_NOT_INTERRUPTIBLE is set }; // Returns cast state for any unit by GUID (empty/non-casting if not found) const UnitCastState* getUnitCastState(uint64_t guid) const { @@ -819,6 +820,10 @@ public: auto* s = getUnitCastState(targetGuid); return s ? s->timeRemaining : 0.0f; } + bool isTargetCastInterruptible() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->interruptible : true; + } // Talents uint8_t getActiveTalentSpec() const { return activeTalentSpec_; } @@ -2056,6 +2061,9 @@ public: const std::string& getSkillLineName(uint32_t spellId) const; /// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other) uint8_t getSpellDispelType(uint32_t spellId) const; + /// Returns true if the spell can be interrupted by abilities like Kick/Counterspell. + /// False for spells with SPELL_ATTR_EX_NOT_INTERRUPTIBLE (attrEx bit 4 = 0x10). + bool isSpellInterruptible(uint32_t spellId) const; struct TrainerTab { std::string name; @@ -3059,7 +3067,7 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; }; + struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2a16211f..27de9b6d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18279,10 +18279,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // Track cast bar for any non-player caster (target frame + boss frames) if (data.casterUnit != playerGuid && data.castTime > 0) { auto& s = unitCastStates_[data.casterUnit]; - s.casting = true; - s.spellId = data.spellId; - s.timeTotal = data.castTime / 1000.0f; - s.timeRemaining = s.timeTotal; + s.casting = true; + s.spellId = data.spellId; + s.timeTotal = data.castTime / 1000.0f; + s.timeRemaining = s.timeTotal; + s.interruptible = isSpellInterruptible(data.spellId); // Trigger cast animation on the casting unit if (spellCastAnimCallback_) { spellCastAnimCallback_(data.casterUnit, true, false); @@ -21320,6 +21321,14 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } } + // AttributesEx field (bit 4 = SPELL_ATTR_EX_NOT_INTERRUPTIBLE) + uint32_t attrExField = 0xFFFFFFFF; + bool hasAttrExField = false; + if (spellL) { + uint32_t f = spellL->field("AttributesEx"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; } + } + // Tooltip/description field uint32_t tooltipField = 0xFFFFFFFF; if (spellL) { @@ -21334,7 +21343,7 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0}; + SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0}; if (tooltipField != 0xFFFFFFFF) { entry.description = dbc->getString(i, tooltipField); } @@ -21349,6 +21358,9 @@ void GameHandler::loadSpellNameCache() { if (hasDispelField) { entry.dispelType = static_cast(dbc->getUInt32(i, dispelField)); } + if (hasAttrExField) { + entry.attrEx = dbc->getUInt32(i, attrExField); + } spellNameCache_[id] = std::move(entry); } } @@ -21554,6 +21566,15 @@ uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.dispelType : 0; } +bool GameHandler::isSpellInterruptible(uint32_t spellId) const { + if (spellId == 0) return true; + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + if (it == spellNameCache_.end()) return true; // assume interruptible if unknown + // SPELL_ATTR_EX_NOT_INTERRUPTIBLE = bit 4 of AttributesEx (0x00000010) + return (it->second.attrEx & 0x00000010u) == 0; +} + const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 03a027dd..9502058f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4288,14 +4288,19 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float castPct = gameHandler.getTargetCastProgress(); float castLeft = gameHandler.getTargetCastTimeRemaining(); uint32_t tspell = gameHandler.getTargetCastSpellId(); + bool interruptible = gameHandler.isTargetCastInterruptible(); const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; - // Pulse bright orange when cast is > 80% complete — interrupt window closing + // Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80% ImVec4 castBarColor; if (castPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - castBarColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + if (interruptible) + castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse + else + castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse } else { - castBarColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + castBarColor = interruptible ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) // green = can interrupt + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); char castLabel[72]; @@ -9702,14 +9707,18 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { castBarBaseY += snSz.y + 2.0f; } - // Cast bar background + fill (pulse orange when >80% = interrupt window closing) - ImU32 cbBg = IM_COL32(40, 30, 60, A(180)); + // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete + ImU32 cbBg = IM_COL32(30, 25, 40, A(180)); ImU32 cbFill; if (castPct > 0.8f && unit->isHostile()) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - cbFill = IM_COL32(static_cast(255 * pulse), static_cast(130 * pulse), 0, A(220)); + cbFill = cs->interruptible + ? IM_COL32(static_cast(40 * pulse), static_cast(220 * pulse), static_cast(40 * pulse), A(220)) // green pulse + : IM_COL32(static_cast(255 * pulse), static_cast(30 * pulse), static_cast(30 * pulse), A(220)); // red pulse } else { - cbFill = IM_COL32(140, 80, 220, A(200)); // purple cast bar + cbFill = cs->interruptible + ? IM_COL32(50, 190, 50, A(200)) // green = interruptible + : IM_COL32(190, 40, 40, A(200)); // red = uninterruptible } drawList->AddRectFilled(ImVec2(barX, castBarBaseY), ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); From 7c932559e0ebffc96298c8e9644f402f1087270f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:44:48 -0700 Subject: [PATCH 40/64] fix: apply interruptibility coloring to boss frame cast bars Boss encounter frames were still using the old fixed orange/red cast bar color. Update them to match the target frame: green = interruptible, red = SPELL_ATTR_EX_NOT_INTERRUPTIBLE, both pulse at >80% completion. --- src/ui/game_screen.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9502058f..84d30f5b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11138,13 +11138,17 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { uint32_t bspell = cs->spellId; const std::string& bcastName = (bspell != 0) ? gameHandler.getSpellName(bspell) : ""; - // Pulse bright orange when > 80% complete — interrupt window closing + // Green = interruptible, Red = immune; pulse when > 80% complete ImVec4 bcastColor; if (castPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - bcastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + bcastColor = cs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); } else { - bcastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + bcastColor = cs->interruptible + ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); char bcastLabel[72]; From 1f20f55c620bf3278b2cc754e1a7de52e755bff0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:45:45 -0700 Subject: [PATCH 41/64] fix: set interruptible flag on channel start for non-player casters MSG_CHANNEL_START for NPCs/bosses was leaving UnitCastState::interruptible at its default (true) instead of checking Spell.dbc AttributesEx. --- src/game/game_handler.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 27de9b6d..f760f050 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7116,10 +7116,11 @@ void GameHandler::handlePacket(network::Packet& packet) { castTimeRemaining = castTimeTotal; } else { auto& s = unitCastStates_[chanCaster]; - s.casting = true; - s.spellId = chanSpellId; - s.timeTotal = chanTotalMs / 1000.0f; - s.timeRemaining = s.timeTotal; + s.casting = true; + s.spellId = chanSpellId; + s.timeTotal = chanTotalMs / 1000.0f; + s.timeRemaining = s.timeTotal; + s.interruptible = isSpellInterruptible(chanSpellId); } LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, " spell=", chanSpellId, " total=", chanTotalMs, "ms"); From 614fcf6b9821f19415b2698a0bdef062d6f4c81e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:47:45 -0700 Subject: [PATCH 42/64] feat: show orange nameplate border when hostile NPC is targeting player When a hostile unit has UNIT_FIELD_TARGET pointing to the local player, highlight its nameplate with an orange border so players can immediately see which enemies are attacking them vs. attacking group members. Priority: gold=selected, orange=targeting you, dark=default. --- src/ui/game_screen.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 84d30f5b..73abfda6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9655,9 +9655,25 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); } + // Check if this unit is targeting the local player (threat indicator) + bool isTargetingPlayer = false; + if (unit->isHostile() && !isCorpse) { + const auto& fields = entityPtr->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end() && loIt->second != 0) { + uint64_t unitTarget = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + unitTarget |= (static_cast(hiIt->second) << 32); + isTargetingPlayer = (unitTarget == playerGuid); + } + } + // Border: gold = currently selected, orange = targeting player, dark = default ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) - : IM_COL32(20, 20, 20, A(180)); + : isTargetingPlayer + ? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you + : IM_COL32(20, 20, 20, A(180)); // Bar geometry const float barW = 80.0f * nameplateScale_; From d0df6eed2cf06528695c195c47ff402231f2bb76 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:52:17 -0700 Subject: [PATCH 43/64] feat: show corpse skull marker on world map when player is a ghost When the player dies and releases spirit, the world map now renders a bone-white X cross at the corpse's location (matching the existing minimap skull marker). The marker appears only when the player is a ghost with an unclaimed corpse on the same map, and shows a "Your corpse" tooltip on hover. Implemented via setCorpsePos() on WorldMap, called from renderWorldMap() using getCorpseCanonicalPos(). --- include/rendering/world_map.hpp | 11 +++++++++++ src/rendering/world_map.cpp | 27 +++++++++++++++++++++++++++ src/ui/game_screen.cpp | 11 +++++++++++ 3 files changed, 49 insertions(+) diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index cfbf5595..eedc88af 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -67,6 +67,13 @@ public: void setServerExplorationMask(const std::vector& masks, bool hasData); void setPartyDots(std::vector dots) { partyDots_ = std::move(dots); } void setTaxiNodes(std::vector nodes) { taxiNodes_ = std::move(nodes); } + /// Set the player's corpse position for overlay rendering. + /// @param hasCorpse True when the player is a ghost with an unclaimed corpse on this map. + /// @param renderPos Corpse position in render-space coordinates. + void setCorpsePos(bool hasCorpse, glm::vec3 renderPos) { + hasCorpse_ = hasCorpse; + corpseRenderPos_ = renderPos; + } bool isOpen() const { return open; } void close() { open = false; } @@ -141,6 +148,10 @@ private: std::vector taxiNodes_; int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC) + // Corpse marker (ghost state — set each frame from the UI layer) + bool hasCorpse_ = false; + glm::vec3 corpseRenderPos_ = {}; + // Exploration / fog of war std::vector serverExplorationMask; bool hasServerExplorationMask = false; diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 2257ded9..cc278b5f 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -1096,6 +1096,33 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Corpse marker — skull X shown when player is a ghost with unclaimed corpse + if (hasCorpse_ && currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + glm::vec2 uv = renderPosToMapUV(corpseRenderPos_, currentIdx); + if (uv.x >= 0.0f && uv.x <= 1.0f && uv.y >= 0.0f && uv.y <= 1.0f) { + float cx = imgMin.x + uv.x * displayW; + float cy = imgMin.y + uv.y * displayH; + constexpr float R = 5.0f; // cross arm half-length + constexpr float T = 1.8f; // line thickness + // Dark outline + drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), + IM_COL32(0, 0, 0, 220), T + 1.5f); + drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), + IM_COL32(0, 0, 0, 220), T + 1.5f); + // Bone-white X + drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), + IM_COL32(230, 220, 200, 240), T); + drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), + IM_COL32(230, 220, 200, 240), T); + // Tooltip on hover + ImVec2 mp = ImGui::GetMousePos(); + float dx = mp.x - cx, dy = mp.y - cy; + if (dx * dx + dy * dy < 64.0f) { + ImGui::SetTooltip("Your corpse"); + } + } + } + // Hover coordinate display — show WoW coordinates under cursor if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { auto& io = ImGui::GetIO(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 73abfda6..a580fab3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7083,6 +7083,17 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { wm->setTaxiNodes(std::move(taxiNodes)); } + // Corpse marker: show skull X on world map when ghost with unclaimed corpse + { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + bool ghostWithCorpse = gameHandler.isPlayerGhost() && + gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY); + glm::vec3 corpseRender = ghostWithCorpse + ? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)) + : glm::vec3{}; + wm->setCorpsePos(ghostWithCorpse, corpseRender); + } + glm::vec3 playerPos = renderer->getCharacterPosition(); float playerYaw = renderer->getCharacterYaw(); auto* window = app.getWindow(); From 4ce6fdb5f3d1a0fb577d9fc0de3bc6408ba5a9b5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 19:56:52 -0700 Subject: [PATCH 44/64] feat: color player cast bar by spell school from Spell.dbc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player's own cast bar now uses spell-school-based colors for quick identification: Fire=orange-red, Frost=icy blue, Shadow=purple, Arcane=violet, Nature=green, Holy=golden, Physical=gold. Channels remain blue regardless of school. Adds getSpellSchoolMask() using the already-loaded Spell.dbc cache (schoolMask field, covering all expansions including Classic SchoolEnum→bitmask conversion). --- include/game/game_handler.hpp | 4 ++++ src/game/game_handler.cpp | 7 +++++++ src/ui/game_screen.cpp | 17 ++++++++++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 58d73e37..c8b20983 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2064,6 +2064,10 @@ public: /// Returns true if the spell can be interrupted by abilities like Kick/Counterspell. /// False for spells with SPELL_ATTR_EX_NOT_INTERRUPTIBLE (attrEx bit 4 = 0x10). bool isSpellInterruptible(uint32_t spellId) const; + /// Returns the school bitmask for the spell from Spell.dbc + /// (0x01=Physical, 0x02=Holy, 0x04=Fire, 0x08=Nature, 0x10=Frost, 0x20=Shadow, 0x40=Arcane). + /// Returns 0 if unknown. + uint32_t getSpellSchoolMask(uint32_t spellId) const; struct TrainerTab { std::string name; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f760f050..aee5d91e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21576,6 +21576,13 @@ bool GameHandler::isSpellInterruptible(uint32_t spellId) const { return (it->second.attrEx & 0x00000010u) == 0; } +uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const { + if (spellId == 0) return 0; + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.schoolMask : 0; +} + const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a580fab3..cd4d0589 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8616,9 +8616,20 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ? (1.0f - gameHandler.getCastProgress()) : gameHandler.getCastProgress(); - ImVec4 barColor = channeling - ? ImVec4(0.3f, 0.6f, 0.9f, 1.0f) // blue for channels - : ImVec4(0.8f, 0.6f, 0.2f, 1.0f); // gold for casts + // Color by spell school for cast identification; channels always blue + ImVec4 barColor; + if (channeling) { + barColor = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); // blue for channels + } else { + uint32_t school = (currentSpellId != 0) ? gameHandler.getSpellSchoolMask(currentSpellId) : 0; + if (school & 0x04) barColor = ImVec4(0.95f, 0.40f, 0.10f, 1.0f); // Fire: orange-red + else if (school & 0x10) barColor = ImVec4(0.30f, 0.65f, 0.95f, 1.0f); // Frost: icy blue + else if (school & 0x20) barColor = ImVec4(0.55f, 0.15f, 0.70f, 1.0f); // Shadow: purple + else if (school & 0x40) barColor = ImVec4(0.65f, 0.30f, 0.85f, 1.0f); // Arcane: violet + else if (school & 0x08) barColor = ImVec4(0.20f, 0.75f, 0.25f, 1.0f); // Nature: green + else if (school & 0x02) barColor = ImVec4(0.90f, 0.80f, 0.30f, 1.0f); // Holy: golden + else barColor = ImVec4(0.80f, 0.60f, 0.20f, 1.0f); // Physical/default: gold + } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); char overlay[96]; From 63f4d10ab1e09f62c01e451ad6c21314274e9919 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:02:02 -0700 Subject: [PATCH 45/64] fix: apply interruptibility coloring to target-of-target cast bar The ToT (target-of-target) cast bar was still using a fixed orange-yellow color regardless of spell interruptibility. Now uses the same green/red scheme as the target frame and nameplate cast bars: green = interruptible (can Kick/Counterspell), red = not interruptible, both pulse at >80%. --- src/ui/game_screen.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cd4d0589..dd10209d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4645,16 +4645,20 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - // ToT cast bar — orange-yellow, pulses when near completion + // ToT cast bar — green if interruptible, red if not; pulses near completion if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { float totCastPct = (totCs->timeTotal > 0.0f) ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; ImVec4 tcColor; if (totCastPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - tcColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + tcColor = totCs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); } else { - tcColor = ImVec4(0.8f, 0.5f, 0.1f, 1.0f); + tcColor = totCs->interruptible + ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); char tcLabel[48]; From e62ae8b03e53d8d61798e3abe6e52321adc2bdfc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:06:05 -0700 Subject: [PATCH 46/64] feat: add local time clock display below minimap coordinates Shows current local time in HH:MM format in a small dimmed label just below the coordinate display near the minimap. Uses localtime_r (POSIX) with a _WIN32 fallback. The clock complements the existing coordinate and zone name overlays, matching the WoW default UI minimap area. --- src/ui/game_screen.cpp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dd10209d..9c1d402b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17504,6 +17504,37 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); } + // Local time clock — displayed just below the coordinate label + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + std::tm tmLocal{}; +#if defined(_WIN32) + localtime_s(&tmLocal, &tt); +#else + localtime_r(&tt, &tmLocal); +#endif + char clockBuf[16]; + std::snprintf(clockBuf, sizeof(clockBuf), "%02d:%02d", + tmLocal.tm_hour, tmLocal.tm_min); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.9f; // slightly smaller than coords + ImVec2 clockSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, clockBuf); + + float tx = centerX - clockSz.x * 0.5f; + // Position below the coordinate line (+fontSize of coord + 2px gap) + float coordLineH = ImGui::GetFontSize(); + float ty = centerY + mapRadius + 3.0f + coordLineH + 2.0f; + + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + clockSz.x + pad, ty + clockSz.y + pad), + IM_COL32(0, 0, 0, 120), 3.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(200, 200, 220, 220), clockBuf); + } + // Zone name display — drawn inside the top edge of the minimap circle { auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; From 072f256af68f12a101495c412c7965732fbae184 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:21:06 -0700 Subject: [PATCH 47/64] feat: add auto-sell grey items on vendor open When 'Auto Sell Greys' is enabled in Settings > Gameplay, all grey (ItemQuality::POOR) items in the backpack and extra bags are sold automatically when opening a vendor window. Items with no sell price are skipped. A system chat message reports the number of items sold and total gold received. The setting persists to ~/.wowee/settings.cfg under the key auto_sell_grey. --- include/game/game_handler.hpp | 3 ++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 62 +++++++++++++++++++++++++++++++++++ src/ui/game_screen.cpp | 10 +++++- 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c8b20983..c18f234c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1394,6 +1394,8 @@ public: const LootResponseData& getCurrentLoot() const { return currentLoot; } void setAutoLoot(bool enabled) { autoLoot_ = enabled; } bool isAutoLoot() const { return autoLoot_; } + void setAutoSellGrey(bool enabled) { autoSellGrey_ = enabled; } + bool isAutoSellGrey() const { return autoSellGrey_; } // Master loot candidates (from SMSG_LOOT_MASTER_LIST) const std::vector& getMasterLootCandidates() const { return masterLootCandidates_; } @@ -2879,6 +2881,7 @@ private: // ---- Phase 5: Loot ---- bool lootWindowOpen = false; bool autoLoot_ = false; + bool autoSellGrey_ = false; LootResponseData currentLoot; std::vector masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 3acd13b0..4f6e0f84 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -195,6 +195,7 @@ private: bool pendingSeparateBags = true; bool pendingShowKeyring = true; bool pendingAutoLoot = false; + bool pendingAutoSellGrey = false; // Keybinding customization int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index aee5d91e..7b354ddd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21197,6 +21197,68 @@ void GameHandler::handleListInventory(network::Packet& packet) { vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens + // Auto-sell grey items if enabled + if (autoSellGrey_ && currentVendorItems.vendorGuid != 0) { + uint32_t totalSellPrice = 0; + int itemsSold = 0; + + // Helper lambda to attempt selling a poor-quality slot + auto tryAutoSell = [&](const ItemSlot& slot, uint64_t itemGuid) { + if (slot.empty()) return; + if (slot.item.quality != ItemQuality::POOR) return; + // Determine sell price (slot cache first, then item info fallback) + uint32_t sp = slot.item.sellPrice; + if (sp == 0) { + if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) + sp = info->sellPrice; + } + if (sp == 0 || itemGuid == 0) return; + BuybackItem sold; + sold.itemGuid = itemGuid; + sold.item = slot.item; + sold.count = 1; + buybackItems_.push_front(sold); + if (buybackItems_.size() > 12) buybackItems_.pop_back(); + pendingSellToBuyback_[itemGuid] = sold; + sellItem(currentVendorItems.vendorGuid, itemGuid, 1); + totalSellPrice += sp; + ++itemsSold; + }; + + // Backpack slots + for (int i = 0; i < inventory.getBackpackSize(); ++i) { + uint64_t guid = backpackSlotGuids_[i]; + if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBackpackSlot(i).item.itemId); + tryAutoSell(inventory.getBackpackSlot(i), guid); + } + + // Extra bag slots + for (int b = 0; b < inventory.NUM_BAG_SLOTS; ++b) { + uint64_t bagGuid = equipSlotGuids_[19 + b]; + for (int s = 0; s < inventory.getBagSize(b); ++s) { + uint64_t guid = 0; + if (bagGuid != 0) { + auto it = containerContents_.find(bagGuid); + if (it != containerContents_.end() && s < static_cast(it->second.numSlots)) + guid = it->second.slotGuids[s]; + } + if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBagSlot(b, s).item.itemId); + tryAutoSell(inventory.getBagSlot(b, s), guid); + } + } + + if (itemsSold > 0) { + uint32_t gold = totalSellPrice / 10000; + uint32_t silver = (totalSellPrice % 10000) / 100; + uint32_t copper = totalSellPrice % 100; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "|cffaaaaaaAuto-sold %d grey item%s for %ug %us %uc.|r", + itemsSold, itemsSold == 1 ? "" : "s", gold, silver, copper); + addSystemChatMessage(buf); + } + } + // Play vendor sound if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) { auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9c1d402b..729c4ea7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -592,8 +592,9 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } - // Apply auto-loot setting to GameHandler every frame (cheap bool sync) + // Apply auto-loot / auto-sell settings to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); + gameHandler.setAutoSellGrey(pendingAutoSellGrey); // Zone entry detection — fire a toast when the renderer's zone name changes if (auto* rend = core::Application::getInstance().getRenderer()) { @@ -16328,6 +16329,11 @@ void GameScreen::renderSettingsWindow() { } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Automatically pick up all items when looting"); + if (ImGui::Checkbox("Auto Sell Greys", &pendingAutoSellGrey)) { + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically sell all grey (poor quality) items when opening a vendor"); ImGui::Spacing(); ImGui::Text("Bags"); @@ -18328,6 +18334,7 @@ void GameScreen::saveSettings() { // Gameplay out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; + out << "auto_sell_grey=" << (pendingAutoSellGrey ? 1 : 0) << "\n"; out << "graphics_preset=" << static_cast(currentGraphicsPreset) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; @@ -18469,6 +18476,7 @@ void GameScreen::loadSettings() { else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); // Gameplay else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); + else if (key == "auto_sell_grey") pendingAutoSellGrey = (std::stoi(val) != 0); else if (key == "graphics_preset") { int presetVal = std::clamp(std::stoi(val), 0, 4); currentGraphicsPreset = static_cast(presetVal); From d44d4626867f8e79487ade977ec2c0bc3948a330 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:27:45 -0700 Subject: [PATCH 48/64] feat: add auto-repair at vendor open When 'Auto Repair' is enabled in Settings > Gameplay, all damaged equipment is automatically repaired when opening any armorer vendor (canRepair=true). The repair is skipped when no items are actually damaged to avoid a pointless server round-trip. A system chat message confirms the repair. Setting persists to ~/.wowee/settings.cfg as auto_repair. --- include/game/game_handler.hpp | 3 +++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 18 ++++++++++++++++++ src/ui/game_screen.cpp | 8 ++++++++ 4 files changed, 30 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c18f234c..114ee071 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1396,6 +1396,8 @@ public: bool isAutoLoot() const { return autoLoot_; } void setAutoSellGrey(bool enabled) { autoSellGrey_ = enabled; } bool isAutoSellGrey() const { return autoSellGrey_; } + void setAutoRepair(bool enabled) { autoRepair_ = enabled; } + bool isAutoRepair() const { return autoRepair_; } // Master loot candidates (from SMSG_LOOT_MASTER_LIST) const std::vector& getMasterLootCandidates() const { return masterLootCandidates_; } @@ -2882,6 +2884,7 @@ private: bool lootWindowOpen = false; bool autoLoot_ = false; bool autoSellGrey_ = false; + bool autoRepair_ = false; LootResponseData currentLoot; std::vector masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 4f6e0f84..98b93b67 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -196,6 +196,7 @@ private: bool pendingShowKeyring = true; bool pendingAutoLoot = false; bool pendingAutoSellGrey = false; + bool pendingAutoRepair = false; // Keybinding customization int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7b354ddd..7f1c7b02 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21259,6 +21259,24 @@ void GameHandler::handleListInventory(network::Packet& packet) { } } + // Auto-repair all items if enabled and vendor can repair + if (autoRepair_ && currentVendorItems.canRepair && currentVendorItems.vendorGuid != 0) { + // Check that at least one equipped item is actually damaged to avoid no-op + bool anyDamaged = false; + for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inventory.getEquipSlot(static_cast(i)); + if (!slot.empty() && slot.item.maxDurability > 0 + && slot.item.curDurability < slot.item.maxDurability) { + anyDamaged = true; + break; + } + } + if (anyDamaged) { + repairAll(currentVendorItems.vendorGuid, false); + addSystemChatMessage("|cffaaaaaaAuto-repair triggered.|r"); + } + } + // Play vendor sound if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) { auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 729c4ea7..e0ed2b03 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -595,6 +595,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Apply auto-loot / auto-sell settings to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); gameHandler.setAutoSellGrey(pendingAutoSellGrey); + gameHandler.setAutoRepair(pendingAutoRepair); // Zone entry detection — fire a toast when the renderer's zone name changes if (auto* rend = core::Application::getInstance().getRenderer()) { @@ -16334,6 +16335,11 @@ void GameScreen::renderSettingsWindow() { } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Automatically sell all grey (poor quality) items when opening a vendor"); + if (ImGui::Checkbox("Auto Repair", &pendingAutoRepair)) { + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically repair all damaged equipment when opening an armorer vendor"); ImGui::Spacing(); ImGui::Text("Bags"); @@ -18335,6 +18341,7 @@ void GameScreen::saveSettings() { // Gameplay out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; out << "auto_sell_grey=" << (pendingAutoSellGrey ? 1 : 0) << "\n"; + out << "auto_repair=" << (pendingAutoRepair ? 1 : 0) << "\n"; out << "graphics_preset=" << static_cast(currentGraphicsPreset) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; @@ -18477,6 +18484,7 @@ void GameScreen::loadSettings() { // Gameplay else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); else if (key == "auto_sell_grey") pendingAutoSellGrey = (std::stoi(val) != 0); + else if (key == "auto_repair") pendingAutoRepair = (std::stoi(val) != 0); else if (key == "graphics_preset") { int presetVal = std::clamp(std::stoi(val), 0, 4); currentGraphicsPreset = static_cast(presetVal); From 48cb7df4b4d6208edce7e126f8f0384f067f92c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:46:41 -0700 Subject: [PATCH 49/64] feat: add Skills/Professions window (K key) with per-category progress bars Implements renderSkillsWindow() showing all player skills grouped by DBC category (Professions, Secondary Skills, Class Skills, Weapon Skills, Armor, Languages) with value/max progress bars and a bonus breakdown tooltip. Hooked up to the TOGGLE_SKILLS keybinding (K by default). --- include/ui/game_screen.hpp | 4 ++ src/ui/game_screen.cpp | 114 +++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 98b93b67..52bcca78 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -438,6 +438,10 @@ private: char achievementSearchBuf_[128] = {}; void renderAchievementWindow(game::GameHandler& gameHandler); + // Skills / Professions window (K key) + bool showSkillsWindow_ = false; + void renderSkillsWindow(game::GameHandler& gameHandler); + // Titles window bool showTitlesWindow_ = false; void renderTitlesWindow(game::GameHandler& gameHandler); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e0ed2b03..b7cc3135 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -718,6 +718,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWhoWindow(gameHandler); renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); + renderSkillsWindow(gameHandler); renderTitlesWindow(gameHandler); renderEquipSetWindow(gameHandler); renderGmTicketWindow(gameHandler); @@ -2766,6 +2767,9 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { showAchievementWindow_ = !showAchievementWindow_; } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SKILLS)) { + showSkillsWindow_ = !showSkillsWindow_; + } // Toggle Titles window with H (hero/title screen — no conflicting keybinding) if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { @@ -22702,4 +22706,114 @@ void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderSkillsWindow(game::GameHandler& gameHandler) { + if (!showSkillsWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(220, 130), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Skills & Professions", &showSkillsWindow_)) { + ImGui::End(); + return; + } + + const auto& skills = gameHandler.getPlayerSkills(); + if (skills.empty()) { + ImGui::TextDisabled("No skill data received yet."); + ImGui::End(); + return; + } + + // Organise skills by category + // WoW SkillLine.dbc categories: 6=Weapon, 7=Class, 8=Armor, 9=Secondary, 11=Professions, others=Misc + struct SkillEntry { + uint32_t skillId; + const game::PlayerSkill* skill; + }; + std::map> byCategory; + for (const auto& [id, sk] : skills) { + uint32_t cat = gameHandler.getSkillCategory(id); + byCategory[cat].push_back({id, &sk}); + } + + static const struct { uint32_t cat; const char* label; } kCatOrder[] = { + {11, "Professions"}, + { 9, "Secondary Skills"}, + { 7, "Class Skills"}, + { 6, "Weapon Skills"}, + { 8, "Armor"}, + { 5, "Languages"}, + { 0, "Other"}, + }; + + // Collect handled categories to fall back to "Other" for unknowns + static const uint32_t kKnownCats[] = {11, 9, 7, 6, 8, 5}; + + // Redirect unknown categories into bucket 0 + for (auto& [cat, vec] : byCategory) { + bool known = false; + for (uint32_t kc : kKnownCats) if (cat == kc) { known = true; break; } + if (!known && cat != 0) { + auto& other = byCategory[0]; + other.insert(other.end(), vec.begin(), vec.end()); + vec.clear(); + } + } + + ImGui::BeginChild("##skillscroll", ImVec2(0, 0), false); + + for (const auto& [cat, label] : kCatOrder) { + auto it = byCategory.find(cat); + if (it == byCategory.end() || it->second.empty()) continue; + + auto& entries = it->second; + // Sort alphabetically within each category + std::sort(entries.begin(), entries.end(), [&](const SkillEntry& a, const SkillEntry& b) { + return gameHandler.getSkillName(a.skillId) < gameHandler.getSkillName(b.skillId); + }); + + if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) { + for (const auto& e : entries) { + const std::string& name = gameHandler.getSkillName(e.skillId); + const char* displayName = name.empty() ? "Unknown" : name.c_str(); + uint16_t val = e.skill->effectiveValue(); + uint16_t maxVal = e.skill->maxValue; + + ImGui::PushID(static_cast(e.skillId)); + + // Name column + ImGui::TextUnformatted(displayName); + ImGui::SameLine(170.0f); + + // Progress bar + float fraction = (maxVal > 0) ? static_cast(val) / static_cast(maxVal) : 0.0f; + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", val, maxVal); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); + ImGui::ProgressBar(fraction, ImVec2(160.0f, 14.0f), overlay); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("%s", displayName); + ImGui::Separator(); + ImGui::Text("Base: %u", e.skill->value); + if (e.skill->bonusPerm > 0) + ImGui::Text("Permanent bonus: +%u", e.skill->bonusPerm); + if (e.skill->bonusTemp > 0) + ImGui::Text("Temporary bonus: +%u", e.skill->bonusTemp); + ImGui::Text("Max: %u", maxVal); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + ImGui::Spacing(); + } + } + + ImGui::EndChild(); + ImGui::End(); +} + }} // namespace wowee::ui From 113be66314bf293a52aae1aae38b5294710d6e18 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:54:59 -0700 Subject: [PATCH 50/64] feat: parse MSG_BATTLEGROUND_PLAYER_POSITIONS and show flag carriers on minimap Replaces the silent consume with full packet parsing: reads two lists of (guid, x, y) positions (typically ally and horde flag carriers) and stores them in bgPlayerPositions_. Renders each as a colored diamond on the minimap (blue=group0, red=group1) with a "Flag carrier" tooltip showing the player's name when available. --- include/game/game_handler.hpp | 12 +++++++++ src/game/game_handler.cpp | 18 ++++++++++--- src/ui/game_screen.cpp | 49 +++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 114ee071..027b5de8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -497,6 +497,15 @@ public: return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_; } + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + struct BgPlayerPosition { + uint64_t guid = 0; + float wowX = 0.0f; // canonical WoW X (north) + float wowY = 0.0f; // canonical WoW Y (west) + int group = 0; // 0 = first list (usually ally flag carriers), 1 = second list + }; + const std::vector& getBgPlayerPositions() const { return bgPlayerPositions_; } + // Network latency (milliseconds, updated each PONG response) uint32_t getLatencyMs() const { return lastLatency; } @@ -2780,6 +2789,9 @@ private: // BG scoreboard (MSG_PVP_LOG_DATA) BgScoreboardData bgScoreboard_; + // BG flag carrier / player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + std::vector bgPlayerPositions_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7f1c7b02..8f83e052 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5587,10 +5587,22 @@ void GameHandler::handlePacket(network::Packet& packet) { addUIError("Battlefield port denied."); addSystemChatMessage("Battlefield port denied."); break; - case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: - // Optional map position updates for BG objectives/players. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: { + bgPlayerPositions_.clear(); + for (int grp = 0; grp < 2; ++grp) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t count = packet.readUInt32(); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 16; ++i) { + BgPlayerPosition pos; + pos.guid = packet.readUInt64(); + pos.wowX = packet.readFloat(); + pos.wowY = packet.readFloat(); + pos.group = grp; + bgPlayerPositions_.push_back(pos); + } + } break; + } case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE: addSystemChatMessage("You have been removed from the PvP queue."); break; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b7cc3135..6430e1df 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17391,6 +17391,55 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + { + const auto& bgPositions = gameHandler.getBgPlayerPositions(); + if (!bgPositions.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + // group 0 = typically ally-held flag / first list; group 1 = enemy + static const ImU32 kBgGroupColors[2] = { + IM_COL32( 80, 180, 255, 240), // group 0: blue (alliance) + IM_COL32(220, 50, 50, 240), // group 1: red (horde) + }; + for (const auto& bp : bgPositions) { + // Packet coords: wowX=canonical X (north), wowY=canonical Y (west) + glm::vec3 bpRender = core::coords::canonicalToRender(glm::vec3(bp.wowX, bp.wowY, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(bpRender, sx, sy)) continue; + + ImU32 col = kBgGroupColors[bp.group & 1]; + + // Draw a flag-like diamond icon + const float r = 5.0f; + ImVec2 top (sx, sy - r); + ImVec2 right(sx + r, sy ); + ImVec2 bot (sx, sy + r); + ImVec2 left (sx - r, sy ); + drawList->AddQuadFilled(top, right, bot, left, col); + drawList->AddQuad(top, right, bot, left, IM_COL32(255, 255, 255, 180), 1.0f); + + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + // Show entity name if available, otherwise guid + auto ent = gameHandler.getEntityManager().getEntity(bp.guid); + if (ent) { + std::string nm; + if (ent->getType() == game::ObjectType::PLAYER) { + auto pl = std::static_pointer_cast(ent); + nm = pl ? pl->getName() : ""; + } + if (!nm.empty()) + ImGui::SetTooltip("Flag carrier: %s", nm.c_str()); + else + ImGui::SetTooltip("Flag carrier"); + } else { + ImGui::SetTooltip("Flag carrier"); + } + } + } + } + } + // Corpse direction indicator — shown when player is a ghost if (gameHandler.isPlayerGhost()) { float corpseCanX = 0.0f, corpseCanY = 0.0f; From 5df5f4d423ccadc26899ff0cc02a7dbb1fc921f0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 20:59:29 -0700 Subject: [PATCH 51/64] feat: handle SMSG_PET_RENAMEABLE to auto-open pet rename dialog on first tame When the server sends SMSG_PET_RENAMEABLE (after taming a pet for the first time), the pet rename modal now automatically opens so the player can name their new pet without needing to right-click the pet frame. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 6 +++++- src/ui/game_screen.cpp | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 027b5de8..f67477d4 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -718,6 +718,8 @@ public: void dismissPet(); void renamePet(const std::string& newName); bool hasPet() const { return petGuid_ != 0; } + // Returns true once after SMSG_PET_RENAMEABLE; consuming the flag clears it. + bool consumePetRenameablePending() { bool v = petRenameablePending_; petRenameablePending_ = false; return v; } uint64_t getPetGuid() const { return petGuid_; } // ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ---- @@ -2747,6 +2749,7 @@ private: uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss uint8_t petReact_ = 1; // 0=passive,1=defensive,2=aggressive + bool petRenameablePending_ = false; // set by SMSG_PET_RENAMEABLE, consumed by UI std::vector petSpellList_; // known pet spells std::unordered_set petAutocastSpells_; // spells with autocast on diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8f83e052..4b052724 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7661,10 +7661,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: case Opcode::SMSG_PET_UNLEARN_CONFIRM: - case Opcode::SMSG_PET_RENAMEABLE: case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PET_RENAMEABLE: + // Server signals that the pet can now be named (first tame) + petRenameablePending_ = true; + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_PET_NAME_INVALID: addUIError("That pet name is invalid. Please choose a different name."); addSystemChatMessage("That pet name is invalid. Please choose a different name."); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6430e1df..40fab36e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -629,6 +629,12 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPetFrame(gameHandler); } + // Auto-open pet rename modal when server signals the pet is renameable (first tame) + if (gameHandler.consumePetRenameablePending()) { + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } + // Totem frame (Shaman only, when any totem is active) if (gameHandler.getPlayerClass() == 7) { renderTotemFrame(gameHandler); From 67c8101f6725ee7ff96bb2d2ef42143146e85c4a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:08:02 -0700 Subject: [PATCH 52/64] fix: add missing TOGGLE_SKILLS to keybinding_manager (fixes CI build failure) --- include/ui/keybinding_manager.hpp | 1 + src/ui/keybinding_manager.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/include/ui/keybinding_manager.hpp b/include/ui/keybinding_manager.hpp index e242ae5d..3c67b125 100644 --- a/include/ui/keybinding_manager.hpp +++ b/include/ui/keybinding_manager.hpp @@ -30,6 +30,7 @@ public: TOGGLE_NAMEPLATES, TOGGLE_RAID_FRAMES, TOGGLE_ACHIEVEMENTS, + TOGGLE_SKILLS, ACTION_COUNT }; diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index b522e671..a7f52a3b 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -36,6 +36,7 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) + bindings_[static_cast(Action::TOGGLE_SKILLS)] = ImGuiKey_K; // WoW standard: K opens Skills/Professions } bool KeybindingManager::isActionPressed(Action action, bool repeat) { @@ -93,6 +94,7 @@ const char* KeybindingManager::getActionName(Action action) { case Action::TOGGLE_NAMEPLATES: return "Nameplates"; case Action::TOGGLE_RAID_FRAMES: return "Raid Frames"; case Action::TOGGLE_ACHIEVEMENTS: return "Achievements"; + case Action::TOGGLE_SKILLS: return "Skills / Professions"; case Action::ACTION_COUNT: break; } return "Unknown"; @@ -158,6 +160,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { else if (action == "toggle_raid_frames") actionIdx = static_cast(Action::TOGGLE_RAID_FRAMES); else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUESTS); // legacy alias else if (action == "toggle_achievements") actionIdx = static_cast(Action::TOGGLE_ACHIEVEMENTS); + else if (action == "toggle_skills") actionIdx = static_cast(Action::TOGGLE_SKILLS); if (actionIdx < 0) continue; @@ -254,6 +257,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const { {Action::TOGGLE_NAMEPLATES, "toggle_nameplates"}, {Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"}, {Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"}, + {Action::TOGGLE_SKILLS, "toggle_skills"}, }; for (const auto& [action, nameStr] : actionMap) { From 49ba89dfc3c570adb8d2379f15efc4967c9c3d4d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:13:27 -0700 Subject: [PATCH 53/64] feat: handle SMSG_PET_UNLEARN_CONFIRM with pet talent respec dialog Parses the pet talent wipe confirm packet (petGuid + cost), shows a confirmation dialog matching the player talent reset UX, and sends CMSG_PET_UNLEARN_TALENTS on confirmation. Completes the pet talent respec flow for Hunters/Warlocks on WotLK servers. --- include/game/game_handler.hpp | 9 +++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 25 ++++++++++++- src/ui/game_screen.cpp | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f67477d4..ff253b96 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1176,6 +1176,11 @@ public: uint32_t getTalentWipeCost() const { return talentWipeCost_; } void confirmTalentWipe(); void cancelTalentWipe() { talentWipePending_ = false; } + // Pet talent respec confirm + bool showPetUnlearnDialog() const { return petUnlearnPending_; } + uint32_t getPetUnlearnCost() const { return petUnlearnCost_; } + void confirmPetUnlearn(); + void cancelPetUnlearn() { petUnlearnPending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ @@ -3305,6 +3310,10 @@ private: bool talentWipePending_ = false; uint64_t talentWipeNpcGuid_ = 0; uint32_t talentWipeCost_ = 0; + // ---- Pet talent respec confirm dialog ---- + bool petUnlearnPending_ = false; + uint64_t petUnlearnGuid_ = 0; + uint32_t petUnlearnCost_ = 0; bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST uint64_t resurrectCasterGuid_ = 0; std::string resurrectCasterName_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 52bcca78..603aba7d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -358,6 +358,7 @@ private: void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); + void renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); void applyGraphicsPreset(GraphicsPreset preset); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4b052724..40c861d3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7660,7 +7660,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PET_GUIDS: case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: - case Opcode::SMSG_PET_UNLEARN_CONFIRM: + case Opcode::SMSG_PET_UNLEARN_CONFIRM: { + // uint64 petGuid + uint32 cost (copper) + if (packet.getSize() - packet.getReadPos() >= 12) { + petUnlearnGuid_ = packet.readUInt64(); + petUnlearnCost_ = packet.readUInt32(); + petUnlearnPending_ = true; + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; @@ -18867,6 +18876,20 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { addSystemChatMessage(msg); } +void GameHandler::confirmPetUnlearn() { + if (!petUnlearnPending_) return; + petUnlearnPending_ = false; + if (state != WorldState::IN_WORLD || !socket) return; + + // Respond with CMSG_PET_UNLEARN_TALENTS (no payload in 3.3.5a) + network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS)); + socket->send(pkt); + LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS"); + addSystemChatMessage("Pet talent reset confirmed."); + petUnlearnGuid_ = 0; + petUnlearnCost_ = 0; +} + void GameHandler::confirmTalentWipe() { if (!talentWipePending_) return; talentWipePending_ = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 40fab36e..5eda576d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -741,6 +741,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); renderTalentWipeConfirmDialog(gameHandler); + renderPetUnlearnConfirmDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -15568,6 +15569,74 @@ void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +void GameScreen::renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showPetUnlearnDialog()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##PetUnlearnDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getPetUnlearnCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = std::string("Reset your pet's talents for ") + costStr + "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All pet talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f)); + if (ImGui::Button("Confirm##petunlearn", ImVec2(btnW, 30))) { + gameHandler.confirmPetUnlearn(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f)); + if (ImGui::Button("Cancel##petunlearn", ImVec2(btnW, 30))) { + gameHandler.cancelPetUnlearn(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Settings Window // ============================================================ From b29d76bbc85896206ebf1570b2cd400919993120 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:17:22 -0700 Subject: [PATCH 54/64] feat: highlight quest-starting items in loot window with gold indicator Items with startQuestId != 0 now show: - Gold outer glow border (2px) around the item icon - Gold "!" badge in the top-right corner of the icon - "Begins a Quest" label in gold on the second text line Matches WoW's visual convention for quest-pickup items in loot rolls. --- src/ui/game_screen.cpp | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5eda576d..ca5e263d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13484,6 +13484,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { itemName = "Item #" + std::to_string(item.itemId); } ImVec4 qColor = InventoryScreen::getQualityColor(quality); + bool startsQuest = (info && info->startQuestId != 0); // Get item icon uint32_t displayId = item.displayInfoId; @@ -13544,6 +13545,14 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), IM_COL32(80, 80, 80, 200)); } + // Quest-starter: gold outer glow border + "!" badge on top-right corner + if (startsQuest) { + drawList->AddRect(ImVec2(cursor.x - 2.0f, cursor.y - 2.0f), + ImVec2(cursor.x + iconSize + 2.0f, cursor.y + iconSize + 2.0f), + IM_COL32(255, 210, 0, 210), 0.0f, 0, 2.0f); + drawList->AddText(ImVec2(cursor.x + iconSize - 10.0f, cursor.y + 1.0f), + IM_COL32(255, 210, 0, 255), "!"); + } // Draw item name float textX = cursor.x + iconSize + 6.0f; @@ -13551,12 +13560,15 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(textX, textY), ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str()); - // Draw count if > 1 - if (item.count > 1) { + // Draw count or "Begins a Quest" label on second line + float secondLineY = textY + ImGui::GetTextLineHeight(); + if (startsQuest) { + drawList->AddText(ImVec2(textX, secondLineY), + IM_COL32(255, 210, 0, 255), "Begins a Quest"); + } else if (item.count > 1) { char countStr[32]; snprintf(countStr, sizeof(countStr), "x%u", item.count); - float countY = textY + ImGui::GetTextLineHeight(); - drawList->AddText(ImVec2(textX, countY), IM_COL32(200, 200, 200, 220), countStr); + drawList->AddText(ImVec2(textX, secondLineY), IM_COL32(200, 200, 200, 220), countStr); } ImGui::PopID(); From 005b1fcb541fe60abd9e720f74720d281114745c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:22:41 -0700 Subject: [PATCH 55/64] feat: implement Warden API stub dispatch via Unicorn UC_HOOK_CODE Previously hookAPI() allocated a stub address and registered a C++ handler but never stored the handler or wrote any executable code to the stub region, meaning any Warden module call to a Windows API would execute zeros and crash or silently return garbage. Changes: - Store ApiHookEntry {argCount, handler} per stub address in apiHandlers_ - Write RET (0xC3) to stub memory as a safe fallback - Register UC_HOOK_CODE over the API stub address range during initialize() - hookCode() now detects stub addresses, reads args from the emulated stack, dispatches to the C++ handler, then simulates stdcall epilogue by setting EAX/ESP/EIP so Unicorn returns cleanly to the caller - Convert static-local nextStubAddr to instance member nextApiStubAddr_ so re-initialization resets the allocator correctly - Known arg counts for all 7 registered Windows APIs (VirtualAlloc, VirtualFree, GetTickCount, Sleep, GetCurrentThreadId, GetCurrentProcessId, ReadProcessMemory) --- include/game/warden_emulator.hpp | 11 +++- src/game/warden_emulator.cpp | 97 +++++++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/include/game/warden_emulator.hpp b/include/game/warden_emulator.hpp index c3dbc37c..30a0759f 100644 --- a/include/game/warden_emulator.hpp +++ b/include/game/warden_emulator.hpp @@ -147,9 +147,18 @@ private: uint32_t heapSize_; // Heap size uint32_t apiStubBase_; // API stub base address - // API hooks: DLL name -> Function name -> Handler + // API hooks: DLL name -> Function name -> stub address std::map> apiAddresses_; + // API stub dispatch: stub address -> {argCount, handler} + struct ApiHookEntry { + int argCount; + std::function&)> handler; + }; + std::map apiHandlers_; + uint32_t nextApiStubAddr_; // tracks next free stub slot (replaces static local) + bool apiCodeHookRegistered_; // true once UC_HOOK_CODE for stub range is added + // Memory allocation tracking std::map allocations_; std::map freeBlocks_; // free-list keyed by base address diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index b1a99f7e..a30a6dd6 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -32,6 +32,8 @@ WardenEmulator::WardenEmulator() , heapBase_(HEAP_BASE) , heapSize_(HEAP_SIZE) , apiStubBase_(API_STUB_BASE) + , nextApiStubAddr_(API_STUB_BASE) + , apiCodeHookRegistered_(false) , nextHeapAddr_(HEAP_BASE) { } @@ -51,8 +53,11 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 allocations_.clear(); freeBlocks_.clear(); apiAddresses_.clear(); + apiHandlers_.clear(); hooks_.clear(); nextHeapAddr_ = heapBase_; + nextApiStubAddr_ = apiStubBase_; + apiCodeHookRegistered_ = false; { char addrBuf[32]; @@ -149,6 +154,13 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0); hooks_.push_back(hh); + // Add code hook over the API stub area so Windows API calls are intercepted + uc_hook apiHook; + uc_hook_add(uc_, &apiHook, UC_HOOK_CODE, (void*)hookCode, this, + API_STUB_BASE, API_STUB_BASE + 0x10000 - 1); + hooks_.push_back(apiHook); + apiCodeHookRegistered_ = true; + { char sBuf[128]; std::snprintf(sBuf, sizeof(sBuf), "WardenEmulator: Emulator initialized Stack: 0x%X-0x%X Heap: 0x%X-0x%X", @@ -161,23 +173,45 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uint32_t WardenEmulator::hookAPI(const std::string& dllName, const std::string& functionName, - [[maybe_unused]] std::function&)> handler) { - // Allocate address for this API stub - static uint32_t nextStubAddr = API_STUB_BASE; - uint32_t stubAddr = nextStubAddr; - nextStubAddr += 16; // Space for stub code + std::function&)> handler) { + // Allocate address for this API stub (16 bytes each) + uint32_t stubAddr = nextApiStubAddr_; + nextApiStubAddr_ += 16; - // Store mapping + // Store address mapping for IAT patching apiAddresses_[dllName][functionName] = stubAddr; - { - char hBuf[32]; - std::snprintf(hBuf, sizeof(hBuf), "0x%X", stubAddr); - LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf); + // Determine stdcall arg count from known Windows APIs so the hook can + // clean up the stack correctly (RETN N convention). + static const std::pair knownArgCounts[] = { + {"VirtualAlloc", 4}, + {"VirtualFree", 3}, + {"GetTickCount", 0}, + {"Sleep", 1}, + {"GetCurrentThreadId", 0}, + {"GetCurrentProcessId", 0}, + {"ReadProcessMemory", 5}, + }; + int argCount = 0; + for (const auto& [name, cnt] : knownArgCounts) { + if (functionName == name) { argCount = cnt; break; } } - // TODO: Write stub code that triggers a hook callback - // For now, just return the address for IAT patching + // Store the handler so hookCode() can dispatch to it + apiHandlers_[stubAddr] = { argCount, std::move(handler) }; + + // Write a RET (0xC3) at the stub address as a safe fallback in case + // the code hook fires after EIP has already advanced past our intercept. + if (uc_) { + static const uint8_t retInstr = 0xC3; + uc_mem_write(uc_, stubAddr, &retInstr, 1); + } + + { + char hBuf[64]; + std::snprintf(hBuf, sizeof(hBuf), "0x%X (argCount=%d)", stubAddr, argCount); + LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf); + } return stubAddr; } @@ -503,8 +537,40 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve // Unicorn Callbacks // ============================================================================ -void WardenEmulator::hookCode([[maybe_unused]] uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, [[maybe_unused]] void* userData) { - (void)address; // Trace disabled by default to avoid log spam +void WardenEmulator::hookCode(uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, void* userData) { + auto* self = static_cast(userData); + if (!self) return; + + auto it = self->apiHandlers_.find(static_cast(address)); + if (it == self->apiHandlers_.end()) return; // not an API stub — trace disabled to avoid spam + + const ApiHookEntry& entry = it->second; + + // Read stack: [ESP+0] = return address, [ESP+4..] = stdcall args + uint32_t esp = 0; + uc_reg_read(uc, UC_X86_REG_ESP, &esp); + + uint32_t retAddr = 0; + uc_mem_read(uc, esp, &retAddr, 4); + + std::vector args(static_cast(entry.argCount)); + for (int i = 0; i < entry.argCount; ++i) { + uint32_t val = 0; + uc_mem_read(uc, esp + 4 + static_cast(i) * 4, &val, 4); + args[static_cast(i)] = val; + } + + // Dispatch to the C++ handler + uint32_t retVal = 0; + if (entry.handler) { + retVal = entry.handler(*self, args); + } + + // Simulate stdcall epilogue: pop return address + args + uint32_t newEsp = esp + 4 + static_cast(entry.argCount) * 4; + uc_reg_write(uc, UC_X86_REG_EAX, &retVal); + uc_reg_write(uc, UC_X86_REG_ESP, &newEsp); + uc_reg_write(uc, UC_X86_REG_EIP, &retAddr); } void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, uint64_t address, int size, [[maybe_unused]] int64_t value, [[maybe_unused]] void* userData) { @@ -533,7 +599,8 @@ WardenEmulator::WardenEmulator() : uc_(nullptr), moduleBase_(0), moduleSize_(0) , stackBase_(0), stackSize_(0) , heapBase_(0), heapSize_(0) - , apiStubBase_(0), nextHeapAddr_(0) {} + , apiStubBase_(0), nextApiStubAddr_(0), apiCodeHookRegistered_(false) + , nextHeapAddr_(0) {} WardenEmulator::~WardenEmulator() {} bool WardenEmulator::initialize(const void*, size_t, uint32_t) { return false; } uint32_t WardenEmulator::hookAPI(const std::string&, const std::string&, From c70740fcdfb58cffd4a3a474d290fbea9fc3aa1e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:29:09 -0700 Subject: [PATCH 56/64] feat: wire Warden funcList_ dispatchers and implement PacketHandler call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously initializeModule() read the 4 WardenFuncList function addresses from emulated memory, logged them, then discarded them — funcList_ was never populated, so tick(), generateRC4Keys(), and processCheckRequest() were permanently no-ops even when the Unicorn emulator successfully ran the module. Changes: - initializeModule() now wraps each non-null emulated function address in a std::function lambda that marshals args to/from emulated memory via emulator_->writeData/callFunction/freeMemory - generateRC4Keys: copies 4-byte seed to emulated space, calls function - unload: calls function with NULL (module saves own RC4 state) - tick: direct uint32_t(deltaMs) dispatch, returns emulated EAX - packetHandler: 2-arg variant for generic callers - Stores emulatedPacketHandlerAddr_ for full 4-arg call in processCheckRequest - processCheckRequest() now calls the emulated PacketHandler with the proper 4-argument stdcall convention: (data, size, responseOut, responseSizeOut), reads back the response size and bytes, returns them in responseOut - unload() resets emulatedPacketHandlerAddr_ to 0 for clean re-initialization - Remove dead no-op renderObjectiveTracker() (no call sites, superseded) --- include/game/warden_module.hpp | 1 + include/ui/game_screen.hpp | 1 - src/game/warden_module.cpp | 112 +++++++++++++++++++++++++++------ src/ui/game_screen.cpp | 6 +- 4 files changed, 94 insertions(+), 26 deletions(-) diff --git a/include/game/warden_module.hpp b/include/game/warden_module.hpp index bcea989f..e11bc4f9 100644 --- a/include/game/warden_module.hpp +++ b/include/game/warden_module.hpp @@ -140,6 +140,7 @@ private: size_t relocDataOffset_ = 0; // Offset into decompressedData_ where relocation data starts WardenFuncList funcList_; // Callback functions std::unique_ptr emulator_; // Cross-platform x86 emulator + uint32_t emulatedPacketHandlerAddr_ = 0; // Raw emulated VA for 4-arg PacketHandler call // Validation and loading steps bool verifyMD5(const std::vector& data, diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 603aba7d..0e73c552 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -379,7 +379,6 @@ private: void renderGuildBankWindow(game::GameHandler& gameHandler); void renderAuctionHouseWindow(game::GameHandler& gameHandler); void renderDungeonFinderWindow(game::GameHandler& gameHandler); - void renderObjectiveTracker(game::GameHandler& gameHandler); void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 9f577978..f93dc300 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -161,24 +161,53 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, } try { - // Call module's PacketHandler - // void PacketHandler(uint8_t* checkData, size_t checkSize, - // uint8_t* responseOut, size_t* responseSizeOut) - LOG_INFO("WardenModule: Calling PacketHandler..."); + if (emulatedPacketHandlerAddr_ == 0) { + LOG_ERROR("WardenModule: PacketHandler address not set (module not fully initialized)"); + emulator_->freeMemory(checkDataAddr); + emulator_->freeMemory(responseAddr); + return false; + } - // For now, this is a placeholder - actual calling would depend on - // the module's exact function signature - LOG_WARNING("WardenModule: PacketHandler execution stubbed"); - LOG_INFO("WardenModule: Would call emulated function to process checks"); - LOG_INFO("WardenModule: This would generate REAL responses (not fakes!)"); + // Allocate uint32_t for responseSizeOut in emulated memory + uint32_t initialSize = 1024; + uint32_t responseSizeAddr = emulator_->writeData(&initialSize, sizeof(uint32_t)); + if (responseSizeAddr == 0) { + LOG_ERROR("WardenModule: Failed to allocate responseSizeAddr"); + emulator_->freeMemory(checkDataAddr); + emulator_->freeMemory(responseAddr); + return false; + } + + // Call: void PacketHandler(uint8_t* data, uint32_t size, + // uint8_t* responseOut, uint32_t* responseSizeOut) + LOG_INFO("WardenModule: Calling emulated PacketHandler..."); + emulator_->callFunction(emulatedPacketHandlerAddr_, { + checkDataAddr, + static_cast(checkData.size()), + responseAddr, + responseSizeAddr + }); + + // Read back response size and data + uint32_t responseSize = 0; + emulator_->readMemory(responseSizeAddr, &responseSize, sizeof(uint32_t)); + emulator_->freeMemory(responseSizeAddr); + + if (responseSize > 0 && responseSize <= 1024) { + responseOut.resize(responseSize); + if (!emulator_->readMemory(responseAddr, responseOut.data(), responseSize)) { + LOG_ERROR("WardenModule: Failed to read response data"); + responseOut.clear(); + } else { + LOG_INFO("WardenModule: PacketHandler wrote ", responseSize, " byte response"); + } + } else { + LOG_WARNING("WardenModule: PacketHandler returned invalid responseSize=", responseSize); + } - // Clean up emulator_->freeMemory(checkDataAddr); emulator_->freeMemory(responseAddr); - - // For now, return false to use fake responses - // Once we have a real module, we'd read the response from responseAddr - return false; + return !responseOut.empty(); } catch (const std::exception& e) { LOG_ERROR("WardenModule: Exception during PacketHandler: ", e.what()); @@ -240,6 +269,7 @@ void WardenModule::unload() { // Clear function pointers funcList_ = {}; + emulatedPacketHandlerAddr_ = 0; loaded_ = false; moduleData_.clear(); @@ -961,7 +991,12 @@ bool WardenModule::initializeModule() { } // Read WardenFuncList structure from emulated memory - // Structure has 4 function pointers (16 bytes) + // Structure has 4 function pointers (16 bytes): + // [0] generateRC4Keys(uint8_t* seed) + // [1] unload(uint8_t* rc4Keys) + // [2] packetHandler(uint8_t* data, uint32_t size, + // uint8_t* responseOut, uint32_t* responseSizeOut) + // [3] tick(uint32_t deltaMs) -> uint32_t uint32_t funcAddrs[4] = {}; if (emulator_->readMemory(result, funcAddrs, 16)) { char fb[4][32]; @@ -973,11 +1008,48 @@ bool WardenModule::initializeModule() { LOG_INFO("WardenModule: packetHandler: ", fb[2]); LOG_INFO("WardenModule: tick: ", fb[3]); - // Store function addresses for later use - // funcList_.generateRC4Keys = ... (would wrap emulator calls) - // funcList_.unload = ... - // funcList_.packetHandler = ... - // funcList_.tick = ... + // Wrap emulated function addresses into std::function dispatchers + WardenEmulator* emu = emulator_.get(); + + if (funcAddrs[0]) { + uint32_t addr = funcAddrs[0]; + funcList_.generateRC4Keys = [emu, addr](uint8_t* seed) { + // Warden RC4 seed is a fixed 4-byte value + uint32_t seedAddr = emu->writeData(seed, 4); + if (seedAddr) { + emu->callFunction(addr, {seedAddr}); + emu->freeMemory(seedAddr); + } + }; + } + + if (funcAddrs[1]) { + uint32_t addr = funcAddrs[1]; + funcList_.unload = [emu, addr]([[maybe_unused]] uint8_t* rc4Keys) { + emu->callFunction(addr, {0u}); // pass NULL; module saves its own state + }; + } + + if (funcAddrs[2]) { + // Store raw address for the 4-arg call in processCheckRequest + emulatedPacketHandlerAddr_ = funcAddrs[2]; + uint32_t addr = funcAddrs[2]; + // Simple 2-arg variant for generic callers (no response extraction) + funcList_.packetHandler = [emu, addr](uint8_t* data, size_t length) { + uint32_t dataAddr = emu->writeData(data, length); + if (dataAddr) { + emu->callFunction(addr, {dataAddr, static_cast(length)}); + emu->freeMemory(dataAddr); + } + }; + } + + if (funcAddrs[3]) { + uint32_t addr = funcAddrs[3]; + funcList_.tick = [emu, addr](uint32_t deltaMs) -> uint32_t { + return emu->callFunction(addr, {deltaMs}); + }; + } } LOG_INFO("WardenModule: Module fully initialized and ready!"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ca5e263d..7b08a08d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -22421,11 +22421,7 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { ImGui::End(); } -// ─── Quest Objective Tracker (legacy stub — superseded by renderQuestObjectiveTracker) ─── -void GameScreen::renderObjectiveTracker(game::GameHandler&) { - // No-op: consolidated into renderQuestObjectiveTracker which renders the - // full-featured draggable tracker with context menus and item icons. -} + // ─── Book / Scroll / Note Window ────────────────────────────────────────────── void GameScreen::renderBookWindow(game::GameHandler& gameHandler) { From a731223e47f7f68894053dbeeb97146ce3b2c805 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:38:08 -0700 Subject: [PATCH 57/64] fix: right-clicking a quest-starting item now opens the quest offer dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items with startQuestId != 0 were calling useItemBySlot()/useItemInBag() which sends CMSG_USE_ITEM — but quest-starting items have no on-use spell, so the server silently ignored the packet and no quest dialog appeared. Fix: - offerQuestFromItem(itemGuid, questId): sends CMSG_QUESTGIVER_QUERY_QUEST with the item's own GUID as the questgiver GUID. The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() already picks up and opens the Accept/Decline dialog with full rewards/description. - getBagItemGuid(bagIndex, slotIndex): resolves the per-slot item GUID from the bag's containerContents_ map (mirrors the logic inside useItemInBag). - inventory_screen.cpp right-click handler: checks item.startQuestId != 0 before the equip/use branch; if set, resolves item GUID and calls offerQuestFromItem. Works for both backpack slots and bag slots. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 28 ++++++++++++++++++++++++++++ src/ui/inventory_screen.cpp | 16 ++++++++++++---- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ff253b96..e75fedb5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1460,6 +1460,9 @@ public: void acceptQuest(); void declineQuest(); void closeGossip(); + // Quest-starting items: right-click triggers quest offer dialog via questgiver protocol + void offerQuestFromItem(uint64_t itemGuid, uint32_t questId); + uint64_t getBagItemGuid(int bagIndex, int slotIndex) const; bool isGossipWindowOpen() const { return gossipWindowOpen; } const GossipMessageData& getCurrentGossip() const { return currentGossip; } bool isQuestDetailsOpen() { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 40c861d3..c5fe5c5e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20552,6 +20552,34 @@ void GameHandler::closeGossip() { currentGossip = GossipMessageData{}; } +void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { + if (state != WorldState::IN_WORLD || !socket) return; + if (itemGuid == 0 || questId == 0) { + addSystemChatMessage("Cannot start quest right now."); + return; + } + // Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver." + // The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() + // picks up and opens the Accept/Decline dialog. + auto queryPkt = packetParsers_ + ? packetParsers_->buildQueryQuestPacket(itemGuid, questId) + : QuestgiverQueryQuestPacket::build(itemGuid, questId); + socket->send(queryPkt); + LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec, + " questId=", questId); +} + +uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0; + if (slotIndex < 0) return 0; + uint64_t bagGuid = equipSlotGuids_[19 + bagIndex]; + if (bagGuid == 0) return 0; + auto it = containerContents_.find(bagGuid); + if (it == containerContents_.end()) return 0; + if (slotIndex >= static_cast(it->second.numSlots)) return 0; + return it->second.slotGuids[slotIndex]; +} + void GameHandler::openVendor(uint64_t npcGuid) { if (state != WorldState::IN_WORLD || !socket) return; buybackItems_.clear(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 42c03e8e..083096a7 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2335,8 +2335,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { LOG_INFO("Right-click backpack item: name='", item.name, "' inventoryType=", (int)item.inventoryType, - " itemId=", item.itemId); - if (item.inventoryType > 0) { + " itemId=", item.itemId, + " startQuestId=", item.startQuestId); + if (item.startQuestId != 0) { + uint64_t iGuid = gameHandler_->getBackpackItemGuid(backpackIndex); + gameHandler_->offerQuestFromItem(iGuid, item.startQuestId); + } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemBySlot(backpackIndex); } else { gameHandler_->useItemBySlot(backpackIndex); @@ -2344,8 +2348,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (kind == SlotKind::BACKPACK && isBagSlot) { LOG_INFO("Right-click bag item: name='", item.name, "' inventoryType=", (int)item.inventoryType, - " bagIndex=", bagIndex, " slotIndex=", bagSlotIndex); - if (item.inventoryType > 0) { + " bagIndex=", bagIndex, " slotIndex=", bagSlotIndex, + " startQuestId=", item.startQuestId); + if (item.startQuestId != 0) { + uint64_t iGuid = gameHandler_->getBagItemGuid(bagIndex, bagSlotIndex); + gameHandler_->offerQuestFromItem(iGuid, item.startQuestId); + } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex); } else { gameHandler_->useItemInBag(bagIndex, bagSlotIndex); From 32497552d15a2983b8dbe7db65300f95a5b06c7b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 21:52:45 -0700 Subject: [PATCH 58/64] fix: R key resets camera angles only; consume all SpellCastTargets bytes - CameraController::resetAngles(): new method that only resets yaw/pitch without teleporting the player. R key now calls resetAngles() instead of reset() so pressing R no longer moves the character to spawn. The full reset() (position + angles) is still used on world-entry and respawn via application.cpp. - packet_parsers_classic: parseSpellStart now calls skipClassicSpellCastTargets() to consume all target payload bytes (UNIT, ITEM, SOURCE_LOCATION, DEST_LOCATION, etc.) instead of only handling UNIT/OBJECT. Prevents packet-read corruption for ground- targeted AoE spells. - packet_parsers_tbc: added skipTbcSpellCastTargets() static helper (uint32 targetFlags, full payload coverage including TRADE_ITEM and STRING targets). parseSpellStart now uses it. --- include/rendering/camera_controller.hpp | 1 + src/game/packet_parsers_classic.cpp | 27 ++++---- src/game/packet_parsers_tbc.cpp | 87 ++++++++++++++++++++----- src/rendering/camera_controller.cpp | 13 +++- 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index e219e917..fae92812 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -44,6 +44,7 @@ public: } void reset(); + void resetAngles(); void teleportTo(const glm::vec3& pos); void setOnlineMode(bool online) { onlineMode = online; } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 7b25d257..e04832f6 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -520,23 +520,20 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) data.castTime = packet.readUInt32(); - // SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK) - if (rem() < 2) { - LOG_WARNING("[Classic] Spell start: missing targetFlags"); - packet.setReadPos(startPos); - return false; - } - uint16_t targetFlags = packet.readUInt16(); - // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID - if ((targetFlags & 0x02) || (targetFlags & 0x800)) { - if (!hasFullPackedGuid(packet)) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + // SpellCastTargets: consume ALL target payload types so subsequent reads stay aligned. + // Previously only UNIT(0x02)/OBJECT(0x800) were handled; DEST_LOCATION(0x40), + // SOURCE_LOCATION(0x20), and ITEM(0x10) bytes were silently skipped, corrupting + // castFlags/castTime for every AOE/ground-targeted spell (Rain of Fire, Blizzard, etc.). + { + uint64_t targetGuid = 0; + // skipClassicSpellCastTargets reads uint16 targetFlags and all payloads. + // Non-fatal on truncation: self-cast spells have zero-byte targets. + skipClassicSpellCastTargets(packet, &targetGuid); + data.targetGuid = targetGuid; } - LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms", + " targetGuid=0x", std::hex, data.targetGuid, std::dec); return true; } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index a29a0beb..3b0a545c 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1232,6 +1232,66 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector bool { + if (!(targetFlags & flag)) return true; + // Packed GUID: 1-byte mask + up to 8 data bytes + if (packet.getReadPos() >= packet.getSize()) return false; + uint8_t mask = packet.getData()[packet.getReadPos()]; + size_t needed = 1; + for (int b = 0; b < 8; ++b) if (mask & (1u << b)) ++needed; + if (packet.getSize() - packet.getReadPos() < needed) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (capture && primaryTargetGuid && *primaryTargetGuid == 0) *primaryTargetGuid = g; + return true; + }; + auto skipFloats3 = [&](uint32_t flag) -> bool { + if (!(targetFlags & flag)) return true; + if (packet.getSize() - packet.getReadPos() < 12) return false; + (void)packet.readFloat(); (void)packet.readFloat(); (void)packet.readFloat(); + return true; + }; + + // Process in wire order matching cmangos-tbc SpellCastTargets::write() + if (!readPackedGuidCond(0x0002, true)) return false; // UNIT + if (!readPackedGuidCond(0x0004, false)) return false; // UNIT_MINIPET + if (!readPackedGuidCond(0x0010, false)) return false; // ITEM + if (!skipFloats3(0x0020)) return false; // SOURCE_LOCATION + if (!skipFloats3(0x0040)) return false; // DEST_LOCATION + + if (targetFlags & 0x1000) { // TRADE_ITEM: uint8 + if (packet.getReadPos() >= packet.getSize()) return false; + (void)packet.readUInt8(); + } + if (targetFlags & 0x2000) { // STRING: null-terminated + const auto& raw = packet.getData(); + size_t pos = packet.getReadPos(); + while (pos < raw.size() && raw[pos] != 0) ++pos; + if (pos >= raw.size()) return false; + packet.setReadPos(pos + 1); + } + if (!readPackedGuidCond(0x8200, false)) return false; // CORPSE / PVP_CORPSE + if (!readPackedGuidCond(0x0800, true)) return false; // OBJECT + + return true; +} + // TbcPacketParsers::parseSpellStart — TBC 2.4.3 SMSG_SPELL_START // // TBC uses full uint64 GUIDs for casterGuid and casterUnit. @@ -1243,7 +1303,6 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector packet.getSize()) { - LOG_WARNING("[TBC] Spell start: missing targetFlags"); - packet.setReadPos(startPos); - return false; + // SpellCastTargets: consume ALL target payload types to keep the read position + // aligned for any bytes the caller may parse after this (ammo, etc.). + // The previous code only read UNIT(0x02)/OBJECT(0x800) target GUIDs and left + // DEST_LOCATION(0x40)/SOURCE_LOCATION(0x20)/ITEM(0x10) bytes unconsumed, + // corrupting subsequent reads for every AOE/ground-targeted spell cast. + { + uint64_t targetGuid = 0; + skipTbcSpellCastTargets(packet, &targetGuid); // non-fatal on truncation + data.targetGuid = targetGuid; } - uint32_t targetFlags = packet.readUInt32(); - const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT - if (needsTargetGuid) { - if (packet.getReadPos() + 8 > packet.getSize()) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = packet.readUInt64(); // full GUID in TBC - } - - LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms", + " targetGuid=0x", std::hex, data.targetGuid, std::dec); return true; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 22c5304f..d16fa26c 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -388,10 +388,11 @@ void CameraController::update(float deltaTime) { if (mounted_) sitting = false; xKeyWasDown = xDown; - // Reset camera with R key (edge-triggered) — only when UI doesn't want keyboard + // Reset camera angles with R key (edge-triggered) — only when UI doesn't want keyboard + // Does NOT move the player; full reset() is reserved for world-entry/respawn. bool rDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R); if (rDown && !rKeyWasDown) { - reset(); + resetAngles(); } rKeyWasDown = rDown; @@ -1941,6 +1942,14 @@ void CameraController::processMouseButton(const SDL_MouseButtonEvent& event) { mouseButtonDown = anyDown; } +void CameraController::resetAngles() { + if (!camera) return; + yaw = defaultYaw; + facingYaw = defaultYaw; + pitch = defaultPitch; + camera->setRotation(yaw, pitch); +} + void CameraController::reset() { if (!camera) { return; From c870460dea768609844721d4e81da8911f784637 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:00:06 -0700 Subject: [PATCH 59/64] fix: wire Warden module tick, generateRC4Keys, and unload callbacks The funcList_ dispatchers were populated by initializeModule() but the public tick(), generateRC4Keys(), and unload() methods had their actual call sites commented out as TODOs. - tick(): now calls funcList_.tick(deltaMs) so the emulated module can run its internal periodic scheduler. - generateRC4Keys(): now calls funcList_.generateRC4Keys(packet) so the Warden crypto stream is re-keyed as the module expects. - unload(): now calls funcList_.unload(nullptr) before freeing module memory, allowing the module to clean up its own state. All three paths already guard on !loaded_ || !funcList_. so they are no-ops when the module is not loaded or Unicorn is unavailable. --- src/game/warden_module.cpp | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index f93dc300..eea0f0ee 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -225,25 +225,18 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, return false; } -uint32_t WardenModule::tick([[maybe_unused]] uint32_t deltaMs) { +uint32_t WardenModule::tick(uint32_t deltaMs) { if (!loaded_ || !funcList_.tick) { - return 0; // No tick needed + return 0; } - - // TODO: Call module's Tick function - // return funcList_.tick(deltaMs); - - return 0; + return funcList_.tick(deltaMs); } -void WardenModule::generateRC4Keys([[maybe_unused]] uint8_t* packet) { +void WardenModule::generateRC4Keys(uint8_t* packet) { if (!loaded_ || !funcList_.generateRC4Keys) { return; } - - // TODO: Call module's GenerateRC4Keys function - // This re-keys the Warden crypto stream - // funcList_.generateRC4Keys(packet); + funcList_.generateRC4Keys(packet); } void WardenModule::unload() { @@ -251,8 +244,7 @@ void WardenModule::unload() { // Call module's Unload() function if loaded if (loaded_ && funcList_.unload) { LOG_INFO("WardenModule: Calling module unload callback..."); - // TODO: Implement callback when execution layer is complete - // funcList_.unload(nullptr); + funcList_.unload(nullptr); } // Free executable memory region From b00025918c402d23f89fcd86ac4e99ba81f6a8cd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:05:24 -0700 Subject: [PATCH 60/64] feat: draw player facing arrow at minimap center The minimap had a comment "skip self (already drawn as arrow)" but no code that actually drew the arrow. Players had no visual indication of which direction they were facing on the minimap. Draws a chevron-shaped white/gold arrow at the minimap center: - On fixed-north minimap: arrow rotates to match camera compass bearing (computed from camera forward vector: atan2(-fwd.x, fwd.y)) - On rotating minimap: arrow points straight up because the minimap already rotates to put camera-forward at the top - Style: two filled triangles (tip+left half, tip+right half) with dark outline for readability against all map backgrounds - Rendered last so it sits on top of all other minimap markers --- src/ui/game_screen.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7b08a08d..8cd7a6c8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17595,6 +17595,46 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Player position arrow at minimap center, pointing in camera facing direction. + // On a rotating minimap the map already turns so forward = screen-up; on a fixed + // minimap we rotate the arrow to match the player's compass heading. + { + // Compute screen-space facing direction for the arrow. + // bearing = clockwise angle from screen-north (0 = facing north/up). + float arrowAngle = 0.0f; // 0 = pointing up (north) + if (!minimap->isRotateWithCamera()) { + // Fixed minimap: arrow must show actual facing relative to north. + glm::vec3 fwd = camera->getForward(); + // +render_y = north = screen-up, +render_x = west = screen-left. + // bearing from north clockwise: atan2(-fwd.x_west, fwd.y_north) + // => sin=east component, cos=north component + // In render coords west=+x, east=-x, so sin(bearing)=east=-fwd.x + arrowAngle = std::atan2(-fwd.x, fwd.y); // clockwise from north in screen space + } + // Screen direction the arrow tip points toward + float nx = std::sin(arrowAngle); // screen +X = east + float ny = -std::cos(arrowAngle); // screen -Y = north + + // Draw a chevron-style arrow: tip, two base corners, and a notch at the back + const float tipLen = 8.0f; // tip forward distance + const float baseW = 5.0f; // half-width at base + const float notchIn = 3.0f; // how far back the center notch sits + // Perpendicular direction (rotated 90°) + float px = ny; // perpendicular x + float py = -nx; // perpendicular y + + ImVec2 tip (centerX + nx * tipLen, centerY + ny * tipLen); + ImVec2 baseL(centerX - nx * baseW + px * baseW, centerY - ny * baseW + py * baseW); + ImVec2 baseR(centerX - nx * baseW - px * baseW, centerY - ny * baseW - py * baseW); + ImVec2 notch(centerX - nx * (baseW - notchIn), centerY - ny * (baseW - notchIn)); + + // Fill: bright white with slight gold tint, dark outline for readability + drawList->AddTriangleFilled(tip, baseL, notch, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangleFilled(tip, notch, baseR, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangle(tip, baseL, notch, IM_COL32(60, 40, 0, 200), 1.2f); + drawList->AddTriangle(tip, notch, baseR, IM_COL32(60, 40, 0, 200), 1.2f); + } + // Scroll wheel over minimap → zoom in/out { float wheel = ImGui::GetIO().MouseWheel; From a4415eb20770e746a0f20480ae81c87917f584ea Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:08:25 -0700 Subject: [PATCH 61/64] fix: clamp pointCount in handleMonsterMoveTransport to prevent DoS handleMonsterMoveTransport() read a server-supplied pointCount without any bounds check before iterating. A malformed packet with pointCount=0xFFFFFFFF would loop billions of times. All other parsers (MonsterMoveParser::parse, TBC parseMonsterMove) cap at 1000 or 16384. Added kMaxTransportSplinePoints=1000 cap with a LOG_WARNING, matching the limit used by MonsterMoveParser::parse() in world_packets.cpp. --- src/game/game_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c5fe5c5e..2eacb363 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17663,6 +17663,12 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { if (packet.getReadPos() + 4 > packet.getSize()) return; uint32_t pointCount = packet.readUInt32(); + constexpr uint32_t kMaxTransportSplinePoints = 1000; + if (pointCount > kMaxTransportSplinePoints) { + LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount, + " clamped to ", kMaxTransportSplinePoints); + pointCount = kMaxTransportSplinePoints; + } // Read destination point (transport-local server coords) float destLocalX = localX, destLocalY = localY, destLocalZ = localZ; From dd64724dbbc90655a80e40c963fd33036ac9ad1d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:20:03 -0700 Subject: [PATCH 62/64] fix: consume all SpellCastTargets bytes in WotLK SpellStartParser Replaced partial UNIT/OBJECT-only flag handling with full WotLK SpellCastTargets layout: UNIT/UNIT_MINIPET/CORPSE/GAMEOBJECT share one PackedGuid, ITEM/TRADE_ITEM share one PackedGuid, SOURCE_LOCATION and DEST_LOCATION are each PackedGuid+3floats (transport-relative), STRING is null-terminated. Prevents byte-stream corruption on ground-targeted AoE and similar multi-field target packets. --- src/game/world_packets.cpp | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5d322ff1..511f2544 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3780,14 +3780,44 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { return false; } + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // subsequent fields (e.g. school mask, cast flags 0x20 extra data) are not + // misaligned for ground-targeted or AoE spells. uint32_t targetFlags = packet.readUInt32(); - const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT - if (needsTargetGuid) { - if (!hasFullPackedGuid(packet)) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!hasFullPackedGuid(packet)) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!hasFullPackedGuid(packet)) return false; + UpdateObjectParser::readPackedGuid(packet); // transport GUID (may be zero) + if (packet.getSize() - packet.getReadPos() < 12) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share a single object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); // best-effort; ignore failure + } + // ITEM/TRADE_ITEM share a single item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {} } LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); From 6f936f258f478094dc83afca16f94d1e9bd34a6d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:26:05 -0700 Subject: [PATCH 63/64] fix: consume all SpellCastTargets bytes in WotLK SpellGoParser Applied the same SpellCastTargets fix from SpellStartParser (dd64724) to SpellGoParser: after parsing hit/miss target lists, now reads the full target section (UNIT/UNIT_MINIPET/CORPSE/GAMEOBJECT packed GUID, ITEM/TRADE_ITEM packed GUID, SOURCE/DEST PackedGuid+3floats, null- terminated STRING). Also adds targetGuid field to SpellGoData so callers can read the primary target. Prevents stream misalignment on ground-targeted AoE spells (e.g. Blizzard, Rain of Fire). --- include/game/world_packets.hpp | 1 + src/game/world_packets.cpp | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index a30194f4..c7fc0ef4 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1875,6 +1875,7 @@ struct SpellGoData { std::vector hitTargets; uint8_t missCount = 0; std::vector missTargets; + uint64_t targetGuid = 0; ///< Primary target GUID from SpellCastTargets (0 = none/AoE) bool isValid() const { return spellId != 0; } }; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 511f2544..aaf18ca2 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3931,6 +3931,50 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } data.missCount = static_cast(data.missTargets.size()); + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // any trailing fields after the target section are not misaligned for + // ground-targeted or AoE spells. Same layout as SpellStartParser. + if (packet.getReadPos() < packet.getSize()) { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t targetFlags = packet.readUInt32(); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!hasFullPackedGuid(packet)) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!hasFullPackedGuid(packet)) return false; + UpdateObjectParser::readPackedGuid(packet); // transport GUID + if (packet.getSize() - packet.getReadPos() < 12) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); + } + // ITEM/TRADE_ITEM share one item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {} + } + } + } + LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); return true; From 87cb293297e098e095fac95af5848757d4d2bd8e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:29:02 -0700 Subject: [PATCH 64/64] fix: consume SpellCastTargets bytes after miss list in Classic/TBC SpellGo Added skipClassicSpellCastTargets() and skipTbcSpellCastTargets() calls in parseSpellGo() for both expansions, matching the same fix applied to WotLK SpellGoParser and both SpellStartParsers. Prevents packet stream misalignment for ground-targeted and AoE spells (Blizzard, Rain of Fire, Flamestrike, etc.) where the server appends DEST_LOCATION or other target fields after the hit/miss lists. --- src/game/packet_parsers_classic.cpp | 4 ++++ src/game/packet_parsers_tbc.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index e04832f6..e0dd01f8 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -762,6 +762,10 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da return false; } + // SpellCastTargets follows the miss list — consume all target bytes so that + // any subsequent fields (e.g. castFlags extras) are not misaligned. + skipClassicSpellCastTargets(packet, &data.targetGuid); + LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); return true; diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 3b0a545c..8e8fbd25 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1423,6 +1423,10 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) } data.missCount = static_cast(data.missTargets.size()); + // SpellCastTargets follows the miss list — consume all target bytes so that + // any subsequent fields are not misaligned for ground-targeted AoE spells. + skipTbcSpellCastTargets(packet, &data.targetGuid); + LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); return true;