From 0a2cd213dcdaa8dbd3087033f86a59e0c443c3d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:15:08 -0700 Subject: [PATCH 01/82] feat: display gem socket slots and socket bonus in item tooltips --- include/game/world_packets.hpp | 3 +++ src/game/world_packets.cpp | 20 ++++++++++++++++++++ src/ui/inventory_screen.cpp | 29 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 6e5721fd..2cb47fe1 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1594,6 +1594,9 @@ struct ItemQueryResponseData { struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; std::vector extraStats; uint32_t startQuestId = 0; // Non-zero: item begins a quest + // Gem socket slots (WotLK/TBC): 0=no socket; color mask: 1=Meta,2=Red,4=Yellow,8=Blue + std::array socketColor{}; + uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none bool valid = false; }; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5a9a77ec..abfa5929 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2930,6 +2930,26 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa data.startQuestId = packet.readUInt32(); // StartQuest } + // WotLK 3.3.5a: additional fields after StartQuest (read up to socket data) + // LockID(4), Material(4), Sheath(4), RandomProperty(4), RandomSuffix(4), + // Block(4), ItemSet(4), MaxDurability(4), Area(4), Map(4), BagFamily(4), + // TotemCategory(4) = 48 bytes before sockets + constexpr size_t kPreSocketSkip = 48; + if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) { + for (size_t i = 0; i < kPreSocketSkip / 4; ++i) + packet.readUInt32(); + // 3 socket slots: socketColor (4 bytes each) + data.socketColor[0] = packet.readUInt32(); + data.socketColor[1] = packet.readUInt32(); + data.socketColor[2] = packet.readUInt32(); + // 3 socket content (gem enchantment IDs — skip, not currently displayed) + packet.readUInt32(); + packet.readUInt32(); + packet.readUInt32(); + // socketBonus (enchantmentId) + data.socketBonus = packet.readUInt32(); + } + data.valid = !data.name.empty(); return true; } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e2075f09..661d27fe 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2482,6 +2482,35 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } } + // Gem socket slots + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + bool hasSocket = false; + for (int i = 0; i < 3; ++i) { + if (info.socketColor[i] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info.socketColor[i] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info.socketBonus != 0 && gameHandler_) { + // Socket bonus is an enchantment ID — show its name if known + const std::string& bonusName = gameHandler_->getSpellName(info.socketBonus); + if (!bonusName.empty()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", bonusName.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus); + } + } + if (info.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); } From 60794c6e0fbaf9a3fdd5f1948efc0e521bc9964a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:24:15 -0700 Subject: [PATCH 02/82] feat: track and display elemental resistances in character stats panel --- include/game/game_handler.hpp | 8 ++++++++ include/ui/inventory_screen.hpp | 2 +- src/game/game_handler.cpp | 7 +++++++ src/ui/inventory_screen.cpp | 29 +++++++++++++++++++++++++++-- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7a11d97b..67a459da 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -295,6 +295,13 @@ public: // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } + // Server-authoritative elemental resistances (UNIT_FIELD_RESISTANCES[1-6]). + // school: 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane. Returns 0 if not received. + int32_t getResistance(int school) const { + if (school < 1 || school > 6) return 0; + return playerResistances_[school - 1]; + } + // Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI). // Returns -1 if the server hasn't sent the value yet. int32_t getPlayerStat(int idx) const { @@ -2436,6 +2443,7 @@ private: std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; int32_t playerArmorRating_ = 0; + int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet int32_t playerStats_[5] = {-1, -1, -1, -1, -1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 31cae856..7a40f43b 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -149,7 +149,7 @@ private: void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, - const int32_t* serverStats = nullptr); + const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr); void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index df1da0df..7cd7bc2b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6703,6 +6703,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; + std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); @@ -8807,6 +8808,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerArmorRating_ = static_cast(val); LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, @@ -9147,6 +9151,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 661d27fe..fc9bd564 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1159,7 +1159,9 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { int32_t stats[5]; for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); + int32_t resists[6]; + for (int i = 0; i < 6; ++i) resists[i] = gameHandler.getResistance(i + 1); + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists); // Played time (shown if available, fetched on character screen open) uint32_t totalSec = gameHandler.getTotalTimePlayed(); @@ -1557,7 +1559,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // ============================================================ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, - int32_t serverArmor, const int32_t* serverStats) { + int32_t serverArmor, const int32_t* serverStats, + const int32_t* serverResists) { // Sum equipment stats for item-query bonus display int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; // Secondary stat sums from extraStats @@ -1665,6 +1668,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play renderSecondary("Mana per 5 sec", itemMp5); renderSecondary("Health per 5 sec",itemHp5); } + + // Elemental resistances from server update fields + if (serverResists) { + static const char* kResistNames[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + bool hasResist = false; + for (int i = 0; i < 6; ++i) { + if (serverResists[i] > 0) { hasResist = true; break; } + } + if (hasResist) { + ImGui::Spacing(); + ImGui::Separator(); + for (int i = 0; i < 6; ++i) { + if (serverResists[i] > 0) { + ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 1.0f), + "%s: %d", kResistNames[i], serverResists[i]); + } + } + } + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { From d48e4fb7c322db1ff2911146ade1b7406a6d0b9f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:32:19 -0700 Subject: [PATCH 03/82] feat: resolve enchant names from SpellItemEnchantment.dbc in inspect window --- Data/expansions/tbc/dbc_layouts.json | 3 +++ Data/expansions/turtle/dbc_layouts.json | 3 +++ Data/expansions/wotlk/dbc_layouts.json | 3 +++ src/ui/game_screen.cpp | 36 ++++++++++++++++++++++--- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index b99159a6..9ada081b 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -97,5 +97,8 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, "Name": 8 } } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 3b06971d..beaa0c6c 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -95,5 +95,8 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, "Name": 8 } } diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 94f7df4d..3c4ec125 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -98,5 +98,8 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, "Name": 8 } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 33e78f25..0f4c0b96 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17996,6 +17996,28 @@ void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { if (!showInspectWindow_) return; + // Lazy-load SpellItemEnchantment.dbc for enchant name lookup + static std::unordered_map s_enchantNames; + static bool s_enchantDbLoaded = false; + auto* assetMgrEnchant = core::Application::getInstance().getAssetManager(); + if (!s_enchantDbLoaded && assetMgrEnchant && assetMgrEnchant->isInitialized()) { + s_enchantDbLoaded = true; + auto dbc = assetMgrEnchant->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") + : nullptr; + uint32_t idField = layout ? (*layout)["ID"] : 0; + uint32_t nameField = layout ? (*layout)["Name"] : 8; + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string nm = dbc->getString(i, nameField); + if (!nm.empty()) s_enchantNames[id] = std::move(nm); + } + } + } + // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) static const char* kSlotNames[19] = { "Head", "Neck", "Shoulder", "Shirt", "Chest", @@ -18122,10 +18144,18 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::TextColored(qColor, "%s", info->name.c_str()); // Enchant indicator on the same row as the name if (enchantId != 0) { + auto enchIt = s_enchantNames.find(enchantId); + const std::string& enchName = (enchIt != s_enchantNames.end()) + ? enchIt->second : std::string{}; ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); // UTF-8 ✦ - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + if (!enchName.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), + "\xe2\x9c\xa6 %s", enchName.c_str()); // UTF-8 ✦ + } else { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + } } ImGui::EndGroup(); hovered = hovered || ImGui::IsItemHovered(); From 48f12d9ca8ffe949e3d00ed0c51eaec9bfbc7c13 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:35:56 -0700 Subject: [PATCH 04/82] feat: parse item set ID and display set name in item tooltip via ItemSet.dbc --- Data/expansions/tbc/dbc_layouts.json | 10 +++++++++ Data/expansions/turtle/dbc_layouts.json | 10 +++++++++ Data/expansions/wotlk/dbc_layouts.json | 10 +++++++++ include/game/world_packets.hpp | 1 + src/game/world_packets.cpp | 7 ++++-- src/ui/inventory_screen.cpp | 30 +++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 2 deletions(-) diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 9ada081b..7929446f 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -100,5 +100,15 @@ }, "SpellItemEnchantment": { "ID": 0, "Name": 8 + }, + "ItemSet": { + "ID": 0, "Name": 1, + "Item0": 18, "Item1": 19, "Item2": 20, "Item3": 21, "Item4": 22, + "Item5": 23, "Item6": 24, "Item7": 25, "Item8": 26, "Item9": 27, + "Spell0": 28, "Spell1": 29, "Spell2": 30, "Spell3": 31, "Spell4": 32, + "Spell5": 33, "Spell6": 34, "Spell7": 35, "Spell8": 36, "Spell9": 37, + "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, + "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, + "Threshold8": 46, "Threshold9": 47 } } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index beaa0c6c..c5a3948e 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -98,5 +98,15 @@ }, "SpellItemEnchantment": { "ID": 0, "Name": 8 + }, + "ItemSet": { + "ID": 0, "Name": 1, + "Item0": 10, "Item1": 11, "Item2": 12, "Item3": 13, "Item4": 14, + "Item5": 15, "Item6": 16, "Item7": 17, "Item8": 18, "Item9": 19, + "Spell0": 20, "Spell1": 21, "Spell2": 22, "Spell3": 23, "Spell4": 24, + "Spell5": 25, "Spell6": 26, "Spell7": 27, "Spell8": 28, "Spell9": 29, + "Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33, + "Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37, + "Threshold8": 38, "Threshold9": 39 } } diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 3c4ec125..c8287a29 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -101,5 +101,15 @@ }, "SpellItemEnchantment": { "ID": 0, "Name": 8 + }, + "ItemSet": { + "ID": 0, "Name": 1, + "Item0": 18, "Item1": 19, "Item2": 20, "Item3": 21, "Item4": 22, + "Item5": 23, "Item6": 24, "Item7": 25, "Item8": 26, "Item9": 27, + "Spell0": 28, "Spell1": 29, "Spell2": 30, "Spell3": 31, "Spell4": 32, + "Spell5": 33, "Spell6": 34, "Spell7": 35, "Spell8": 36, "Spell9": 37, + "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, + "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, + "Threshold8": 46, "Threshold9": 47 } } diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 2cb47fe1..24d795f7 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1597,6 +1597,7 @@ struct ItemQueryResponseData { // Gem socket slots (WotLK/TBC): 0=no socket; color mask: 1=Meta,2=Red,4=Yellow,8=Blue std::array socketColor{}; uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none + uint32_t itemSetId = 0; // ItemSet.dbc entry; 0=not part of a set bool valid = false; }; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index abfa5929..dd7dc33f 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2936,8 +2936,11 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa // TotemCategory(4) = 48 bytes before sockets constexpr size_t kPreSocketSkip = 48; if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) { - for (size_t i = 0; i < kPreSocketSkip / 4; ++i) - packet.readUInt32(); + // LockID(0), Material(1), Sheath(2), RandomProperty(3), RandomSuffix(4), Block(5) + for (size_t i = 0; i < 6; ++i) packet.readUInt32(); + data.itemSetId = packet.readUInt32(); // ItemSet(6) + // MaxDurability(7), Area(8), Map(9), BagFamily(10), TotemCategory(11) + for (size_t i = 0; i < 5; ++i) packet.readUInt32(); // 3 socket slots: socketColor (4 bytes each) data.socketColor[0] = packet.readUInt32(); data.socketColor[1] = packet.readUInt32(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index fc9bd564..fbe5fbfc 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2536,6 +2536,36 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } } + // Item set membership + if (info.itemSetId != 0) { + // Lazy-load ItemSet.dbc name table + static std::unordered_map s_setNames; + static bool s_setNamesLoaded = false; + if (!s_setNamesLoaded && assetManager_) { + s_setNamesLoaded = true; + auto dbc = assetManager_->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 1; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + std::string nm = dbc->getString(r, nameF); + if (!nm.empty()) s_setNames[id] = std::move(nm); + } + } + } + auto setIt = s_setNames.find(info.itemSetId); + const char* setName = (setIt != s_setNames.end()) ? setIt->second.c_str() : nullptr; + ImGui::Spacing(); + if (setName) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", setName); + else + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info.itemSetId); + } + if (info.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); } From a56b50df2bba068e47cc7bf775557d0c46d6e15c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:41:05 -0700 Subject: [PATCH 05/82] feat: show average item level in character stats panel --- src/ui/inventory_screen.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index fbe5fbfc..7c705d9d 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1599,6 +1599,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor; + // Average item level (exclude shirt/tabard as WoW convention) + { + uint32_t iLvlSum = 0; + int iLvlCount = 0; + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + auto eslot = static_cast(s); + if (eslot == game::EquipSlot::SHIRT || eslot == game::EquipSlot::TABARD) continue; + const auto& slot = inventory.getEquipSlot(eslot); + if (!slot.empty() && slot.item.itemLevel > 0) { + iLvlSum += slot.item.itemLevel; + ++iLvlCount; + } + } + if (iLvlCount > 0) { + float avg = static_cast(iLvlSum) / static_cast(iLvlCount); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 1.0f, 1.0f), + "Average Item Level: %.1f (%d/%d slots)", avg, iLvlCount, + game::Inventory::NUM_EQUIP_SLOTS - 2); // -2 for shirt/tabard + } + ImGui::Separator(); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); From bc0d98adae7bdcc44e20dbea59bed01e8530d3cc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:43:53 -0700 Subject: [PATCH 06/82] feat: show item set piece count and active bonuses in item tooltip --- src/ui/inventory_screen.cpp | 94 +++++++++++++++++++++++++++++++------ 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 7c705d9d..519b2592 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2560,32 +2560,98 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, // Item set membership if (info.itemSetId != 0) { - // Lazy-load ItemSet.dbc name table - static std::unordered_map s_setNames; - static bool s_setNamesLoaded = false; - if (!s_setNamesLoaded && assetManager_) { - s_setNamesLoaded = true; + // Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds) + 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 && assetManager_) { + s_setDataLoaded = true; auto dbc = assetManager_->loadDBC("ItemSet.dbc"); if (dbc && dbc->isLoaded()) { const auto* layout = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0; - uint32_t nameF = layout ? (*layout)["Name"] : 1; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = { + "Item0","Item1","Item2","Item3","Item4", + "Item5","Item6","Item7","Item8","Item9" + }; + static const char* spellKeys[10] = { + "Spell0","Spell1","Spell2","Spell3","Spell4", + "Spell5","Spell6","Spell7","Spell8","Spell9" + }; + static const char* thrKeys[10] = { + "Threshold0","Threshold1","Threshold2","Threshold3","Threshold4", + "Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" + }; + uint32_t itemFallback[10], spellFallback[10], thrFallback[10]; + for (int i = 0; i < 10; ++i) { + itemFallback[i] = 18 + i; + spellFallback[i] = 28 + i; + thrFallback[i] = 38 + i; + } for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { uint32_t id = dbc->getUInt32(r, idF); if (!id) continue; - std::string nm = dbc->getString(r, nameF); - if (!nm.empty()) s_setNames[id] = std::move(nm); + 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]] : itemFallback[i]); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFallback[i]); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFallback[i]); + } + s_setData[id] = std::move(e); } } } - auto setIt = s_setNames.find(info.itemSetId); - const char* setName = (setIt != s_setNames.end()) ? setIt->second.c_str() : nullptr; + + auto setIt = s_setData.find(info.itemSetId); ImGui::Spacing(); - if (setName) - ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", setName); - else + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + // Count equipped pieces + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + if (inventory) { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& eSlot = inventory->getEquipSlot(static_cast(s)); + if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + } + if (total > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + } else { + if (!se.name.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + } + // Show set bonuses: gray if not reached, green if active + if (gameHandler_) { + 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); + } } if (info.startQuestId != 0) { From d44f5e6560eacbbfd10474c600b350a76c1b1289 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:49:38 -0700 Subject: [PATCH 07/82] feat: show achievement description and point value in tooltip Hovering an earned achievement now shows its point value (gold badge), description text from Achievement.dbc field 21, and the earn date. loadAchievementNameCache() also populates achievementDescCache_ and achievementPointsCache_ in a single DBC pass; Points field (39) added to the WotLK Achievement DBC layout. --- Data/expansions/wotlk/dbc_layouts.json | 2 +- include/game/game_handler.hpp | 16 +++++++++++++++- src/game/game_handler.cpp | 11 +++++++++++ src/ui/game_screen.cpp | 19 +++++++++++++++++-- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index c8287a29..aefc2216 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -31,7 +31,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, + "Achievement": { "ID": 0, "Title": 4, "Description": 21, "Points": 39 }, "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 67a459da..efc8553d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1429,6 +1429,18 @@ public: static const std::string kEmpty; return kEmpty; } + /// Returns the description of an achievement by ID, or empty string if unknown. + const std::string& getAchievementDescription(uint32_t id) const { + auto it = achievementDescCache_.find(id); + if (it != achievementDescCache_.end()) return it->second; + static const std::string kEmpty; + return kEmpty; + } + /// Returns the point value of an achievement by ID, or 0 if unknown. + uint32_t getAchievementPoints(uint32_t id) const { + auto it = achievementPointsCache_.find(id); + return (it != achievementPointsCache_.end()) ? it->second : 0u; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -2590,8 +2602,10 @@ private: std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; - // Achievement name cache (lazy-loaded from Achievement.dbc on first earned event) + // Achievement caches (lazy-loaded from Achievement.dbc on first earned event) std::unordered_map achievementNameCache_; + std::unordered_map achievementDescCache_; + std::unordered_map achievementPointsCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7cd7bc2b..7576561e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20303,12 +20303,23 @@ void GameHandler::loadAchievementNameCache() { ? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr; uint32_t titleField = achL ? achL->field("Title") : 4; if (titleField == 0xFFFFFFFF) titleField = 4; + uint32_t descField = achL ? achL->field("Description") : 0xFFFFFFFF; + uint32_t ptsField = achL ? achL->field("Points") : 0xFFFFFFFF; + uint32_t fieldCount = dbc->getFieldCount(); for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, 0); if (id == 0) continue; std::string title = dbc->getString(i, titleField); if (!title.empty()) achievementNameCache_[id] = std::move(title); + if (descField != 0xFFFFFFFF && descField < fieldCount) { + std::string desc = dbc->getString(i, descField); + if (!desc.empty()) achievementDescCache_[id] = std::move(desc); + } + if (ptsField != 0xFFFFFFFF && ptsField < fieldCount) { + uint32_t pts = dbc->getUInt32(i, ptsField); + if (pts > 0) achievementPointsCache_[id] = pts; + } } LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc"); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0f4c0b96..30bd6789 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17559,7 +17559,22 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { ImGui::TextUnformatted(display.c_str()); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); - ImGui::Text("Achievement ID: %u", id); + // Points badge + uint32_t pts = gameHandler.getAchievementPoints(id); + if (pts > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), + "%u Achievement Point%s", pts, pts == 1 ? "" : "s"); + ImGui::Separator(); + } + // Description + const std::string& desc = gameHandler.getAchievementDescription(id); + if (!desc.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); + ImGui::TextUnformatted(desc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + // Earn date uint32_t packed = gameHandler.getAchievementDate(id); if (packed != 0) { // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] @@ -17573,7 +17588,7 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { "Jul","Aug","Sep","Oct","Nov","Dec" }; const char* mname = (month >= 1 && month <= 12) ? kMonths[month - 1] : "?"; - ImGui::Text("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); + ImGui::TextDisabled("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); } ImGui::EndTooltip(); } From 8921c2ddf44a7daea1fc35c0a0d78281f8c54c0d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:52:08 -0700 Subject: [PATCH 08/82] feat: show criteria description and progress in achievement window The Criteria tab now loads AchievementCriteria.dbc to display each criterion's description text, parent achievement name, and a current/required progress counter (e.g. "25/100") instead of the raw numeric IDs. The search filter now also matches by achievement name. AchievementCriteria DBC layout added to wotlk/dbc_layouts.json. --- Data/expansions/wotlk/dbc_layouts.json | 1 + src/ui/game_screen.cpp | 92 +++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index aefc2216..70c80d61 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -32,6 +32,7 @@ "ReputationBase2": 12, "ReputationBase3": 13 }, "Achievement": { "ID": 0, "Title": 4, "Description": 21, "Points": 39 }, + "AchievementCriteria": { "ID": 0, "AchievementID": 1, "Quantity": 4, "Description": 9 }, "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 30bd6789..93038426 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17603,24 +17603,98 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { char critLabel[32]; snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", (unsigned)criteria.size()); if (ImGui::BeginTabItem(critLabel)) { + // Lazy-load AchievementCriteria.dbc for descriptions + struct CriteriaEntry { uint32_t achievementId; uint64_t quantity; std::string description; }; + static std::unordered_map s_criteriaData; + static bool s_criteriaDataLoaded = false; + if (!s_criteriaDataLoaded) { + s_criteriaDataLoaded = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (am && am->isInitialized()) { + auto dbc = am->loadDBC("AchievementCriteria.dbc"); + if (dbc && dbc->isLoaded() && dbc->getFieldCount() >= 10) { + const auto* acL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("AchievementCriteria") : nullptr; + uint32_t achField = acL ? acL->field("AchievementID") : 1u; + uint32_t qtyField = acL ? acL->field("Quantity") : 4u; + uint32_t descField = acL ? acL->field("Description") : 9u; + if (achField == 0xFFFFFFFF) achField = 1; + if (qtyField == 0xFFFFFFFF) qtyField = 4; + if (descField == 0xFFFFFFFF) descField = 9; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t cid = dbc->getUInt32(r, 0); + if (cid == 0) continue; + CriteriaEntry ce; + ce.achievementId = (achField < fc) ? dbc->getUInt32(r, achField) : 0; + ce.quantity = (qtyField < fc) ? dbc->getUInt32(r, qtyField) : 0; + ce.description = (descField < fc) ? dbc->getString(r, descField) : std::string{}; + s_criteriaData[cid] = std::move(ce); + } + } + } + } + if (criteria.empty()) { ImGui::TextDisabled("No criteria progress received yet."); } else { ImGui::BeginChild("##critlist", ImVec2(0, 0), false); - // Sort criteria by id for stable display std::vector> clist(criteria.begin(), criteria.end()); std::sort(clist.begin(), clist.end()); for (const auto& [cid, cval] : clist) { - std::string label = std::to_string(cid); - if (!filter.empty()) { - std::string lower = label; - for (char& c : lower) c = static_cast(tolower(static_cast(c))); - if (lower.find(filter) == std::string::npos) continue; + auto ceIt = s_criteriaData.find(cid); + + // Build display text for filtering + std::string display; + if (ceIt != s_criteriaData.end() && !ceIt->second.description.empty()) { + display = ceIt->second.description; + } else { + display = std::to_string(cid); } + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + // Also allow filtering by achievement name + if (lower.find(filter) == std::string::npos && ceIt != s_criteriaData.end()) { + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + std::string achLower = achName; + for (char& c : achLower) c = static_cast(tolower(static_cast(c))); + if (achLower.find(filter) == std::string::npos) continue; + } else if (lower.find(filter) == std::string::npos) { + continue; + } + } + ImGui::PushID(static_cast(cid)); - ImGui::TextDisabled("Criteria %u:", cid); - ImGui::SameLine(); - ImGui::Text("%llu", static_cast(cval)); + if (ceIt != s_criteriaData.end()) { + // Show achievement name as header (dim) + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + if (!achName.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.8f), "%s", achName.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + } + if (!ceIt->second.description.empty()) { + ImGui::TextUnformatted(ceIt->second.description.c_str()); + } else { + ImGui::TextDisabled("Criteria %u", cid); + } + ImGui::SameLine(); + if (ceIt->second.quantity > 0) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), + "%llu/%llu", + static_cast(cval), + static_cast(ceIt->second.quantity)); + } else { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), + "%llu", static_cast(cval)); + } + } else { + ImGui::TextDisabled("Criteria %u:", cid); + ImGui::SameLine(); + ImGui::Text("%llu", static_cast(cval)); + } ImGui::PopID(); } ImGui::EndChild(); From ed2b50af265293348667fade9001fb352048e934 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:57:15 -0700 Subject: [PATCH 09/82] fix: look up socket bonus name from SpellItemEnchantment.dbc socketBonus is a SpellItemEnchantment entry ID, not a spell ID. Previously getSpellName() was called on it, which produced wrong or empty results. Now a lazy SpellItemEnchantment.dbc cache in the item tooltip correctly resolves names like "+6 All Stats". --- src/ui/inventory_screen.cpp | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 519b2592..bc19f557 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2548,11 +2548,30 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } } } - if (hasSocket && info.socketBonus != 0 && gameHandler_) { - // Socket bonus is an enchantment ID — show its name if known - const std::string& bonusName = gameHandler_->getSpellName(info.socketBonus); - if (!bonusName.empty()) - ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", bonusName.c_str()); + if (hasSocket && info.socketBonus != 0) { + // Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetManager_) { + s_enchantNamesLoaded = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_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); } From a10139284d31eb8d1a9219178279648746623ffd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:08:41 -0700 Subject: [PATCH 10/82] feat: show spell tooltip text instead of name in item spell effects Item "Equip:" and "Use:" spell effects now display the spell's description text from Spell.dbc (e.g. "Increases your Spell Power by 30.") rather than the internal spell name (e.g. "Mana Spring Totem"). Falls back to the name when description is unavailable (e.g. older DBCs). Adds getSpellDescription() to GameHandler, backed by the existing loadSpellNameCache() pass which now reads the Tooltip field. --- include/game/game_handler.hpp | 4 +++- src/game/game_handler.cpp | 18 +++++++++++++++++- src/ui/inventory_screen.cpp | 21 +++++++++++++++------ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index efc8553d..a2cf3a6b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1679,6 +1679,8 @@ public: void closeTrainer(); const std::string& getSpellName(uint32_t spellId) const; const std::string& getSpellRank(uint32_t spellId) const; + /// Returns the tooltip/description text from Spell.dbc (empty if unknown or has no text). + const std::string& getSpellDescription(uint32_t spellId) const; 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; @@ -2598,7 +2600,7 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; 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; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7576561e..3439ffc1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17161,6 +17161,13 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } } + // Tooltip/description field + uint32_t tooltipField = 0xFFFFFFFF; + if (spellL) { + uint32_t f = spellL->field("Tooltip"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f; + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -17168,7 +17175,10 @@ 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}; + if (tooltipField != 0xFFFFFFFF) { + entry.description = dbc->getString(i, tooltipField); + } if (hasSchoolMask) { entry.schoolMask = dbc->getUInt32(i, schoolMaskField); } else if (hasSchoolEnum) { @@ -17373,6 +17383,12 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; } +const std::string& GameHandler::getSpellDescription(uint32_t spellId) const { + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.description : EMPTY_STRING; +} + uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { const_cast(this)->loadSpellNameCache(); auto it = spellNameCache_.find(spellId); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index bc19f557..acce3a27 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2233,10 +2233,13 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I default: break; } if (!trigger) continue; - const std::string& spName = gameHandler_->getSpellName(sp.spellId); - if (!spName.empty()) { + const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId); + const std::string& spText = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc; + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), - "%s: %s", trigger, spName.c_str()); + "%s: %s", trigger, spText.c_str()); + ImGui::PopTextWrapPos(); } else { ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); @@ -2521,11 +2524,17 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } if (!trigger) continue; if (gameHandler_) { - const std::string& spName = gameHandler_->getSpellName(sp.spellId); - if (!spName.empty()) + // Prefer the spell's tooltip text (the actual effect description). + // Fall back to the spell name if the description is empty. + const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId); + const std::string& spName = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc; + if (!spName.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str()); - else + ImGui::PopTextWrapPos(); + } else { ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); + } } } From 3ea1b96681e51eabf0f05e5578bf2299850390d5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:09:40 -0700 Subject: [PATCH 11/82] feat: show spell description in trainer window tooltip Trainer spell tooltips now show the spell's effect description from Spell.dbc (e.g. "Sends a shadowy bolt at the enemy...") above the status/requirement lines, matching the WoW trainer UI style. Also styles the spell name yellow (like WoW) and moves status to TextDisabled for better visual hierarchy. --- src/ui/game_screen.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 93038426..1a5c9c43 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12012,10 +12012,18 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); if (!name.empty()) { - ImGui::Text("%s", name.c_str()); - if (!rank.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", rank.c_str()); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", name.c_str()); + if (!rank.empty()) ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "%s", rank.c_str()); } - ImGui::Text("Status: %s", statusLabel); + const std::string& spDesc = gameHandler.getSpellDescription(spell->spellId); + if (!spDesc.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextWrapped("%s", spDesc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + ImGui::TextDisabled("Status: %s", statusLabel); if (spell->reqLevel > 0) { ImVec4 lvlColor = levelMet ? ImVec4(0.7f, 0.7f, 0.7f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); ImGui::TextColored(lvlColor, "Required Level: %u", spell->reqLevel); From d7c4bdcd57e3e88baa9172aba1f68267d254cd22 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:12:24 -0700 Subject: [PATCH 12/82] feat: add item tooltips to quest reward items in quest log --- src/ui/quest_log_screen.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index c343baa5..6e1e1a14 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -534,6 +534,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv ImGui::Text("%s x%u", name.c_str(), ri.count); else ImGui::Text("%s", name.c_str()); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } } @@ -560,6 +565,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv ImGui::Text("%s x%u", name.c_str(), ri.count); else ImGui::Text("%s", name.c_str()); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } } } From 0ffcf001a53dafae01506295d7124f5af31203b4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:14:24 -0700 Subject: [PATCH 13/82] feat: show full item tooltip on action bar item hover --- src/ui/game_screen.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1a5c9c43..08e009ec 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6103,12 +6103,17 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { ImGui::BeginTooltip(); - if (barItemDef && !barItemDef->name.empty()) + // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) + const auto* itemQueryInfo = gameHandler.getItemInfo(slot.id); + if (itemQueryInfo && itemQueryInfo->valid) { + inventoryScreen.renderItemTooltip(*itemQueryInfo); + } else if (barItemDef && !barItemDef->name.empty()) { ImGui::Text("%s", barItemDef->name.c_str()); - else if (!itemNameFromQuery.empty()) + } else if (!itemNameFromQuery.empty()) { ImGui::Text("%s", itemNameFromQuery.c_str()); - else + } else { ImGui::Text("Item #%u", slot.id); + } if (onCooldown) { float cd = slot.cooldownRemaining; if (cd >= 60.0f) From fe4fc714c3286d37b874cbcd58b5825d3f1e55bb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:19:10 -0700 Subject: [PATCH 14/82] feat: add item tooltips to quest objective item tracking --- src/ui/quest_log_screen.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 6e1e1a14..92b52bd9 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -485,12 +485,28 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv auto reqIt = sel.requiredItemCounts.find(itemId); if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + const auto* objInfo = gameHandler.getItemInfo(itemId); if (iconTex) { ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14)); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } ImGui::SameLine(); ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } } else { ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } } } } From 6ffc0cec3d44455ca428ea4055d9636d79c8a05f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:21:00 -0700 Subject: [PATCH 15/82] feat: show spell tooltip on hover in combat log --- src/ui/game_screen.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 08e009ec..5ce867e1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17511,6 +17511,16 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { ImGui::TextDisabled("%s", timeBuf); ImGui::TableSetColumnIndex(1); ImGui::TextColored(color, "%s", desc); + // Hover tooltip: show rich spell info for entries with a known spell + if (e.spellId != 0 && ImGui::IsItemHovered()) { + auto* assetMgrLog = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog); + if (!richOk) { + ImGui::Text("%s", spellName.c_str()); + } + ImGui::EndTooltip(); + } } // Auto-scroll to bottom From 2268f7ac348e2f68dec6225a70debc1d0312c16c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:22:20 -0700 Subject: [PATCH 16/82] feat: add item tooltips to quest tracker overlay item objectives --- src/ui/game_screen.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5ce867e1..7028bb7e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7197,12 +7197,27 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; if (iconTex) { ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } ImGui::SameLine(0, 3); ImGui::TextColored(objColor, "%s: %u/%u", itemName ? itemName : "Item", count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } else if (itemName) { ImGui::TextColored(objColor, " %s: %u/%u", itemName, count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } else { ImGui::TextColored(objColor, " Item: %u/%u", count, required); From d46feee4fc1cf1e51a4221e00b60182daae3c7bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:23:21 -0700 Subject: [PATCH 17/82] feat: show debuff type and spell names on party frame debuff dot hover --- src/ui/game_screen.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7028bb7e..60215eb7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8463,6 +8463,20 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); ImGui::Button("##d", ImVec2(8.0f, 8.0f)); ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) { + static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + // Find spell name(s) of this dispel type + ImGui::BeginTooltip(); + ImGui::TextColored(dotCol, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } ImGui::SameLine(); } ImGui::NewLine(); From 9336b2943c3789c76e64eb8d160bfca9eb4588f1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:28:49 -0700 Subject: [PATCH 18/82] feat: add debuff dots to raid frame and NPC name tooltips on minimap quest markers - Raid frame now shows dispellable debuff dots (magic/curse/disease/poison) in the bottom of each cell, matching the existing party frame behavior; hovering a dot shows the debuff type and spell names for that dispel type - Minimap quest giver dots (! and ?) now show a tooltip with the NPC name and whether the NPC has a new quest or a quest ready to turn in --- src/ui/game_screen.cpp | 73 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 60215eb7..5b43d7cc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8202,6 +8202,60 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); } + // Dispellable debuff dots at the bottom of the raid cell + // Mirrors party frame debuff indicators for healers in 25/40-man raids + if (!isDead && !isGhost) { + const std::vector* unitAuras = nullptr; + if (m.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (m.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(m.guid); + + if (unitAuras) { + bool shown[5] = {}; + float dotX = cellMin.x + 4.0f; + const float dotY = cellMax.y - 5.0f; + const float DOT_R = 3.5f; + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // debuffs only + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dc; + switch (dt) { + case 1: dc = ImVec4(0.25f, 0.50f, 1.00f, 0.90f); break; // Magic: blue + case 2: dc = ImVec4(0.70f, 0.15f, 0.90f, 0.90f); break; // Curse: purple + case 3: dc = ImVec4(0.65f, 0.45f, 0.10f, 0.90f); break; // Disease: brown + case 4: dc = ImVec4(0.10f, 0.75f, 0.10f, 0.90f); break; // Poison: green + default: continue; + } + ImU32 dotColU = ImGui::ColorConvertFloat4ToU32(dc); + draw->AddCircleFilled(ImVec2(dotX, dotY), DOT_R, dotColU); + draw->AddCircle(ImVec2(dotX, dotY), DOT_R + 0.5f, IM_COL32(0, 0, 0, 160), 8, 1.0f); + + float mdx = mouse.x - dotX, mdy = mouse.y - dotY; + if (mdx * mdx + mdy * mdy < (DOT_R + 4.0f) * (DOT_R + 4.0f)) { + static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + ImGui::BeginTooltip(); + ImGui::TextColored(dc, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } + dotX += 9.0f; + } + } + } + // Clickable invisible region over the whole cell ImGui::SetCursorScreenPos(cellMin); ImGui::PushID(static_cast(m.guid)); @@ -14091,6 +14145,25 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, 11.0f, ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f), IM_COL32(0, 0, 0, 255), marker); + + // Show NPC name and quest status on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + std::string npcName; + if (entity->getType() == game::ObjectType::UNIT) { + auto npcUnit = std::static_pointer_cast(entity); + npcName = npcUnit->getName(); + } + if (!npcName.empty()) { + bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE || + status == game::QuestGiverStatus::AVAILABLE_LOW); + ImGui::SetTooltip("%s\n%s", npcName.c_str(), + hasQuest ? "Has a quest for you" : "Quest ready to turn in"); + } + } + } } // Quest kill objective markers — highlight live NPCs matching active quest kill objectives From c76ac579cbeb4c1515ee8b0c91c60b03c8015dbc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:32:10 -0700 Subject: [PATCH 19/82] feat: add aura icons to focus frame with rich tooltips and duration overlays The focus frame now shows buff/debuff icons matching the target frame: debuffs first with dispel-type border colors, buffs after with green borders, duration countdowns on each icon, and rich spell info tooltips on hover. Uses getUnitAuras() falling back to getTargetAuras() when focus happens to also be the current target. --- src/ui/game_screen.cpp | 124 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5b43d7cc..365e781e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3915,6 +3915,130 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { } } + // Focus auras — buffs first, then debuffs, up to 8 icons wide + { + const std::vector* focusAuras = + (focus->getGuid() == gameHandler.getTargetGuid()) + ? &gameHandler.getTargetAuras() + : gameHandler.getUnitAuras(focus->getGuid()); + + if (focusAuras) { + int activeCount = 0; + for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++; + if (activeCount > 0) { + auto* focusAsset = core::Application::getInstance().getAssetManager(); + constexpr float FA_ICON = 20.0f; + constexpr int FA_PER_ROW = 10; + + ImGui::Separator(); + + uint64_t faNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: debuffs first (so hostile-caster info is prominent), then buffs + std::vector faIdx; + faIdx.reserve(focusAuras->size()); + for (size_t i = 0; i < focusAuras->size(); ++i) + if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i); + std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*focusAuras)[a].flags & 0x80) != 0; + bool bD = ((*focusAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; // debuffs first + int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs); + int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int faShown = 0; + for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) { + const auto& aura = (*focusAuras)[faIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(faIdx[si]) + 3000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet faIcon = (focusAsset) + ? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE; + if (faIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##faura", + (ImTextureID)(uintptr_t)faIcon, + ImVec2(FA_ICON - 2, FA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId); + ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t faRemain = aura.getRemainingMs(faNowMs); + if (faRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (faRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y - 1.0f; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, focusAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (faRemain > 0) { + int s = faRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + faShown++; + } + ImGui::PopStyleVar(); + } + } + } + // Clicking the focus frame targets it if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { gameHandler.setTarget(focus->getGuid()); From 8d7391d73e82e6339b98aea2be7e2260e7b5c5a8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:33:48 -0700 Subject: [PATCH 20/82] feat: upgrade pet action bar to rich spell tooltips with autocast status Pet ability buttons now show full spell info (name, description, range, cost, cooldown) instead of just the spell name. Built-in commands (Follow, Stay, Attack, etc.) keep their existing simple labels. Autocast-enabled spells show "Autocast: On" at the bottom of the tooltip. --- src/ui/game_screen.cpp | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 365e781e..f3efeed3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3094,23 +3094,30 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { gameHandler.sendPetAction(slotVal, targetGuid); } - // Tooltip: show spell name or built-in command name. + // Tooltip: rich spell info for pet spells, simple label for built-in commands if (ImGui::IsItemHovered()) { - const char* tip = nullptr; if (builtinLabel) { + const char* tip = nullptr; if (actionId == 1) tip = "Passive"; else if (actionId == 2) tip = "Follow"; else if (actionId == 3) tip = "Stay"; else if (actionId == 4) tip = "Defensive"; else if (actionId == 5) tip = "Attack"; else if (actionId == 6) tip = "Aggressive"; + if (tip) ImGui::SetTooltip("%s", tip); + } else if (actionId > 6) { + auto* spellAsset = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset); + if (!richOk) { + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) nm = "Spell #" + std::to_string(actionId); + ImGui::Text("%s", nm.c_str()); + } + if (autocastOn) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); + ImGui::EndTooltip(); } - std::string spellNm; - if (!tip && actionId > 6) { - spellNm = gameHandler.getSpellName(actionId); - if (!spellNm.empty()) tip = spellNm.c_str(); - } - if (tip) ImGui::SetTooltip("%s", tip); } ImGui::PopID(); From abfb6ecdb5bee51cedbe3437372aed731bfefb66 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:36:06 -0700 Subject: [PATCH 21/82] feat: add spell name tooltips to nameplate debuff dots on hover When hovering over a player-applied DoT/debuff indicator square on an enemy nameplate, the spell name is now shown as a tooltip. Uses direct mouse-position hit test since nameplates render into the background draw list rather than an ImGui window. --- src/ui/game_screen.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f3efeed3..423d374a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8005,6 +8005,18 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), IM_COL32(0, 0, 0, A(150)), 1.0f); + + // Spell name tooltip on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= dotX && mouse.x < dotX + dotSize && + mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { + const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); + if (!dotSpellName.empty()) + ImGui::SetTooltip("%s", dotSpellName.c_str()); + } + } + dotX += dotSize + dotGap; if (dotX + dotSize > barX + barW) break; } From 366572362223cfe61a0c516bf1b030de94aec314 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:39:36 -0700 Subject: [PATCH 22/82] feat: add aura icons to target-of-target frame with debuff coloring and tooltips --- src/ui/game_screen.cpp | 126 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 423d374a..d062be49 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3731,6 +3731,132 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::ProgressBar(pct, ImVec2(-1, 10), ""); ImGui::PopStyleColor(); } + + // ToT aura row — compact icons, debuffs first + { + const std::vector* totAuras = nullptr; + if (totGuid == gameHandler.getPlayerGuid()) + totAuras = &gameHandler.getPlayerAuras(); + else if (totGuid == gameHandler.getTargetGuid()) + totAuras = &gameHandler.getTargetAuras(); + else + totAuras = gameHandler.getUnitAuras(totGuid); + + if (totAuras) { + int totActive = 0; + for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++; + if (totActive > 0) { + auto* totAsset = core::Application::getInstance().getAssetManager(); + constexpr float TA_ICON = 16.0f; + constexpr int TA_PER_ROW = 8; + + ImGui::Separator(); + + uint64_t taNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + std::vector taIdx; + taIdx.reserve(totAuras->size()); + for (size_t i = 0; i < totAuras->size(); ++i) + if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i); + std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*totAuras)[a].flags & 0x80) != 0; + bool bD = ((*totAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; + int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs); + int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int taShown = 0; + for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) { + const auto& aura = (*totAuras)[taIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(taIdx[si]) + 5000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet taIcon = (totAsset) + ? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE; + if (taIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##taura", + (ImTextureID)(uintptr_t)taIcon, + ImVec2(TA_ICON - 2, TA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t taRemain = aura.getRemainingMs(taNowMs); + if (taRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (taRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, totAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (taRemain > 0) { + int s = taRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + taShown++; + } + ImGui::PopStyleVar(); + } + } + } } } ImGui::End(); From 3d1b18798631859b45f3c8cb30f7d616d4af82d0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:43:12 -0700 Subject: [PATCH 23/82] feat: add aura icons to boss frame with DoT tracking and duration overlays --- src/ui/game_screen.cpp | 135 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d062be49..a05bd8ed 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9339,6 +9339,141 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Boss aura row: debuffs first (player DoTs), then boss buffs + { + const std::vector* bossAuras = nullptr; + if (bs.guid == gameHandler.getTargetGuid()) + bossAuras = &gameHandler.getTargetAuras(); + else + bossAuras = gameHandler.getUnitAuras(bs.guid); + + if (bossAuras) { + int bossActive = 0; + for (const auto& a : *bossAuras) if (!a.isEmpty()) bossActive++; + if (bossActive > 0) { + constexpr float BA_ICON = 16.0f; + constexpr int BA_PER_ROW = 10; + + uint64_t baNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: player-applied debuffs first (most relevant), then others + const uint64_t pguid = gameHandler.getPlayerGuid(); + std::vector baIdx; + baIdx.reserve(bossAuras->size()); + for (size_t i = 0; i < bossAuras->size(); ++i) + if (!(*bossAuras)[i].isEmpty()) baIdx.push_back(i); + std::sort(baIdx.begin(), baIdx.end(), [&](size_t a, size_t b) { + const auto& aa = (*bossAuras)[a]; + const auto& ab = (*bossAuras)[b]; + bool aPlayerDot = (aa.flags & 0x80) != 0 && aa.casterGuid == pguid; + bool bPlayerDot = (ab.flags & 0x80) != 0 && ab.casterGuid == pguid; + if (aPlayerDot != bPlayerDot) return aPlayerDot > bPlayerDot; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; + int32_t ra = aa.getRemainingMs(baNowMs); + int32_t rb = ab.getRemainingMs(baNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int baShown = 0; + for (size_t si = 0; si < baIdx.size() && baShown < 20; ++si) { + const auto& aura = (*bossAuras)[baIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + bool isPlayerCast = (aura.casterGuid == pguid); + + if (baShown > 0 && baShown % BA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(baIdx[si]) + 7000); + + ImVec4 borderCol; + if (isBuff) { + // Boss buffs: gold for important enrage/shield types + borderCol = ImVec4(0.8f, 0.6f, 0.1f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = isPlayerCast + ? ImVec4(0.90f, 0.30f, 0.10f, 0.9f) // player DoT: orange-red + : ImVec4(0.60f, 0.20f, 0.20f, 0.9f); // other debuff: dark red + break; + } + } + + VkDescriptorSet baIcon = assetMgr + ? getSpellIcon(aura.spellId, assetMgr) : VK_NULL_HANDLE; + if (baIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##baura", + (ImTextureID)(uintptr_t)baIcon, + ImVec2(BA_ICON - 2, BA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(BA_ICON, BA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t baRemain = aura.getRemainingMs(baNowMs); + if (baRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (baRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (isPlayerCast && !isBuff) + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "Your DoT"); + if (baRemain > 0) { + int s = baRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + baShown++; + } + ImGui::PopStyleVar(); + } + } + } + ImGui::PopID(); ImGui::Spacing(); } From 1e76df7c9836e1148e59052b4f5a7c17bbb2b83e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:47:02 -0700 Subject: [PATCH 24/82] feat: show raid mark symbols on party frame member names --- src/ui/game_screen.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a05bd8ed..bbdf0185 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8651,6 +8651,27 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } } + // Raid mark symbol — shown on same line as name when this party member has a mark + { + static const struct { const char* sym; ImU32 col; } kPartyMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImGui::SameLine(); + ImGui::TextColored( + ImGui::ColorConvertU32ToFloat4(kPartyMarks[pmk].col), + "%s", kPartyMarks[pmk].sym); + } + } + // Health bar: prefer party stats, fall back to entity uint32_t hp = 0, maxHp = 0; if (member.hasPartyStats && member.maxHealth > 0) { From e6f48dd8227b89274c657771bba8a8f4e095775a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:48:01 -0700 Subject: [PATCH 25/82] feat: show raid mark symbols on minimap party member dots with name tooltip --- src/ui/game_screen.cpp | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bbdf0185..448b2c27 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14729,10 +14729,41 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); + // Raid mark: tiny symbol drawn above the dot + { + static const struct { const char* sym; ImU32 col; } kMMMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImFont* mmFont = ImGui::GetFont(); + ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym); + drawList->AddText(mmFont, 9.0f, + ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y), + kMMMarks[pmk].col, kMMMarks[pmk].sym); + } + } + ImVec2 cursorPos = ImGui::GetMousePos(); float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { - ImGui::SetTooltip("%s", member.name.c_str()); + uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid); + if (pmk2 < game::GameHandler::kRaidMarkCount) { + static const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", + "Moon", "Square", "Cross", "Skull" + }; + ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]); + } else { + ImGui::SetTooltip("%s", member.name.c_str()); + } } } } From f39ba56390759d49ff2343fd9b3c6a5053f63e75 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:50:46 -0700 Subject: [PATCH 26/82] feat: show raid mark symbols on raid frame cells beside leader crown --- src/ui/game_screen.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 448b2c27..54803971 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8414,6 +8414,29 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (isMemberLeader) draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); + // Raid mark symbol — small, just to the left of the leader crown + { + static const struct { const char* sym; ImU32 col; } kCellMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t rmk = gameHandler.getEntityRaidMark(m.guid); + if (rmk < game::GameHandler::kRaidMarkCount) { + ImFont* rmFont = ImGui::GetFont(); + ImVec2 rmsz = rmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kCellMarks[rmk].sym); + float rmX = cellMax.x - 10.0f - 2.0f - rmsz.x; + draw->AddText(rmFont, 9.0f, + ImVec2(rmX, cellMin.y + 2.0f), + kCellMarks[rmk].col, kCellMarks[rmk].sym); + } + } + // LFG role badge in bottom-right corner of cell if (m.roles & 0x02) draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); From e2b242523090a9270e4c20ccf7da8c98cdf58fa7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 13:58:30 -0700 Subject: [PATCH 27/82] feat: add cast bar to target-of-target frame with pulsing interrupt warning --- 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 54803971..66206cc4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3732,6 +3732,28 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // ToT cast bar — orange-yellow, pulses when 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); + } else { + tcColor = ImVec4(0.8f, 0.5f, 0.1f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); + char tcLabel[48]; + const std::string& tcName = gameHandler.getSpellName(totCs->spellId); + if (!tcName.empty()) + snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining); + else + snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining); + ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel); + ImGui::PopStyleColor(); + } + // ToT aura row — compact icons, debuffs first { const std::vector* totAuras = nullptr; From fb843026adf2a1c017a273a9a8450cdd3f6c5364 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:00:14 -0700 Subject: [PATCH 28/82] feat: add LFG queue time indicator below minimap with role-check pulsing --- src/ui/game_screen.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 66206cc4..92c8eecb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15290,6 +15290,31 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { break; // Show at most one queue slot indicator } + // LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck) + { + using LfgState = game::GameHandler::LfgState; + LfgState lfgSt = gameHandler.getLfgState(); + if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) { + if (lfgSt == LfgState::RoleCheck) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check..."); + } else { + uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); + int qMin = static_cast(qMs / 60000); + int qSec = static_cast((qMs % 60000) / 1000); + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.2f); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse), + "LFG: %d:%02d", qMin, qSec); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + // Latency indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { From a03ee33f8c79d51b02cf5425d6f90b5ba2d2cc76 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:09:01 -0700 Subject: [PATCH 29/82] feat: add power bar to boss frames for energy/mana tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Energy bosses (e.g. Anub'arak, various WotLK encounters) use energy as their ability cooldown mechanic — tracking it in the boss frame lets raiders anticipate major ability casts. Mana, rage, focus, and energy all shown with type-appropriate colors as a slim 6px bar below HP. --- src/ui/game_screen.cpp | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 92c8eecb..a2c36436 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9338,17 +9338,22 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { for (const auto& bs : active) { ImGui::PushID(static_cast(bs.guid)); - // Try to resolve name and health from entity manager + // Try to resolve name, health, and power from entity manager std::string name = "Boss"; uint32_t hp = 0, maxHp = 0; + uint8_t bossPowerType = 0; + uint32_t bossPower = 0, bossMaxPower = 0; auto entity = gameHandler.getEntityManager().getEntity(bs.guid); if (entity && (entity->getType() == game::ObjectType::UNIT || entity->getType() == game::ObjectType::PLAYER)) { auto unit = std::static_pointer_cast(entity); const auto& n = unit->getName(); if (!n.empty()) name = n; - hp = unit->getHealth(); - maxHp = unit->getMaxHealth(); + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + bossPowerType = unit->getPowerType(); + bossPower = unit->getPower(); + bossMaxPower = unit->getMaxPower(); } // Clickable name to target @@ -9369,6 +9374,25 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Boss power bar — shown when boss has a non-zero power pool + // Energy bosses (type 3) are particularly important: full energy signals ability use + if (bossMaxPower > 0 && bossPower > 0) { + float bpPct = static_cast(bossPower) / static_cast(bossMaxPower); + ImVec4 bpColor; + switch (bossPowerType) { + case 0: bpColor = ImVec4(0.2f, 0.3f, 0.9f, 1.0f); break; // Mana: blue + case 1: bpColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage: red + case 2: bpColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus: orange + case 3: bpColor = ImVec4(0.9f, 0.9f, 0.1f, 1.0f); break; // Energy: yellow + default: bpColor = ImVec4(0.4f, 0.8f, 0.4f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bpColor); + char bpLabel[24]; + std::snprintf(bpLabel, sizeof(bpLabel), "%u", bossPower); + ImGui::ProgressBar(bpPct, ImVec2(-1, 6), bpLabel); + ImGui::PopStyleColor(); + } + // Boss cast bar — shown when the boss is casting (critical for interrupt) if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { float castPct = (cs->timeTotal > 0.0f) From 8cb0f1d0ef5041eadebffeb702898b6829f23178 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:13:09 -0700 Subject: [PATCH 30/82] feat: show Elite/Rare/Boss classification badge in target frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads creature rank (0=Normal, 1=Elite, 2=RareElite, 3=Boss, 4=Rare) from the existing creatureInfoCache populated by creature query responses. Shows a colored badge next to the level: gold for Elite, purple for Rare Elite, red for Boss, cyan for Rare — each with a tooltip. Adds getCreatureRank() accessor to GameHandler for UI use. --- include/game/game_handler.hpp | 6 ++++++ src/ui/game_screen.cpp | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a2cf3a6b..352fceb6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -548,6 +548,12 @@ public: } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; + // Returns the creature rank (0=Normal,1=Elite,2=RareElite,3=Boss,4=Rare) + // or -1 if not cached yet + int getCreatureRank(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? static_cast(it->second.rank) : -1; + } // ---- Phase 2: Combat ---- void startAutoAttack(uint64_t targetGuid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a2c36436..c1201aa4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3389,6 +3389,27 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + // Classification badge: Elite / Rare Elite / Boss / Rare + if (target->getType() == game::ObjectType::UNIT) { + int rank = gameHandler.getCreatureRank(unit->getEntry()); + if (rank == 1) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group"); + } else if (rank == 2) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended"); + } else if (rank == 3) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "[Boss]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss"); + } else if (rank == 4) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); + } + } if (confirmedCombatWithTarget) { float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); From 1165aa6e74a87fac3c6b905be9e0f153667fd0cf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:14:25 -0700 Subject: [PATCH 31/82] feat: display creature subtitle in target frame Shows the NPC subtitle (e.g. '') below the creature name in the target frame, using the subName field already parsed from SMSG_CREATURE_QUERY_RESPONSE. Adds getCachedCreatureSubName() accessor to GameHandler. Matches the official client's presentation. --- include/game/game_handler.hpp | 5 +++++ src/ui/game_screen.cpp | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 352fceb6..fefedffc 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -548,6 +548,11 @@ public: } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; + // Returns the creature subname/title (e.g. ""), empty if not cached + std::string getCachedCreatureSubName(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.subName : ""; + } // Returns the creature rank (0=Normal,1=Elite,2=RareElite,3=Boss,4=Rare) // or -1 if not cached yet int getCreatureRank(uint32_t entry) const { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c1201aa4..feb0cee6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3315,6 +3315,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Creature subtitle (e.g. "", "Captain of the Guard") + if (target->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(target); + const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str()); + } + } + // Right-click context menu on the target name if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); From d2db0b46ff46fe7c56b8a56bac8c773566a0746f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:15:45 -0700 Subject: [PATCH 32/82] feat: show quest giver ! and ? indicators in target frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads QuestGiverStatus from the existing npcQuestStatus_ cache and displays a colored badge next to the target's name: ! (gold) — quest available ! (gray) — low-level quest available ? (gold) — quest ready to turn in ? (gray) — quest incomplete / in progress Matches the standard WoW quest indicator convention. --- src/ui/game_screen.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index feb0cee6..54b2c9fc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3315,6 +3315,29 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Quest giver indicator — "!" for available quests, "?" for completable quests + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete"); + } + } + // Creature subtitle (e.g. "", "Captain of the Guard") if (target->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(target); From 9494b1e607e9307afdf0388936fcacde91e3832c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:18:22 -0700 Subject: [PATCH 33/82] feat: show quest giver ! and ? indicators on nameplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For non-hostile NPCs with quest status data, displays a colored symbol to the right of the nameplate name: ! (gold) — quest available ! (gray) — low-level quest ? (gold) — quest ready to turn in ? (gray) — quest incomplete Displayed adjacent to the existing quest-kill sword icon, maintaining the existing icon offset logic so both can coexist. --- src/ui/game_screen.cpp | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 54b2c9fc..f786cd52 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8289,11 +8289,35 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } // Quest kill objective indicator: small yellow sword icon to the right of the name + float questIconX = nameX + textSize.x + 4.0f; if (!isPlayer && questKillEntries.count(unit->getEntry())) { const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) - float objX = nameX + textSize.x + 4.0f; - drawList->AddText(ImVec2(objX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); - drawList->AddText(ImVec2(objX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); + drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f; + } + + // Quest giver indicator: "!" for available quests, "?" for completable/incomplete + if (!isPlayer) { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(guid); + const char* qSym = nullptr; + ImU32 qCol = IM_COL32(255, 210, 0, A(255)); + if (qgs == QGS::AVAILABLE) { + qSym = "!"; + } else if (qgs == QGS::AVAILABLE_LOW) { + qSym = "!"; + qCol = IM_COL32(160, 160, 160, A(220)); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + qSym = "?"; + } else if (qgs == QGS::INCOMPLETE) { + qSym = "?"; + qCol = IM_COL32(160, 160, 160, A(220)); + } + if (qSym) { + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym); + drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym); + } } } From d34f505eeacda630049d44757ae1907ff3014296 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:21:02 -0700 Subject: [PATCH 34/82] feat: add quest indicator, classification badge, and subtitle to focus frame Mirrors the target frame improvements: NPC focus targets now show quest giver ! / ? indicators, Elite/Rare/Boss/Rare Elite badges, and the creature subtitle (e.g. ''). Keeps the focus and target frames in consistent feature parity. --- src/ui/game_screen.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f786cd52..c724ceb7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4007,6 +4007,42 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Quest giver indicator and classification badge for NPC focus targets + if (focus->getType() == game::ObjectType::UNIT) { + auto focusUnit = std::static_pointer_cast(focus); + + // Quest indicator: ! / ? + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); + } + } + + // Classification badge + int fRank = gameHandler.getCreatureRank(focusUnit->getEntry()); + if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); } + else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); } + else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "[Boss]"); } + else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } + + // Creature subtitle + const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); + if (!fSub.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); + } + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); const uint64_t fGuid = focus->getGuid(); From 65f19b2d53dee053a79cc4e84359403db65d498b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:25:37 -0700 Subject: [PATCH 35/82] feat: show durability warning overlay when gear is damaged or broken Displays a bottom-center HUD banner when any equipped item drops below 20% durability (yellow) or reaches 0 (red "broken" alert), matching WoW's own repair reminder UX. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index c2320681..d01b3638 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -368,6 +368,7 @@ private: void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); void renderDPSMeter(game::GameHandler& gameHandler); + void renderDurabilityWarning(game::GameHandler& gameHandler); /** * Inventory screen diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c724ceb7..abf95f72 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -565,6 +565,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderRaidWarningOverlay(gameHandler); renderCombatText(gameHandler); renderDPSMeter(gameHandler); + renderDurabilityWarning(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); @@ -9088,6 +9089,72 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Durability Warning (equipment damage indicator) +// ============================================================ + +void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { + if (gameHandler.getPlayerGuid() == 0) return; + + const auto& inv = gameHandler.getInventory(); + + // Scan all equipment slots (skip bag slots which have no durability) + float minDurPct = 1.0f; + bool hasBroken = false; + + for (int i = static_cast(game::EquipSlot::HEAD); + i < static_cast(game::EquipSlot::BAG1); ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) { + hasBroken = true; + } + float pct = static_cast(slot.item.curDurability) / + static_cast(slot.item.maxDurability); + if (pct < minDurPct) minDurPct = pct; + } + + // Only show warning below 20% + if (minDurPct >= 0.2f && !hasBroken) return; + + ImGuiIO& io = ImGui::GetIO(); + const float screenW = io.DisplaySize.x; + const float screenH = io.DisplaySize.y; + + // Position: just above the XP bar / action bar area (bottom-center) + const float warningW = 220.0f; + const float warningH = 26.0f; + const float posX = (screenW - warningW) * 0.5f; + const float posY = screenH - 140.0f; // above action bar + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0)); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + if (ImGui::Begin("##durability_warn", nullptr, flags)) { + if (hasBroken) { + ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f), + "\xef\x94\x9b Gear broken! Visit a repair NPC"); + } else { + int pctInt = static_cast(minDurPct * 100.0f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), + "\xef\x94\x9b Low durability: %d%%", pctInt); + } + if (ImGui::IsWindowHovered()) + ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC."); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + // ============================================================ // UI Error Frame (WoW-style center-bottom error overlay) // ============================================================ From 950a4e299138a48467ed2abdb6538260bfbf9195 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:30:15 -0700 Subject: [PATCH 36/82] feat: show raid mark icon on focus frame to match target frame parity --- src/ui/game_screen.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index abf95f72..e941bba4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3999,6 +3999,27 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::TextDisabled("[Focus]"); ImGui::SameLine(); + // Raid mark icon (star, circle, diamond, …) preceding the name + { + static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow) + { "\xe2\x97\x8f", IM_COL32(255, 103, 0, 255) }, // 1 Circle (orange) + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple) + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green) + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue) + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal) + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red) + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white) + }; + uint8_t fmark = gameHandler.getEntityRaidMark(focus->getGuid()); + if (fmark < game::GameHandler::kRaidMarkCount) { + ImGui::GetWindowDrawList()->AddText( + ImGui::GetCursorScreenPos(), + kFocusMarks[fmark].col, kFocusMarks[fmark].sym); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); + } + } + std::string focusName = getEntityName(focus); ImGui::PushStyleColor(ImGuiCol_Text, focusColor); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); From b44ff09b637281298395724c4ca34c378a84fdf5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:32:15 -0700 Subject: [PATCH 37/82] feat: add pulsing golden glow to Attack action bar slot when auto-attacking --- src/ui/game_screen.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e941bba4..08308673 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6584,6 +6584,20 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } + // Auto-attack active glow — pulsing golden border when slot 6603 (Attack) is toggled on + if (slot.type == game::ActionBarSlot::SPELL && slot.id == 6603 + && gameHandler.isAutoAttacking()) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); + ImU32 glowCol = IM_COL32( + static_cast(255), + static_cast(200 * pulse), + static_cast(0), + static_cast(200 * pulse)); + ImGui::GetWindowDrawList()->AddRect(bMin, bMax, glowCol, 2.0f, 0, 2.5f); + } + // Item stack count overlay — bottom-right corner of icon if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { // Count total of this item across all inventory slots From 6d21a8cb8d6428647aa7391a4c300e12255469d1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:34:21 -0700 Subject: [PATCH 38/82] feat: add distance indicator to focus frame for range awareness --- src/ui/game_screen.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 08308673..b6e3a0a4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4305,6 +4305,16 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { } } + // Distance to focus target + { + const auto& mv = gameHandler.getMovementInfo(); + float fdx = focus->getX() - mv.x; + float fdy = focus->getY() - mv.y; + float fdz = focus->getZ() - mv.z; + float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz); + ImGui::TextDisabled("%.1f yd", fdist); + } + // Clicking the focus frame targets it if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { gameHandler.setTarget(focus->getGuid()); From 2f234af43bb372e9ec312e6dcc12b261dc83376c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:39:29 -0700 Subject: [PATCH 39/82] feat: show group leader crown on target frame when targeting party leader --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b6e3a0a4..923aea8e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3316,6 +3316,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Group leader crown — golden ♛ when the targeted player is the party/raid leader + if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + // Quest giver indicator — "!" for available quests, "?" for completable quests { using QGS = game::QuestGiverStatus; From d682ec4ca72fcf3f40a7713eb9bafcc87c29abf5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:43:58 -0700 Subject: [PATCH 40/82] feat: show group leader crown on focus frame for parity with target frame --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 923aea8e..c77346c4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4038,6 +4038,15 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Group leader crown — golden ♛ when the focused player is the party/raid leader + if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + // Quest giver indicator and classification badge for NPC focus targets if (focus->getType() == game::ObjectType::UNIT) { auto focusUnit = std::static_pointer_cast(focus); From 61412ae06dff5e0e68e766fd8b75244afa4f2bd7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:47:51 -0700 Subject: [PATCH 41/82] feat: show group leader crown on player frame when you are party/raid leader --- 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 c77346c4..84c1ff09 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2590,6 +2590,13 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD"); } + // Group leader crown on self frame when you lead the party/raid + if (gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader"); + } if (gameHandler.isAfk()) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); From 604473966145cb440d060d8f016b7ddeab7bc5c4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:48:53 -0700 Subject: [PATCH 42/82] feat: show group leader crown on world nameplate for party/raid leader players --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 84c1ff09..8168dc29 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8376,6 +8376,15 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + // Group leader crown to the right of the name on player nameplates + if (isPlayer && gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == guid) { + float crownX = nameX + textSize.x + 3.0f; + const char* crownSym = "\xe2\x99\x9b"; // ♛ + drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym); + drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym); + } + // Raid mark (if any) to the left of the name { static const struct { const char* sym; ImU32 col; } kNPMarks[] = { From c0c750a76ec4e4553fc35436b86c4ccc574c85e4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:50:59 -0700 Subject: [PATCH 43/82] feat: show aura stack/charge count on focus frame aura icons for parity with target frame --- src/ui/game_screen.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8168dc29..d9106e78 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4302,6 +4302,17 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); } + // Stack / charge count — upper-left corner (parity with target frame) + if (aura.charges > 1) { + ImVec2 faMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + // Tooltip if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); From 6645845d05814660a8d6d773661adb85e388c1a8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:53:14 -0700 Subject: [PATCH 44/82] feat: show aura stack/charge count on boss frame aura icons for parity --- src/ui/game_screen.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d9106e78..52b01a48 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9804,6 +9804,17 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); } + // Stack / charge count — upper-left corner (parity with target/focus frames) + if (aura.charges > 1) { + ImVec2 baMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 2, baMin.y + 2), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 1, baMin.y + 1), + IM_COL32(255, 220, 50, 255), chargeStr); + } + // Tooltip if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); From 6dc630c1d8abfc810a391c51edf9b71dea64a515 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 14:58:48 -0700 Subject: [PATCH 45/82] feat: add Arena tab to Social frame showing per-team rating and weekly/season record --- src/ui/game_screen.cpp | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 52b01a48..71be9279 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11450,6 +11450,52 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // ---- Arena tab (WotLK: shows per-team rating/record) ---- + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (!arenaStats.empty()) { + if (ImGui::BeginTabItem("Arena")) { + ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false); + + for (size_t ai = 0; ai < arenaStats.size(); ++ai) { + const auto& ts = arenaStats[ai]; + ImGui::PushID(static_cast(ai)); + + // Team header with rating + char teamLabel[48]; + snprintf(teamLabel, sizeof(teamLabel), "Team #%u", ts.teamId); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel); + + ImGui::Indent(8.0f); + // Rating and rank + ImGui::Text("Rating: %u", ts.rating); + if (ts.rank > 0) { + ImGui::SameLine(0, 6); + ImGui::TextDisabled("(Rank #%u)", ts.rank); + } + + // Weekly record + uint32_t weekLosses = ts.weekGames > ts.weekWins + ? ts.weekGames - ts.weekWins : 0; + ImGui::Text("Week: %u W / %u L", ts.weekWins, weekLosses); + + // Season record + uint32_t seasLosses = ts.seasonGames > ts.seasonWins + ? ts.seasonGames - ts.seasonWins : 0; + ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); + + ImGui::Unindent(8.0f); + + if (ai + 1 < arenaStats.size()) + ImGui::Separator(); + + ImGui::PopID(); + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } } From e2f36f6ac5d49a92d270bc042edb69f68ea9c184 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:05:52 -0700 Subject: [PATCH 46/82] feat: show party member dots on world map with name labels and class colors --- include/rendering/world_map.hpp | 12 ++++++++++++ src/rendering/world_map.cpp | 27 +++++++++++++++++++++++++++ src/ui/game_screen.cpp | 25 +++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 47956b42..77a98ec0 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,13 @@ class VkContext; class VkTexture; class VkRenderTarget; +/// Party member dot passed in from the UI layer for world map overlay. +struct WorldMapPartyDot { + glm::vec3 renderPos; ///< Position in render-space coordinates + uint32_t color; ///< RGBA packed color (IM_COL32 format) + std::string name; ///< Member name (shown as tooltip on hover) +}; + struct WorldMapZone { uint32_t wmaID = 0; uint32_t areaID = 0; // 0 = continent level @@ -47,6 +55,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); } bool isOpen() const { return open; } void close() { open = false; } @@ -113,6 +122,9 @@ private: // Texture storage (owns all VkTexture objects for zone tiles) std::vector> zoneTextures; + // Party member dots (set each frame from the UI layer) + std::vector partyDots_; + // 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 701c5148..138d39db 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -1017,6 +1017,33 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Party member dots + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + ImFont* font = ImGui::GetFont(); + for (const auto& dot : partyDots_) { + glm::vec2 uv = renderPosToMapUV(dot.renderPos, 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; + drawList->AddCircleFilled(ImVec2(px, py), 5.0f, dot.color); + drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(0, 0, 0, 200), 0, 1.5f); + // Name tooltip on hover + if (!dot.name.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + float dx = mp.x - px, dy = mp.y - py; + if (dx * dx + dy * dy <= 49.0f) { // radius 7 px hit area + ImGui::SetTooltip("%s", dot.name.c_str()); + } + // Draw name label above the dot + ImVec2 nameSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, dot.name.c_str()); + float tx = px - nameSz.x * 0.5f; + float ty = py - nameSz.y - 7.0f; + drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), dot.name.c_str()); + drawList->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 220), dot.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 71be9279..ae1a3f41 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6139,6 +6139,31 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { gameHandler.getPlayerExploredZoneMasks(), gameHandler.hasPlayerExploredZoneMasks()); + // Party member dots on world map + { + std::vector dots; + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + for (const auto& member : partyData.members) { + if (!member.isOnline || !member.hasPartyStats) continue; + if (member.posX == 0 && member.posY == 0) continue; + // posY → canonical X (north), posX → canonical Y (west) + float wowX = static_cast(member.posY); + float wowY = static_cast(member.posX); + glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); + auto ent = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(ent.get()); + ImU32 col = (cid != 0) + ? classColorU32(cid, 230) + : (member.guid == partyData.leaderGuid + ? IM_COL32(255, 210, 0, 230) + : IM_COL32(100, 180, 255, 230)); + dots.push_back({ rpos, col, member.name }); + } + } + wm->setPartyDots(std::move(dots)); + } + glm::vec3 playerPos = renderer->getCharacterPosition(); auto* window = app.getWindow(); int screenW = window ? window->getWidth() : 1280; From bff690ea53616d5e8556b99ef488e6b555da6115 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:11:09 -0700 Subject: [PATCH 47/82] feat: show zone name tooltip on party member name hover in party frame --- src/ui/game_screen.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ae1a3f41..e4c6202c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8915,6 +8915,12 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + // Zone tooltip on name hover + if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); + if (!zoneName.empty()) + ImGui::SetTooltip("%s", zoneName.c_str()); + } ImGui::PopStyleColor(); // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set From 39fc6a645ef68f2afe1fee7ad7805e0f5a6a830a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:19:08 -0700 Subject: [PATCH 48/82] feat: show party member dots on minimap with class colors and name tooltip Small colored squares appear on the minimap for each online party member at their server-reported position. Dots use WoW class colors when the entity is loaded, gold for the party leader, and light blue otherwise; dead members show as gray. Hovering a dot shows the member's name. --- src/ui/game_screen.cpp | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e4c6202c..76b45dbb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14961,6 +14961,59 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Party member dots on minimap — small colored squares with name tooltip on hover + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& member : partyData.members) { + if (!member.hasPartyStats) continue; + bool isOnline = (member.onlineStatus & 0x0001) != 0; + bool isDead = (member.onlineStatus & 0x0020) != 0; + bool isGhost = (member.onlineStatus & 0x0010) != 0; + if (!isOnline) continue; + if (member.posX == 0 && member.posY == 0) continue; + + // Party stat positions: posY = canonical X (north), posX = canonical Y (west) + glm::vec3 memberRender = core::coords::canonicalToRender( + glm::vec3(static_cast(member.posY), + static_cast(member.posX), 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(memberRender, sx, sy)) continue; + + // Determine dot color: class color > leader gold > light blue + ImU32 dotCol; + if (isDead || isGhost) { + dotCol = IM_COL32(140, 140, 140, 200); // gray for dead + } else { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + dotCol = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), 230); + } else if (member.guid == partyData.leaderGuid) { + dotCol = IM_COL32(255, 210, 0, 230); // gold for leader + } else { + dotCol = IM_COL32(100, 180, 255, 230); // blue for others + } + } + + // Draw a small square (WoW-style party member dot) + const float hs = 3.5f; + drawList->AddRectFilled(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), dotCol, 1.0f); + drawList->AddRect(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), + IM_COL32(0, 0, 0, 180), 1.0f, 0, 1.0f); + + // Name tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f && !member.name.empty()) { + ImGui::SetTooltip("%s", member.name.c_str()); + } + } + } + for (const auto& [guid, status] : statuses) { ImU32 dotColor; const char* marker = nullptr; From c503bc9432516ae52d533ceefcd8524233772eaa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:20:31 -0700 Subject: [PATCH 49/82] feat: show nearby other-player dots on minimap when NPC dots are enabled --- src/ui/game_screen.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 76b45dbb..f7c2f8f8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14924,6 +14924,32 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Nearby other-player dots — shown when NPC dots are enabled. + // Party members are already drawn as squares above; other players get a small circle. + if (minimapNpcDots_) { + const uint64_t selfGuid = gameHandler.getPlayerGuid(); + const auto& partyData = gameHandler.getPartyData(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + if (entity->getGuid() == selfGuid) continue; // skip self (already drawn as arrow) + + // Skip party members (already drawn as squares above) + bool isPartyMember = false; + for (const auto& m : partyData.members) { + if (m.guid == guid) { isPartyMember = true; break; } + } + if (isPartyMember) continue; + + glm::vec3 pRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(pRender, sx, sy)) continue; + + // Blue dot for other nearby players + drawList->AddCircleFilled(ImVec2(sx, sy), 2.0f, IM_COL32(80, 160, 255, 220)); + } + } + // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. // Shown whenever NPC dots are enabled (or always, since they're always useful). { From 78ad20f95dda9455d47583fb05488f4e29fab1b4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:25:07 -0700 Subject: [PATCH 50/82] feat: add cooldown tracker panel showing all active spell cooldowns A new opt-in panel (Settings > Interface > Show Cooldown Tracker) lists all spells currently on cooldown, sorted longest-to-shortest, with spell icons and color-coded remaining time (red>30s, orange>10s, yellow>5s, green<5s). Adds getSpellCooldowns() accessor to GameHandler. Setting persists to ~/.wowee/settings.cfg. --- include/game/game_handler.hpp | 1 + include/ui/game_screen.hpp | 4 ++ src/ui/game_screen.cpp | 102 ++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index fefedffc..81b02737 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -983,6 +983,7 @@ public: // Cooldowns float getSpellCooldown(uint32_t spellId) const; + const std::unordered_map& getSpellCooldowns() const { return spellCooldowns; } // Player GUID uint64_t getPlayerGuid() const { return playerGuid; } diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index d01b3638..5f75350c 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -314,6 +314,7 @@ private: void renderRepBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(game::GameHandler& gameHandler); + void renderCooldownTracker(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); void renderRaidWarningOverlay(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); @@ -525,6 +526,9 @@ private: std::string lastKnownZoneName_; void renderZoneText(); + // Cooldown tracker + bool showCooldownTracker_ = false; + // DPS / HPS meter bool showDPSMeter_ = false; float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f7c2f8f8..4ba9fd94 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -559,6 +559,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderRepBar(gameHandler); renderCastBar(gameHandler); renderMirrorTimers(gameHandler); + renderCooldownTracker(gameHandler); renderQuestObjectiveTracker(gameHandler); renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); @@ -7532,6 +7533,98 @@ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { } } +// ============================================================ +// Cooldown Tracker — floating panel showing all active spell CDs +// ============================================================ + +void GameScreen::renderCooldownTracker(game::GameHandler& gameHandler) { + if (!showCooldownTracker_) return; + + const auto& cooldowns = gameHandler.getSpellCooldowns(); + if (cooldowns.empty()) return; + + // Collect spells with remaining cooldown > 0.5s (skip GCD noise) + struct CDEntry { uint32_t spellId; float remaining; }; + std::vector active; + active.reserve(16); + for (const auto& [sid, rem] : cooldowns) { + if (rem > 0.5f) active.push_back({sid, rem}); + } + if (active.empty()) return; + + // Sort: longest remaining first + std::sort(active.begin(), active.end(), [](const CDEntry& a, const CDEntry& b) { + return a.remaining > b.remaining; + }); + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float TRACKER_W = 200.0f; + constexpr int MAX_SHOWN = 12; + float posX = screenW - TRACKER_W - 10.0f; + float posY = screenH - 220.0f; // above the action bar area + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##CooldownTracker", nullptr, flags)) { + ImGui::TextDisabled("Cooldowns"); + ImGui::Separator(); + + int shown = 0; + for (const auto& cd : active) { + if (shown >= MAX_SHOWN) break; + + const std::string& name = gameHandler.getSpellName(cd.spellId); + if (name.empty()) continue; // skip unnamed spells (internal/passive) + + // Small icon if available + VkDescriptorSet icon = assetMgr ? getSpellIcon(cd.spellId, assetMgr) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(14, 14)); + ImGui::SameLine(0, 3); + } + + // Name (truncated) + remaining time + char timeStr[16]; + if (cd.remaining >= 60.0f) + snprintf(timeStr, sizeof(timeStr), "%dm%ds", (int)cd.remaining / 60, (int)cd.remaining % 60); + else + snprintf(timeStr, sizeof(timeStr), "%.0fs", cd.remaining); + + // Color: red > 30s, orange > 10s, yellow > 5s, green otherwise + ImVec4 cdColor = cd.remaining > 30.0f ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) : + cd.remaining > 10.0f ? ImVec4(1.0f, 0.6f, 0.2f, 1.0f) : + cd.remaining > 5.0f ? ImVec4(1.0f, 1.0f, 0.3f, 1.0f) : + ImVec4(0.5f, 1.0f, 0.5f, 1.0f); + + // Truncate name to fit + std::string displayName = name; + if (displayName.size() > 16) displayName = displayName.substr(0, 15) + "\xe2\x80\xa6"; // ellipsis + + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", displayName.c_str()); + ImGui::SameLine(TRACKER_W - 48.0f); + ImGui::TextColored(cdColor, "%s", timeStr); + + ++shown; + } + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + // ============================================================ // Quest Objective Tracker (right-side HUD) // ============================================================ @@ -14030,6 +14123,12 @@ void GameScreen::renderSettingsWindow() { ImGui::SameLine(); ImGui::TextDisabled("(damage/healing per second above action bar)"); + if (ImGui::Checkbox("Show Cooldown Tracker", &showCooldownTracker_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(active spell cooldowns near action bar)"); + ImGui::Spacing(); ImGui::SeparatorText("Screen Effects"); ImGui::Spacing(); @@ -16109,6 +16208,7 @@ void GameScreen::saveSettings() { out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n"; + out << "show_cooldown_tracker=" << (showCooldownTracker_ ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "nameplate_scale=" << nameplateScale_ << "\n"; @@ -16219,6 +16319,8 @@ void GameScreen::loadSettings() { pendingShowLatencyMeter = showLatencyMeter_; } else if (key == "show_dps_meter") { showDPSMeter_ = (std::stoi(val) != 0); + } else if (key == "show_cooldown_tracker") { + showCooldownTracker_ = (std::stoi(val) != 0); } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); From 9e4c3d67d9ba2b4795dafb58dd7f2c356b65c52d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:28:31 -0700 Subject: [PATCH 51/82] feat: show interactable game object dots on minimap when NPC dots enabled --- 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 4ba9fd94..ae6c8590 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15086,6 +15086,46 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Interactable game object dots (chests, resource nodes) when NPC dots are enabled. + // Shown as small orange triangles to distinguish from unit dots and loot corpses. + if (minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue; + + // Only show objects that are likely interactive (chests/nodes: type 3; + // also show type 0=Door when open, but filter by dynamic-flag ACTIVATED). + // For simplicity, show all game objects that have a non-empty cached name. + auto go = std::static_pointer_cast(entity); + if (!go) continue; + + // Only show if we have name data (avoids cluttering with unknown objects) + const auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); + if (!goInfo || !goInfo->isValid()) continue; + // Skip transport objects (boats/zeppelins): type 15 = MO_TRANSPORT, 11 = TRANSPORT + if (goInfo->type == 11 || goInfo->type == 15) continue; + + glm::vec3 goRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(goRender, sx, sy)) continue; + + // Small upward triangle in gold/amber for interactable objects + const float ts = 3.5f; + ImVec2 goTip (sx, sy - ts); + ImVec2 goLeft (sx - ts, sy + ts * 0.6f); + ImVec2 goRight(sx + ts, sy + ts * 0.6f); + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + ImGui::SetTooltip("%s", goInfo->name.c_str()); + } + } + } + // Party member dots on minimap — small colored squares with name tooltip on hover if (gameHandler.isInGroup()) { const auto& partyData = gameHandler.getPartyData(); From 98ad71df0de100adab0f74f4e2815de6e9eca024 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:36:25 -0700 Subject: [PATCH 52/82] feat: show class-colored health bars on player nameplates --- src/ui/game_screen.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ae6c8590..5b7332f2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8332,6 +8332,23 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } else if (unit->isHostile()) { barColor = IM_COL32(220, 60, 60, A(200)); bgColor = IM_COL32(100, 25, 25, A(160)); + } else if (isPlayer) { + // Player nameplates: use class color for easy identification + uint8_t cid = entityClassId(unit); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + barColor = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), A(210)); + bgColor = IM_COL32( + static_cast(cv.x * 80), + static_cast(cv.y * 80), + static_cast(cv.z * 80), A(160)); + } else { + barColor = IM_COL32(60, 200, 80, A(200)); + bgColor = IM_COL32(25, 100, 35, A(160)); + } } else { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); From 77879769d339081043069f07079e8191ab3e0f2e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:42:55 -0700 Subject: [PATCH 53/82] feat: show area discovery toast with XP gain when exploring new zones --- include/game/game_handler.hpp | 5 +++ include/ui/game_screen.hpp | 8 ++++ src/game/game_handler.cpp | 2 + src/ui/game_screen.cpp | 84 +++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 81b02737..a02077f1 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1428,6 +1428,10 @@ public: using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + + // Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received + using AreaDiscoveryCallback = std::function; + void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); } const std::unordered_map& getCriteriaProgress() const { return criteriaProgress_; } /// Returns the WoW PackedTime earn date for an achievement, or 0 if unknown. uint32_t getAchievementDate(uint32_t id) const { @@ -2749,6 +2753,7 @@ private: LevelUpCallback levelUpCallback_; OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; + AreaDiscoveryCallback areaDiscoveryCallback_; MountCallback mountCallback_; TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5f75350c..f557ec0e 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -519,6 +519,14 @@ private: std::string achievementToastName_; void renderAchievementToast(); + // Area discovery toast ("Discovered! +XP XP") + static constexpr float DISCOVERY_TOAST_DURATION = 4.0f; + float discoveryToastTimer_ = 0.0f; + std::string discoveryToastName_; + uint32_t discoveryToastXP_ = 0; + bool areaDiscoveryCallbackSet_ = false; + void renderDiscoveryToast(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3439ffc1..0ff47db3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1767,6 +1767,8 @@ void GameHandler::handlePacket(network::Packet& packet) { } addSystemChatMessage(msg); // XP is updated via PLAYER_XP update fields from the server. + if (areaDiscoveryCallback_) + areaDiscoveryCallback_(areaName, xpGained); } } break; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5b7332f2..aa4cef65 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -300,6 +300,16 @@ void GameScreen::render(game::GameHandler& gameHandler) { achievementCallbackSet_ = true; } + // Set up area discovery toast callback (once) + if (!areaDiscoveryCallbackSet_) { + gameHandler.setAreaDiscoveryCallback([this](const std::string& areaName, uint32_t xpGained) { + discoveryToastName_ = areaName.empty() ? "New Area" : areaName; + discoveryToastXP_ = xpGained; + discoveryToastTimer_ = DISCOVERY_TOAST_DURATION; + }); + areaDiscoveryCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -628,6 +638,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderSettingsWindow(); renderDingEffect(); renderAchievementToast(); + renderDiscoveryToast(); renderZoneText(); // World map (M key toggle handled inside) @@ -17927,6 +17938,79 @@ void GameScreen::renderAchievementToast() { IM_COL32(220, 200, 150, (int)(alpha * 255)), idBuf); } +// --------------------------------------------------------------------------- +// Area discovery toast — "Discovered: ! (+XP XP)" centered on screen +// --------------------------------------------------------------------------- + +void GameScreen::renderDiscoveryToast() { + if (discoveryToastTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + discoveryToastTimer_ -= dt; + if (discoveryToastTimer_ < 0.0f) discoveryToastTimer_ = 0.0f; + + // Fade: ramp up in first 0.4s, hold, fade out in last 1.0s + float alpha; + if (discoveryToastTimer_ > DISCOVERY_TOAST_DURATION - 0.4f) + alpha = 1.0f - (discoveryToastTimer_ - (DISCOVERY_TOAST_DURATION - 0.4f)) / 0.4f; + else if (discoveryToastTimer_ < 1.0f) + alpha = discoveryToastTimer_; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImFont* font = ImGui::GetFont(); + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + const char* header = "Discovered!"; + float headerSize = 16.0f; + float nameSize = 28.0f; + float xpSize = 14.0f; + + ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); + ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, discoveryToastName_.c_str()); + + char xpBuf[48]; + if (discoveryToastXP_ > 0) + snprintf(xpBuf, sizeof(xpBuf), "+%u XP", discoveryToastXP_); + else + xpBuf[0] = '\0'; + ImVec2 xpDim = font->CalcTextSizeA(xpSize, FLT_MAX, 0.0f, xpBuf); + + // Position slightly below zone text (at 37% down screen) + float centreY = screenH * 0.37f; + float headerX = (screenW - headerDim.x) * 0.5f; + float nameX = (screenW - nameDim.x) * 0.5f; + float xpX = (screenW - xpDim.x) * 0.5f; + float headerY = centreY; + float nameY = centreY + headerDim.y + 4.0f; + float xpY = nameY + nameDim.y + 4.0f; + + // "Discovered!" in gold + draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), header); + draw->AddText(font, headerSize, ImVec2(headerX, headerY), + IM_COL32(255, 215, 0, (int)(alpha * 255)), header); + + // Area name in white + draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), discoveryToastName_.c_str()); + draw->AddText(font, nameSize, ImVec2(nameX, nameY), + IM_COL32(255, 255, 255, (int)(alpha * 255)), discoveryToastName_.c_str()); + + // XP gain in light green (if any) + if (xpBuf[0] != '\0') { + draw->AddText(font, xpSize, ImVec2(xpX + 1, xpY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 140)), xpBuf); + draw->AddText(font, xpSize, ImVec2(xpX, xpY), + IM_COL32(100, 220, 100, (int)(alpha * 230)), xpBuf); + } +} + // --------------------------------------------------------------------------- // Zone discovery text — "Entering: " fades in/out at screen centre // --------------------------------------------------------------------------- From 5216582f1520a9ddf7c86611221a5f303646848a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:53:45 -0700 Subject: [PATCH 54/82] feat: show whisper toast notification when a player whispers you Adds a slide-in toast overlay at the bottom-left of the screen whenever an incoming whisper arrives. Toasts display "Whisper from:", the sender name in gold, and a truncated message preview. Up to 3 toasts stack with a 5s lifetime; each fades in over 0.25s and fades out in the final 1s. --- include/ui/game_screen.hpp | 11 ++++ src/ui/game_screen.cpp | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f557ec0e..8861be60 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -527,6 +527,17 @@ private: bool areaDiscoveryCallbackSet_ = false; void renderDiscoveryToast(); + // Whisper toast — brief overlay at screen top when a whisper arrives while chat is not focused + struct WhisperToastEntry { + std::string sender; + std::string preview; // first ~60 chars of message + float age = 0.0f; + }; + static constexpr float WHISPER_TOAST_DURATION = 5.0f; + std::vector whisperToasts_; + size_t whisperSeenCount_ = 0; // how many chat entries have been scanned for whispers + void renderWhisperToasts(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index aa4cef65..c6454449 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -639,6 +639,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDingEffect(); renderAchievementToast(); renderDiscoveryToast(); + renderWhisperToasts(); renderZoneText(); // World map (M key toggle handled inside) @@ -1689,6 +1690,32 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared } + // Scan NEW messages for incoming whispers and push a toast notification + { + size_t histSize = chatHistory.size(); + if (histSize < whisperSeenCount_) whisperSeenCount_ = histSize; // cleared + for (size_t wi = whisperSeenCount_; wi < histSize; ++wi) { + const auto& wMsg = chatHistory[wi]; + if (wMsg.type == game::ChatType::WHISPER || + wMsg.type == game::ChatType::RAID_BOSS_WHISPER) { + WhisperToastEntry toast; + toast.sender = wMsg.senderName; + if (toast.sender.empty() && wMsg.senderGuid != 0) + toast.sender = gameHandler.lookupName(wMsg.senderGuid); + if (toast.sender.empty()) toast.sender = "Unknown"; + // Truncate preview to 60 chars + toast.preview = wMsg.message.size() > 60 + ? wMsg.message.substr(0, 57) + "..." + : wMsg.message; + toast.age = 0.0f; + // Keep at most 3 stacked toasts + if (whisperToasts_.size() >= 3) whisperToasts_.erase(whisperToasts_.begin()); + whisperToasts_.push_back(std::move(toast)); + } + } + whisperSeenCount_ = histSize; + } + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; @@ -18012,6 +18039,80 @@ void GameScreen::renderDiscoveryToast() { } // --------------------------------------------------------------------------- +// Whisper toast notifications — brief overlay when a player whispers you +// --------------------------------------------------------------------------- + +void GameScreen::renderWhisperToasts() { + if (whisperToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + + // Age and prune expired toasts + for (auto& t : whisperToasts_) t.age += dt; + whisperToasts_.erase( + std::remove_if(whisperToasts_.begin(), whisperToasts_.end(), + [](const WhisperToastEntry& t) { return t.age >= WHISPER_TOAST_DURATION; }), + whisperToasts_.end()); + if (whisperToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at bottom-left, above the action bars (y ≈ screenH * 0.72) + // Each toast is ~56px tall with a 4px gap between them. + constexpr float TOAST_W = 280.0f; + constexpr float TOAST_H = 56.0f; + constexpr float TOAST_GAP = 4.0f; + constexpr float TOAST_X = 14.0f; // left edge (won't cover action bars) + float baseY = screenH * 0.72f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + + const int count = static_cast(whisperToasts_.size()); + for (int i = 0; i < count; ++i) { + auto& toast = whisperToasts_[i]; + + // Fade in over 0.25s; fade out in last 1.0s + float alpha; + float remaining = WHISPER_TOAST_DURATION - toast.age; + if (toast.age < 0.25f) + alpha = toast.age / 0.25f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left: offset 0→0 after 0.25s + float slideX = (toast.age < 0.25f) ? (TOAST_W * (1.0f - toast.age / 0.25f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(210 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background panel — dark purple tint (whisper color convention) + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(25, 10, 40, bgA), 6.0f); + // Purple border + bgDL->AddRect(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(160, 80, 220, static_cast(180 * alpha)), 6.0f, 0, 1.5f); + + // "Whisper" label (small, purple-ish) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 6.0f), + IM_COL32(190, 110, 255, fgA), "Whisper from:"); + + // Sender name (gold) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 20.0f), + IM_COL32(255, 210, 50, fgA), toast.sender.c_str()); + + // Message preview (white, dimmer) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 36.0f), + IM_COL32(220, 220, 220, static_cast(200 * alpha)), + toast.preview.c_str()); + } +} + // Zone discovery text — "Entering: " fades in/out at screen centre // --------------------------------------------------------------------------- From c3afe543c685b5d73176a7d2adfdf4b301605bf9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:57:09 -0700 Subject: [PATCH 55/82] feat: show quest objective progress toasts on kill and item collection Adds a visual progress overlay at bottom-right when quest kill counts or item collection updates arrive. Each toast shows the quest title, objective name, a fill-progress bar, and an X/Y count. Toasts coalesce when the same objective updates multiple times, and auto-dismiss after 4s. Wires a new QuestProgressCallback through GameHandler to trigger the UI. --- include/game/game_handler.hpp | 8 +++ include/ui/game_screen.hpp | 13 +++++ src/game/game_handler.cpp | 24 ++++++++ src/ui/game_screen.cpp | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a02077f1..047220b5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1432,6 +1432,13 @@ public: // Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received using AreaDiscoveryCallback = std::function; void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); } + + // Quest objective progress callback — fires on SMSG_QUESTUPDATE_ADD_KILL / ADD_ITEM + // questTitle: name of the quest; objectiveName: creature/item name; current/required counts + using QuestProgressCallback = std::function; + void setQuestProgressCallback(QuestProgressCallback cb) { questProgressCallback_ = std::move(cb); } const std::unordered_map& getCriteriaProgress() const { return criteriaProgress_; } /// Returns the WoW PackedTime earn date for an achievement, or 0 if unknown. uint32_t getAchievementDate(uint32_t id) const { @@ -2754,6 +2761,7 @@ private: OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; AreaDiscoveryCallback areaDiscoveryCallback_; + QuestProgressCallback questProgressCallback_; MountCallback mountCallback_; TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 8861be60..29afe02b 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -538,6 +538,19 @@ private: size_t whisperSeenCount_ = 0; // how many chat entries have been scanned for whispers void renderWhisperToasts(); + // Quest objective progress toast ("Quest: X/Y") + struct QuestProgressToastEntry { + std::string questTitle; + std::string objectiveName; + uint32_t current = 0; + uint32_t required = 0; + float age = 0.0f; + }; + static constexpr float QUEST_TOAST_DURATION = 4.0f; + std::vector questToasts_; + bool questProgressCallbackSet_ = false; + void renderQuestProgressToasts(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0ff47db3..8f433ebd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4484,6 +4484,10 @@ void GameHandler::handlePacket(network::Packet& packet) { progressMsg += std::to_string(count) + "/" + std::to_string(reqCount); addSystemChatMessage(progressMsg); + if (questProgressCallback_) { + questProgressCallback_(quest.title, creatureName, count, reqCount); + } + LOG_INFO("Updated kill count for quest ", questId, ": ", count, "/", reqCount); break; @@ -4538,6 +4542,26 @@ void GameHandler::handlePacket(network::Packet& packet) { updatedAny = true; } addSystemChatMessage("Quest item: " + itemLabel + " (" + std::to_string(count) + ")"); + + if (questProgressCallback_ && updatedAny) { + // Find the quest that tracks this item to get title and required count + for (const auto& quest : questLog_) { + if (quest.complete) continue; + if (quest.itemCounts.count(itemId) == 0) continue; + uint32_t required = 0; + auto rIt = quest.requiredItemCounts.find(itemId); + if (rIt != quest.requiredItemCounts.end()) required = rIt->second; + if (required == 0) { + for (const auto& obj : quest.itemObjectives) { + if (obj.itemId == itemId) { required = obj.required; break; } + } + } + if (required == 0) required = count; + questProgressCallback_(quest.title, itemLabel, count, required); + break; + } + } + LOG_INFO("Quest item update: itemId=", itemId, " count=", count, " trackedQuestsUpdated=", updatedAny); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c6454449..112665e7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -310,6 +310,26 @@ void GameScreen::render(game::GameHandler& gameHandler) { areaDiscoveryCallbackSet_ = true; } + // Set up quest objective progress toast callback (once) + if (!questProgressCallbackSet_) { + gameHandler.setQuestProgressCallback([this](const std::string& questTitle, + const std::string& objectiveName, + uint32_t current, uint32_t required) { + // Coalesce: if the same objective already has a toast, just update counts + for (auto& t : questToasts_) { + if (t.questTitle == questTitle && t.objectiveName == objectiveName) { + t.current = current; + t.required = required; + t.age = 0.0f; // restart lifetime + return; + } + } + if (questToasts_.size() >= 4) questToasts_.erase(questToasts_.begin()); + questToasts_.push_back({questTitle, objectiveName, current, required, 0.0f}); + }); + questProgressCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -640,6 +660,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAchievementToast(); renderDiscoveryToast(); renderWhisperToasts(); + renderQuestProgressToasts(); renderZoneText(); // World map (M key toggle handled inside) @@ -18038,6 +18059,92 @@ void GameScreen::renderDiscoveryToast() { } } +// --------------------------------------------------------------------------- +// Quest objective progress toasts — shown at screen bottom-right on kill/item updates +// --------------------------------------------------------------------------- + +void GameScreen::renderQuestProgressToasts() { + if (questToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : questToasts_) t.age += dt; + questToasts_.erase( + std::remove_if(questToasts_.begin(), questToasts_.end(), + [](const QuestProgressToastEntry& t) { return t.age >= QUEST_TOAST_DURATION; }), + questToasts_.end()); + if (questToasts_.empty()) return; + + 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; + + // Stack at bottom-right, just above action bar area + constexpr float TOAST_W = 240.0f; + constexpr float TOAST_H = 48.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = screenW - TOAST_W - 14.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(questToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = questToasts_[i]; + + float remaining = QUEST_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark amber tint (quest color convention) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(35, 25, 5, bgA), 5.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 160, 30, static_cast(160 * alpha)), 5.0f, 0, 1.5f); + + // Quest title (gold, small) + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 5.0f), + IM_COL32(220, 180, 50, fgA), toast.questTitle.c_str()); + + // Progress bar + text: "ObjectiveName X / Y" + float barY = ty + 21.0f; + float barX0 = toastX + 8.0f; + float barX1 = toastX + TOAST_W - 8.0f; + float barH = 8.0f; + float pct = (toast.required > 0) + ? std::min(1.0f, static_cast(toast.current) / static_cast(toast.required)) + : 1.0f; + // Bar background + bgDL->AddRectFilled(ImVec2(barX0, barY), ImVec2(barX1, barY + barH), + IM_COL32(50, 40, 10, static_cast(180 * alpha)), 3.0f); + // Bar fill — green when complete, amber otherwise + ImU32 barCol = (pct >= 1.0f) ? IM_COL32(60, 220, 80, fgA) : IM_COL32(200, 160, 30, fgA); + bgDL->AddRectFilled(ImVec2(barX0, barY), + ImVec2(barX0 + (barX1 - barX0) * pct, barY + barH), + barCol, 3.0f); + + // Objective name + count + char progBuf[48]; + if (!toast.objectiveName.empty()) + snprintf(progBuf, sizeof(progBuf), "%.22s: %u/%u", + toast.objectiveName.c_str(), toast.current, toast.required); + else + snprintf(progBuf, sizeof(progBuf), "%u/%u", toast.current, toast.required); + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 32.0f), + IM_COL32(220, 220, 200, static_cast(210 * alpha)), progBuf); + } +} + // --------------------------------------------------------------------------- // Whisper toast notifications — brief overlay when a player whispers you // --------------------------------------------------------------------------- From 3f340ca235e84313dc22e630627dec83efe83174 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 15:59:30 -0700 Subject: [PATCH 56/82] feat: highlight quest kill objective mobs as gold dots on the minimap Quest kill objective NPCs are now rendered as larger gold dots (3.5px) with a dark outline on the minimap, distinct from standard hostile (red) and friendly (white) dots. Only shows mobs for incomplete objectives in tracked quests (or all active quests if none are tracked). Hovering the dot shows a tooltip with the unit name and "(quest)" annotation. --- src/ui/game_screen.cpp | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 112665e7..4dcb6260 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15082,8 +15082,27 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220)); } + // Build set of NPC entries that are incomplete kill objectives for tracked quests. + // Used both for minimap dot highlighting and tooltip annotation. + std::unordered_set minimapQuestEntries; + { + const auto& ql = gameHandler.getQuestLog(); + const auto& tq = gameHandler.getTrackedQuestIds(); + for (const auto& q : ql) { + if (q.complete || q.questId == 0) continue; + if (!tq.empty() && !tq.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId <= 0 || obj.required == 0) continue; + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); + } + } + } + // Optional base nearby NPC dots (independent of quest status packets). if (minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; @@ -15094,8 +15113,21 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(npcRender, sx, sy)) continue; - ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); - drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + bool isQuestTarget = minimapQuestEntries.count(unit->getEntry()) != 0; + if (isQuestTarget) { + // Quest kill objective: larger gold dot with dark outline + drawList->AddCircleFilled(ImVec2(sx, sy), 3.5f, IM_COL32(255, 210, 30, 240)); + drawList->AddCircle(ImVec2(sx, sy), 3.5f, IM_COL32(80, 50, 0, 180), 0, 1.0f); + // Tooltip on hover showing unit name + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const std::string& nm = unit->getName(); + if (!nm.empty()) ImGui::SetTooltip("%s (quest)", nm.c_str()); + } + } else { + ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); + drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + } } } From b52e9c29c6e5aa9479a59393b91fbfe10fb0def2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:05:34 -0700 Subject: [PATCH 57/82] feat: highlight quest GO objectives as cyan triangles on the minimap Quest game-object objectives (negative npcOrGoId entries, e.g. gather 5 crystals) now render as larger bright-cyan triangles distinct from the standard amber GO markers. Tooltip appends "(quest)" to the name. Also refactors the minimap quest-entry build to track both NPC and GO kill-objective entries from the tracked quest log. --- src/ui/game_screen.cpp | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4dcb6260..161081fe 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15082,9 +15082,11 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220)); } - // Build set of NPC entries that are incomplete kill objectives for tracked quests. - // Used both for minimap dot highlighting and tooltip annotation. + // Build sets of entries that are incomplete objectives for tracked quests. + // minimapQuestEntries: NPC creature entries (npcOrGoId > 0) + // minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value) std::unordered_set minimapQuestEntries; + std::unordered_set minimapQuestGoEntries; { const auto& ql = gameHandler.getQuestLog(); const auto& tq = gameHandler.getTrackedQuestIds(); @@ -15092,10 +15094,17 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { if (q.complete || q.questId == 0) continue; if (!tq.empty() && !tq.count(q.questId)) continue; for (const auto& obj : q.killObjectives) { - if (obj.npcOrGoId <= 0 || obj.required == 0) continue; - auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); - if (it == q.killCounts.end() || it->second.first < it->second.second) - minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); + if (obj.required == 0) continue; + if (obj.npcOrGoId > 0) { + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); + } else if (obj.npcOrGoId < 0) { + uint32_t goEntry = static_cast(-obj.npcOrGoId); + auto it = q.killCounts.find(goEntry); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestGoEntries.insert(goEntry); + } } } } @@ -15218,18 +15227,27 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(goRender, sx, sy)) continue; - // Small upward triangle in gold/amber for interactable objects - const float ts = 3.5f; + // Triangle size and color: bright cyan for quest objectives, amber for others + bool isQuestGO = minimapQuestGoEntries.count(go->getEntry()) != 0; + const float ts = isQuestGO ? 4.5f : 3.5f; ImVec2 goTip (sx, sy - ts); ImVec2 goLeft (sx - ts, sy + ts * 0.6f); ImVec2 goRight(sx + ts, sy + ts * 0.6f); - drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); - drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); + if (isQuestGO) { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(50, 230, 255, 240)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(0, 60, 80, 200), 1.5f); + } else { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); + } // Tooltip on hover float mdx = mouse.x - sx, mdy = mouse.y - sy; if (mdx * mdx + mdy * mdy < 64.0f) { - ImGui::SetTooltip("%s", goInfo->name.c_str()); + if (isQuestGO) + ImGui::SetTooltip("%s (quest)", goInfo->name.c_str()); + else + ImGui::SetTooltip("%s", goInfo->name.c_str()); } } } From 59fc7cebaf2be8c5d4690a61c7e88d739b502b23 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:12:21 -0700 Subject: [PATCH 58/82] feat: show toast when a nearby player levels up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server sends a level-up update for a non-self player entity, fire the existing OtherPlayerLevelUpCallback which was previously unregistered in the UI layer. GameScreen now: - Registers the callback once and stores {guid, level} entries - Lazily resolves the player name from the name cache at render time - Renders gold-bordered toasts bottom-centre with a ★ icon and fade/slide animation ("Thrall is now level 60!"), coalescing duplicates - Prunes entries after 4 s with a 1 s fade-out --- include/ui/game_screen.hpp | 12 +++++ src/ui/game_screen.cpp | 98 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 29afe02b..9fc68363 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -551,6 +551,18 @@ private: bool questProgressCallbackSet_ = false; void renderQuestProgressToasts(); + // Nearby player level-up toast (" is now level X!") + struct PlayerLevelUpToastEntry { + uint64_t guid = 0; + std::string playerName; // resolved lazily at render time + uint32_t newLevel = 0; + float age = 0.0f; + }; + static constexpr float PLAYER_LEVELUP_TOAST_DURATION = 4.0f; + std::vector playerLevelUpToasts_; + bool otherPlayerLevelUpCallbackSet_ = false; + void renderPlayerLevelUpToasts(game::GameHandler& gameHandler); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 161081fe..61131666 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -330,6 +330,24 @@ void GameScreen::render(game::GameHandler& gameHandler) { questProgressCallbackSet_ = true; } + // Set up other-player level-up toast callback (once) + if (!otherPlayerLevelUpCallbackSet_) { + gameHandler.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { + // Coalesce: update existing toast for same player + for (auto& t : playerLevelUpToasts_) { + if (t.guid == guid) { + t.newLevel = newLevel; + t.age = 0.0f; + return; + } + } + if (playerLevelUpToasts_.size() >= 3) + playerLevelUpToasts_.erase(playerLevelUpToasts_.begin()); + playerLevelUpToasts_.push_back({guid, "", newLevel, 0.0f}); + }); + otherPlayerLevelUpCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -661,6 +679,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDiscoveryToast(); renderWhisperToasts(); renderQuestProgressToasts(); + renderPlayerLevelUpToasts(gameHandler); renderZoneText(); // World map (M key toggle handled inside) @@ -18195,6 +18214,85 @@ void GameScreen::renderQuestProgressToasts() { } } +// --------------------------------------------------------------------------- +// Nearby player level-up toasts — shown at screen bottom-centre +// --------------------------------------------------------------------------- + +void GameScreen::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) { + if (playerLevelUpToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : playerLevelUpToasts_) { + t.age += dt; + // Lazy name resolution — fill in once the name cache has it + if (t.playerName.empty() && t.guid != 0) { + t.playerName = gameHandler.lookupName(t.guid); + } + } + playerLevelUpToasts_.erase( + std::remove_if(playerLevelUpToasts_.begin(), playerLevelUpToasts_.end(), + [](const PlayerLevelUpToastEntry& t) { + return t.age >= PLAYER_LEVELUP_TOAST_DURATION; + }), + playerLevelUpToasts_.end()); + if (playerLevelUpToasts_.empty()) return; + + 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; + + // Stack toasts at screen bottom-centre, above action bars + constexpr float TOAST_W = 230.0f; + constexpr float TOAST_H = 38.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = (screenW - TOAST_W) * 0.5f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(playerLevelUpToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = playerLevelUpToasts_[i]; + + float remaining = PLAYER_LEVELUP_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Subtle pop-up from below during first 0.2s + float slideY = (toast.age < 0.2f) ? (TOAST_H * (1.0f - toast.age / 0.2f)) : 0.0f; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP) + slideY; + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark gold tint + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(30, 22, 5, bgA), 5.0f); + // Gold border with glow at peak + float glowStr = (toast.age < 0.5f) ? (1.0f - toast.age / 0.5f) : 0.0f; + uint8_t borderA = static_cast((160 + 80 * glowStr) * alpha); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(255, 210, 50, borderA), 5.0f, 0, 1.5f + glowStr * 1.5f); + + // Star ★ icon on left + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 10.0f), + IM_COL32(255, 220, 60, fgA), "\xe2\x98\x85"); // UTF-8 ★ + + // " is now level X!" text + const char* displayName = toast.playerName.empty() ? "A player" : toast.playerName.c_str(); + char buf[64]; + snprintf(buf, sizeof(buf), "%.18s is now level %u!", displayName, toast.newLevel); + bgDL->AddText(ImVec2(toastX + 26.0f, ty + 11.0f), + IM_COL32(255, 230, 100, fgA), buf); + } +} + // --------------------------------------------------------------------------- // Whisper toast notifications — brief overlay when a player whispers you // --------------------------------------------------------------------------- From 129fa84fe351fd87d9928f3ba5e06e17a36591c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:19:25 -0700 Subject: [PATCH 59/82] feat: add PvP honor credit toast on honorable kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_PVP_CREDIT previously only wrote a system chat message. Now it also fires a new PvpHonorCallback, which game_screen.cpp uses to push a compact dark-red toast at the top-right of the screen showing "⚔ +N Honor" with a 3.5 s lifetime and smooth fade in/out. --- include/game/game_handler.hpp | 7 ++++ include/ui/game_screen.hpp | 11 +++++ src/game/game_handler.cpp | 3 ++ src/ui/game_screen.cpp | 76 +++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 047220b5..501a0f73 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1490,6 +1490,10 @@ public: using RepChangeCallback = std::function; void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + // PvP honor credit callback (honorable kill or BG reward) + using PvpHonorCallback = std::function; + void setPvpHonorCallback(PvpHonorCallback cb) { pvpHonorCallback_ = std::move(cb); } + // Quest turn-in completion callback using QuestCompleteCallback = std::function; void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); } @@ -2831,6 +2835,9 @@ private: RepChangeCallback repChangeCallback_; uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction + // ---- PvP honor credit callback ---- + PvpHonorCallback pvpHonorCallback_; + // ---- Quest completion callback ---- QuestCompleteCallback questCompleteCallback_; }; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 9fc68363..77fc01d8 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -563,6 +563,17 @@ private: bool otherPlayerLevelUpCallbackSet_ = false; void renderPlayerLevelUpToasts(game::GameHandler& gameHandler); + // PvP honor credit toast ("+N Honor" shown when an honorable kill is credited) + struct PvpHonorToastEntry { + uint32_t honor = 0; + uint32_t victimRank = 0; // 0 = unranked / not available + float age = 0.0f; + }; + static constexpr float PVP_HONOR_TOAST_DURATION = 3.5f; + std::vector pvpHonorToasts_; + bool pvpHonorCallbackSet_ = false; + void renderPvpHonorToasts(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8f433ebd..e7911baf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1882,6 +1882,9 @@ void GameHandler::handlePacket(network::Packet& packet) { std::dec, " rank=", rank); std::string msg = "You gain " + std::to_string(honor) + " honor points."; addSystemChatMessage(msg); + if (pvpHonorCallback_) { + pvpHonorCallback_(honor, victimGuid, rank); + } } break; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 61131666..d7f9d772 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -348,6 +348,17 @@ void GameScreen::render(game::GameHandler& gameHandler) { otherPlayerLevelUpCallbackSet_ = true; } + // Set up PvP honor credit toast callback (once) + if (!pvpHonorCallbackSet_) { + gameHandler.setPvpHonorCallback([this](uint32_t honor, uint64_t /*victimGuid*/, uint32_t rank) { + if (honor == 0) return; + pvpHonorToasts_.push_back({honor, rank, 0.0f}); + if (pvpHonorToasts_.size() > 4) + pvpHonorToasts_.erase(pvpHonorToasts_.begin()); + }); + pvpHonorCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -680,6 +691,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWhisperToasts(); renderQuestProgressToasts(); renderPlayerLevelUpToasts(gameHandler); + renderPvpHonorToasts(); renderZoneText(); // World map (M key toggle handled inside) @@ -18214,6 +18226,70 @@ void GameScreen::renderQuestProgressToasts() { } } +// --------------------------------------------------------------------------- +// PvP honor credit toasts — shown at screen top-right on honorable kill +// --------------------------------------------------------------------------- + +void GameScreen::renderPvpHonorToasts() { + if (pvpHonorToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : pvpHonorToasts_) t.age += dt; + pvpHonorToasts_.erase( + std::remove_if(pvpHonorToasts_.begin(), pvpHonorToasts_.end(), + [](const PvpHonorToastEntry& t) { return t.age >= PVP_HONOR_TOAST_DURATION; }), + pvpHonorToasts_.end()); + if (pvpHonorToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + + // Stack toasts at top-right, below any minimap area + constexpr float TOAST_W = 180.0f; + constexpr float TOAST_H = 30.0f; + constexpr float TOAST_GAP = 3.0f; + constexpr float TOAST_TOP = 10.0f; + float toastX = screenW - TOAST_W - 10.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(pvpHonorToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = pvpHonorToasts_[i]; + + float remaining = PVP_HONOR_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.8f) + alpha = remaining / 0.8f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = TOAST_TOP + i * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(190 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark red (PvP theme) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(28, 5, 5, bgA), 4.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 50, 50, static_cast(160 * alpha)), 4.0f, 0, 1.2f); + + // Sword ⚔ icon (U+2694, UTF-8: e2 9a 94) + bgDL->AddText(ImVec2(toastX + 7.0f, ty + 7.0f), + IM_COL32(220, 80, 80, fgA), "\xe2\x9a\x94"); + + // "+N Honor" text in gold + char buf[40]; + snprintf(buf, sizeof(buf), "+%u Honor", toast.honor); + bgDL->AddText(ImVec2(toastX + 24.0f, ty + 8.0f), + IM_COL32(255, 210, 50, fgA), buf); + } +} + // --------------------------------------------------------------------------- // Nearby player level-up toasts — shown at screen bottom-centre // --------------------------------------------------------------------------- From 42d66bc876ab7e69e1e71f1d60b195ef0bf9aa81 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:24:11 -0700 Subject: [PATCH 60/82] feat: show quality-coloured loot toast when items are received SMSG_ITEM_PUSH_RESULT now fires a new ItemLootCallback that game_screen.cpp uses to push a compact slide-in toast at the bottom-left of the screen. Each toast: - Shows a quality-tinted left accent bar (grey/white/green/blue/ purple/orange matching WoW quality colours) - Displays "Loot: " with the name in quality colour - Appends " x" for stacked pickups - Coalesces repeated pickups of the same item (adds count, resets timer) - Stacks up to 5 entries, 3 s lifetime with 0.15 s slide-in and 0.7 s fade-out --- include/game/game_handler.hpp | 7 +++ include/ui/game_screen.hpp | 13 +++++ src/game/game_handler.cpp | 5 ++ src/ui/game_screen.cpp | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 501a0f73..9c1015a7 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1494,6 +1494,10 @@ public: using PvpHonorCallback = std::function; void setPvpHonorCallback(PvpHonorCallback cb) { pvpHonorCallback_ = std::move(cb); } + // Item looted / received callback (SMSG_ITEM_PUSH_RESULT when showInChat is set) + using ItemLootCallback = std::function; + void setItemLootCallback(ItemLootCallback cb) { itemLootCallback_ = std::move(cb); } + // Quest turn-in completion callback using QuestCompleteCallback = std::function; void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); } @@ -2838,6 +2842,9 @@ private: // ---- PvP honor credit callback ---- PvpHonorCallback pvpHonorCallback_; + // ---- Item loot callback ---- + ItemLootCallback itemLootCallback_; + // ---- Quest completion callback ---- QuestCompleteCallback questCompleteCallback_; }; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 77fc01d8..7fe1b72e 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -574,6 +574,19 @@ private: bool pvpHonorCallbackSet_ = false; void renderPvpHonorToasts(); + // Item loot toast — quality-coloured popup when an item is received + struct ItemLootToastEntry { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t quality = 1; // 0=grey,1=white,2=green,3=blue,4=purple,5=orange + std::string name; + float age = 0.0f; + }; + static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f; + std::vector itemLootToasts_; + bool itemLootCallbackSet_ = false; + void renderItemLootToasts(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e7911baf..f70a3c39 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1701,12 +1701,17 @@ void GameHandler::handlePacket(network::Packet& packet) { queryItemInfo(itemId, 0); if (showInChat) { std::string itemName = "item #" + std::to_string(itemId); + uint32_t quality = 1; // white default if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemName = info->name; + quality = info->quality; } std::string msg = "Received: " + itemName; if (count > 1) msg += " x" + std::to_string(count); addSystemChatMessage(msg); + if (itemLootCallback_) { + itemLootCallback_(itemId, count, quality, itemName); + } } LOG_INFO("Item push: itemId=", itemId, " count=", count, " showInChat=", static_cast(showInChat)); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d7f9d772..4f78febf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -359,6 +359,25 @@ void GameScreen::render(game::GameHandler& gameHandler) { pvpHonorCallbackSet_ = true; } + // Set up item loot toast callback (once) + if (!itemLootCallbackSet_) { + gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count, + uint32_t quality, const std::string& name) { + // Coalesce: if same item already in queue, bump count and reset age + for (auto& t : itemLootToasts_) { + if (t.itemId == itemId) { + t.count += count; + t.age = 0.0f; + return; + } + } + if (itemLootToasts_.size() >= 5) + itemLootToasts_.erase(itemLootToasts_.begin()); + itemLootToasts_.push_back({itemId, count, quality, name, 0.0f}); + }); + itemLootCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -692,6 +711,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestProgressToasts(); renderPlayerLevelUpToasts(gameHandler); renderPvpHonorToasts(); + renderItemLootToasts(); renderZoneText(); // World map (M key toggle handled inside) @@ -18226,6 +18246,93 @@ void GameScreen::renderQuestProgressToasts() { } } +// --------------------------------------------------------------------------- +// Item loot toasts — quality-coloured strip at bottom-left when item received +// --------------------------------------------------------------------------- + +void GameScreen::renderItemLootToasts() { + if (itemLootToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : itemLootToasts_) t.age += dt; + itemLootToasts_.erase( + std::remove_if(itemLootToasts_.begin(), itemLootToasts_.end(), + [](const ItemLootToastEntry& t) { return t.age >= ITEM_LOOT_TOAST_DURATION; }), + itemLootToasts_.end()); + if (itemLootToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Quality colours (matching WoW convention) + static const ImU32 kQualityColors[] = { + IM_COL32(157, 157, 157, 255), // 0 grey (poor) + IM_COL32(255, 255, 255, 255), // 1 white (common) + IM_COL32( 30, 255, 30, 255), // 2 green (uncommon) + IM_COL32( 0, 112, 221, 255), // 3 blue (rare) + IM_COL32(163, 53, 238, 255), // 4 purple (epic) + IM_COL32(255, 128, 0, 255), // 5 orange (legendary) + }; + + // Stack at bottom-left above action bars; each item is 24 px tall + constexpr float TOAST_W = 260.0f; + constexpr float TOAST_H = 24.0f; + constexpr float TOAST_GAP = 2.0f; + constexpr float TOAST_X = 14.0f; + float baseY = screenH * 0.68f; // slightly above the whisper toasts + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(itemLootToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = itemLootToasts_[i]; + + float remaining = ITEM_LOOT_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.7f) + alpha = remaining / 0.7f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left + float slideX = (toast.age < 0.15f) ? (TOAST_W * (1.0f - toast.age / 0.15f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(180 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: very dark with quality-tinted left border accent + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(12, 12, 12, bgA), 3.0f); + + // Quality colour accent bar on left edge (3px wide) + ImU32 qualCol = kQualityColors[std::min(static_cast(5u), toast.quality)]; + ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast(fgA) << 24u); + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f); + + // "Loot:" label in dim white + bgDL->AddText(ImVec2(tx + 7.0f, ty + 5.0f), + IM_COL32(160, 160, 160, static_cast(200 * alpha)), "Loot:"); + + // Item name in quality colour + std::string displayName = toast.name.empty() ? ("Item #" + std::to_string(toast.itemId)) : toast.name; + if (displayName.size() > 26) { displayName.resize(23); displayName += "..."; } + bgDL->AddText(ImVec2(tx + 42.0f, ty + 5.0f), qualColA, displayName.c_str()); + + // Count (if > 1) + if (toast.count > 1) { + char countBuf[12]; + snprintf(countBuf, sizeof(countBuf), "x%u", toast.count); + bgDL->AddText(ImVec2(tx + TOAST_W - 34.0f, ty + 5.0f), + IM_COL32(200, 200, 200, static_cast(200 * alpha)), countBuf); + } + } +} + // --------------------------------------------------------------------------- // PvP honor credit toasts — shown at screen top-right on honorable kill // --------------------------------------------------------------------------- From 819a690c33381e3769cda9ddcb615e129f8d31f3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:33:08 -0700 Subject: [PATCH 61/82] =?UTF-8?q?feat:=20show=20resurrection=20flash=20ban?= =?UTF-8?q?ner=20when=20player=20transitions=20ghost=E2=86=92alive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- include/ui/game_screen.hpp | 6 +++ src/ui/game_screen.cpp | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 7fe1b72e..58984256 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -587,6 +587,12 @@ private: bool itemLootCallbackSet_ = false; void renderItemLootToasts(); + // Resurrection flash: brief "You have been resurrected!" overlay on ghost→alive transition + float resurrectFlashTimer_ = 0.0f; + static constexpr float kResurrectFlashDuration = 3.0f; + bool ghostStateCallbackSet_ = false; + void renderResurrectFlash(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4f78febf..7f14109d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -378,6 +378,17 @@ void GameScreen::render(game::GameHandler& gameHandler) { itemLootCallbackSet_ = true; } + // Set up ghost-state callback to flash "You have been resurrected!" on revival (once) + if (!ghostStateCallbackSet_) { + gameHandler.setGhostStateCallback([this](bool isGhost) { + if (!isGhost) { + // Transitioning ghost→alive: trigger the resurrection flash + resurrectFlashTimer_ = kResurrectFlashDuration; + } + }); + ghostStateCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -712,6 +723,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPlayerLevelUpToasts(gameHandler); renderPvpHonorToasts(); renderItemLootToasts(); + renderResurrectFlash(); renderZoneText(); // World map (M key toggle handled inside) @@ -18476,6 +18488,85 @@ void GameScreen::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) { } } +// --------------------------------------------------------------------------- +// Resurrection flash — brief screen brightening + "You have been resurrected!" +// banner when the player transitions from ghost back to alive. +// --------------------------------------------------------------------------- + +void GameScreen::renderResurrectFlash() { + if (resurrectFlashTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + resurrectFlashTimer_ -= dt; + if (resurrectFlashTimer_ <= 0.0f) { + resurrectFlashTimer_ = 0.0f; + return; + } + + 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; + + // Normalised age in [0, 1] (0 = just fired, 1 = fully elapsed) + float t = 1.0f - resurrectFlashTimer_ / kResurrectFlashDuration; + + // Alpha envelope: fast fade-in (first 0.15s), hold, then fade-out (last 0.8s) + float alpha; + const float fadeIn = 0.15f / kResurrectFlashDuration; // ~5% of lifetime + const float fadeOut = 0.8f / kResurrectFlashDuration; // ~27% of lifetime + if (t < fadeIn) + alpha = t / fadeIn; + else if (t < 1.0f - fadeOut) + alpha = 1.0f; + else + alpha = (1.0f - t) / fadeOut; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + ImDrawList* bg = ImGui::GetBackgroundDrawList(); + + // Soft golden/white vignette — brightening instead of darkening + uint8_t vigA = static_cast(50 * alpha); + bg->AddRectFilled(ImVec2(0, 0), ImVec2(screenW, screenH), + IM_COL32(200, 230, 255, vigA)); + + // Centered banner panel + constexpr float PANEL_W = 360.0f; + constexpr float PANEL_H = 52.0f; + float px = (screenW - PANEL_W) * 0.5f; + float py = screenH * 0.34f; + + uint8_t bgA = static_cast(210 * alpha); + uint8_t borderA = static_cast(255 * alpha); + uint8_t textA = static_cast(255 * alpha); + + // Background: deep blue-black + bg->AddRectFilled(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(10, 18, 40, bgA), 8.0f); + + // Border glow: bright holy gold + bg->AddRect(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(200, 230, 100, borderA), 8.0f, 0, 2.0f); + // Inner halo line + bg->AddRect(ImVec2(px + 3.0f, py + 3.0f), ImVec2(px + PANEL_W - 3.0f, py + PANEL_H - 3.0f), + IM_COL32(255, 255, 180, static_cast(80 * alpha)), 6.0f, 0, 1.0f); + + // "✦ You have been resurrected! ✦" centered + // UTF-8 heavy four-pointed star U+2726: \xe2\x9c\xa6 + const char* banner = "\xe2\x9c\xa6 You have been resurrected! \xe2\x9c\xa6"; + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, banner); + float tx = px + (PANEL_W - textSz.x) * 0.5f; + float ty = py + (PANEL_H - textSz.y) * 0.5f; + + // Drop shadow + bg->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, static_cast(180 * alpha)), banner); + // Main text in warm gold + bg->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 240, 120, textA), banner); +} + // --------------------------------------------------------------------------- // Whisper toast notifications — brief overlay when a player whispers you // --------------------------------------------------------------------------- From 6e95709b689a2062c8b26b1d500834595bb2145d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:43:48 -0700 Subject: [PATCH 62/82] feat: add FXAA post-process anti-aliasing, combinable with MSAA --- assets/shaders/fxaa.frag.glsl | 132 ++++++++++++++ include/rendering/renderer.hpp | 29 ++++ include/ui/game_screen.hpp | 2 + src/rendering/renderer.cpp | 308 ++++++++++++++++++++++++++++++++- src/ui/game_screen.cpp | 26 +++ 5 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 assets/shaders/fxaa.frag.glsl diff --git a/assets/shaders/fxaa.frag.glsl b/assets/shaders/fxaa.frag.glsl new file mode 100644 index 00000000..df35aaa0 --- /dev/null +++ b/assets/shaders/fxaa.frag.glsl @@ -0,0 +1,132 @@ +#version 450 + +// FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass. +// Reads the resolved scene color and outputs a smoothed result. +// Push constant: rcpFrame = vec2(1/width, 1/height). + +layout(set = 0, binding = 0) uniform sampler2D uScene; + +layout(location = 0) in vec2 TexCoord; +layout(location = 0) out vec4 outColor; + +layout(push_constant) uniform PC { + vec2 rcpFrame; +} pc; + +// Quality tuning +#define FXAA_EDGE_THRESHOLD (1.0/8.0) // minimum edge contrast to process +#define FXAA_EDGE_THRESHOLD_MIN (1.0/24.0) // ignore very dark regions +#define FXAA_SEARCH_STEPS 12 +#define FXAA_SEARCH_THRESHOLD (1.0/4.0) +#define FXAA_SUBPIX 0.75 +#define FXAA_SUBPIX_TRIM (1.0/4.0) +#define FXAA_SUBPIX_TRIM_SCALE (1.0/(1.0 - FXAA_SUBPIX_TRIM)) +#define FXAA_SUBPIX_CAP (3.0/4.0) + +float luma(vec3 c) { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + +void main() { + vec2 uv = TexCoord; + vec2 rcp = pc.rcpFrame; + + // --- Centre and cardinal neighbours --- + vec3 rgbM = texture(uScene, uv).rgb; + vec3 rgbN = texture(uScene, uv + vec2( 0.0, -1.0) * rcp).rgb; + vec3 rgbS = texture(uScene, uv + vec2( 0.0, 1.0) * rcp).rgb; + vec3 rgbE = texture(uScene, uv + vec2( 1.0, 0.0) * rcp).rgb; + vec3 rgbW = texture(uScene, uv + vec2(-1.0, 0.0) * rcp).rgb; + + float lumaN = luma(rgbN); + float lumaS = luma(rgbS); + float lumaE = luma(rgbE); + float lumaW = luma(rgbW); + float lumaM = luma(rgbM); + + float lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaE, lumaW))); + float lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaE, lumaW))); + float range = lumaMax - lumaMin; + + // Early exit on smooth regions + if (range < max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD)) { + outColor = vec4(rgbM, 1.0); + return; + } + + // --- Diagonal neighbours --- + vec3 rgbNW = texture(uScene, uv + vec2(-1.0, -1.0) * rcp).rgb; + vec3 rgbNE = texture(uScene, uv + vec2( 1.0, -1.0) * rcp).rgb; + vec3 rgbSW = texture(uScene, uv + vec2(-1.0, 1.0) * rcp).rgb; + vec3 rgbSE = texture(uScene, uv + vec2( 1.0, 1.0) * rcp).rgb; + + float lumaNW = luma(rgbNW); + float lumaNE = luma(rgbNE); + float lumaSW = luma(rgbSW); + float lumaSE = luma(rgbSE); + + // --- Sub-pixel blend factor --- + float lumaL = (lumaN + lumaS + lumaE + lumaW) * 0.25; + float rangeL = abs(lumaL - lumaM); + float blendL = max(0.0, (rangeL / range) - FXAA_SUBPIX_TRIM) * FXAA_SUBPIX_TRIM_SCALE; + blendL = min(FXAA_SUBPIX_CAP, blendL) * FXAA_SUBPIX; + + // --- Edge orientation (horizontal vs. vertical) --- + float edgeHorz = + abs(-2.0*lumaW + lumaNW + lumaSW) + + 2.0*abs(-2.0*lumaM + lumaN + lumaS) + + abs(-2.0*lumaE + lumaNE + lumaSE); + float edgeVert = + abs(-2.0*lumaS + lumaSW + lumaSE) + + 2.0*abs(-2.0*lumaM + lumaW + lumaE) + + abs(-2.0*lumaN + lumaNW + lumaNE); + + bool horzSpan = (edgeHorz >= edgeVert); + float lengthSign = horzSpan ? rcp.y : rcp.x; + + float luma1 = horzSpan ? lumaN : lumaW; + float luma2 = horzSpan ? lumaS : lumaE; + float grad1 = abs(luma1 - lumaM); + float grad2 = abs(luma2 - lumaM); + lengthSign = (grad1 >= grad2) ? -lengthSign : lengthSign; + + // --- Edge search --- + vec2 posB = uv; + vec2 offNP = horzSpan ? vec2(rcp.x, 0.0) : vec2(0.0, rcp.y); + if (!horzSpan) posB.x += lengthSign * 0.5; + if ( horzSpan) posB.y += lengthSign * 0.5; + + float lumaMLSS = lumaM - (luma1 + luma2) * 0.5; + float gradientScaled = max(grad1, grad2) * 0.25; + + vec2 posN = posB - offNP; + vec2 posP = posB + offNP; + bool done1 = false, done2 = false; + float lumaEnd1 = 0.0, lumaEnd2 = 0.0; + + for (int i = 0; i < FXAA_SEARCH_STEPS; ++i) { + if (!done1) lumaEnd1 = luma(texture(uScene, posN).rgb) - lumaMLSS; + if (!done2) lumaEnd2 = luma(texture(uScene, posP).rgb) - lumaMLSS; + done1 = done1 || (abs(lumaEnd1) >= gradientScaled * FXAA_SEARCH_THRESHOLD); + done2 = done2 || (abs(lumaEnd2) >= gradientScaled * FXAA_SEARCH_THRESHOLD); + if (done1 && done2) break; + if (!done1) posN -= offNP; + if (!done2) posP += offNP; + } + + float dstN = horzSpan ? (uv.x - posN.x) : (uv.y - posN.y); + float dstP = horzSpan ? (posP.x - uv.x) : (posP.y - uv.y); + bool dirN = (dstN < dstP); + float lumaEndFinal = dirN ? lumaEnd1 : lumaEnd2; + + float spanLength = dstN + dstP; + float pixelOffset = (dirN ? dstN : dstP) / spanLength; + bool goodSpan = ((lumaEndFinal < 0.0) != (lumaMLSS < 0.0)); + float pixelOffsetFinal = max(goodSpan ? pixelOffset : 0.0, blendL); + + vec2 finalUV = uv; + if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign; + if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign; + + outColor = vec4(texture(uScene, finalUV).rgb, 1.0); +} diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 93bbed03..07d8091f 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -271,6 +271,10 @@ public: float getShadowDistance() const { return shadowDistance_; } void setMsaaSamples(VkSampleCountFlagBits samples); + // FXAA post-process anti-aliasing (combinable with MSAA) + void setFXAAEnabled(bool enabled); + bool isFXAAEnabled() const { return fxaa_.enabled; } + // FSR (FidelityFX Super Resolution) upscaling void setFSREnabled(bool enabled); bool isFSREnabled() const { return fsr_.enabled; } @@ -398,6 +402,31 @@ private: void destroyFSRResources(); void renderFSRUpscale(); + // FXAA post-process state + struct FXAAState { + bool enabled = false; + bool needsRecreate = false; + + // Off-screen scene target (same resolution as swapchain — no scaling) + AllocatedImage sceneColor{}; // 1x resolved color target + AllocatedImage sceneDepth{}; // Depth (matches MSAA sample count) + AllocatedImage sceneMsaaColor{}; // MSAA color target (when MSAA > 1x) + AllocatedImage sceneDepthResolve{}; // Depth resolve (MSAA + depth resolve) + VkFramebuffer sceneFramebuffer = VK_NULL_HANDLE; + VkSampler sceneSampler = VK_NULL_HANDLE; + + // FXAA fullscreen pipeline + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout descSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + VkDescriptorSet descSet = VK_NULL_HANDLE; + }; + FXAAState fxaa_; + bool initFXAAResources(); + void destroyFXAAResources(); + void renderFXAAPass(); + // FSR 2.2 temporal upscaling state struct FSR2State { bool enabled = false; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 58984256..a6dd4920 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -204,6 +204,7 @@ private: float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x + bool pendingFXAA = false; // FXAA post-process (combinable with MSAA) bool pendingNormalMapping = true; // on by default float pendingNormalMapStrength = 0.8f; // 0.0-2.0 bool pendingPOM = true; // on by default @@ -238,6 +239,7 @@ private: bool minimapSettingsApplied_ = false; bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool fxaaSettingsApplied_ = false; // True once saved FXAA setting applied to renderer bool waterRefractionApplied_ = false; bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 7618a345..20e2d472 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -858,6 +858,7 @@ void Renderer::shutdown() { destroyFSRResources(); destroyFSR2Resources(); + destroyFXAAResources(); destroyPerFrameResources(); zoneManager.reset(); @@ -960,8 +961,9 @@ void Renderer::applyMsaaChange() { VkDevice device = vkCtx->getDevice(); if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } - if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() + if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() if (fsr2_.sceneFramebuffer) destroyFSR2Resources(); + if (fxaa_.sceneFramebuffer) destroyFXAAResources(); // Will be lazily recreated in beginFrame() // Reinitialize ImGui Vulkan backend with new MSAA sample count ImGui_ImplVulkan_Shutdown(); @@ -1017,6 +1019,19 @@ void Renderer::beginFrame() { } } + // FXAA resource management (disabled when FSR2 is active — FSR2 has its own AA) + if (fxaa_.needsRecreate && fxaa_.sceneFramebuffer) { + destroyFXAAResources(); + fxaa_.needsRecreate = false; + if (!fxaa_.enabled) LOG_INFO("FXAA: disabled"); + } + if (fxaa_.enabled && !fsr2_.enabled && !fsr_.enabled && !fxaa_.sceneFramebuffer) { + if (!initFXAAResources()) { + LOG_ERROR("FXAA: initialization failed, disabling"); + fxaa_.enabled = false; + } + } + // Handle swapchain recreation if needed if (vkCtx->isSwapchainDirty()) { vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); @@ -1033,6 +1048,11 @@ void Renderer::beginFrame() { destroyFSR2Resources(); initFSR2Resources(); } + // Recreate FXAA resources for new swapchain dimensions + if (fxaa_.enabled && !fsr2_.enabled && !fsr_.enabled) { + destroyFXAAResources(); + initFXAAResources(); + } } // Acquire swapchain image and begin command buffer @@ -1122,6 +1142,9 @@ void Renderer::beginFrame() { } else if (fsr_.enabled && fsr_.sceneFramebuffer) { rpInfo.framebuffer = fsr_.sceneFramebuffer; renderExtent = { fsr_.internalWidth, fsr_.internalHeight }; + } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { + rpInfo.framebuffer = fxaa_.sceneFramebuffer; + renderExtent = vkCtx->getSwapchainExtent(); // native resolution — no downscaling } else { rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; renderExtent = vkCtx->getSwapchainExtent(); @@ -1298,10 +1321,50 @@ void Renderer::endFrame() { // Draw FSR upscale fullscreen quad renderFSRUpscale(); + + } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { + // End the off-screen scene render pass + vkCmdEndRenderPass(currentCmd); + + // Transition resolved scene color: PRESENT_SRC_KHR → SHADER_READ_ONLY + transitionImageLayout(currentCmd, fxaa_.sceneColor.image, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + + // Begin swapchain render pass (1x — no MSAA on the output pass) + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = vkCtx->getImGuiRenderPass(); + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + // The swapchain render pass always has 2 attachments when MSAA is off; + // FXAA output goes to the non-MSAA swapchain directly. + VkClearValue fxaaClear[2]{}; + fxaaClear[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fxaaClear[1].depthStencil = {1.0f, 0}; + rpInfo.clearValueCount = 2; + rpInfo.pClearValues = fxaaClear; + + vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkExtent2D ext = vkCtx->getSwapchainExtent(); + VkViewport vp{}; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &vp); + VkRect2D sc{}; + sc.extent = ext; + vkCmdSetScissor(currentCmd, 0, 1, &sc); + + // Draw FXAA pass + renderFXAAPass(); } // ImGui rendering — must respect subpass contents mode - if (!fsr_.enabled && !fsr2_.enabled && parallelRecordingEnabled_) { + if (!fsr_.enabled && !fsr2_.enabled && !fxaa_.enabled && parallelRecordingEnabled_) { // Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS, // so ImGui must be recorded into a secondary command buffer. VkCommandBuffer imguiCmd = beginSecondary(SEC_IMGUI); @@ -4698,6 +4761,247 @@ void Renderer::setAmdFsr3FramegenEnabled(bool enabled) { // ========================= End FSR 2.2 ========================= +// ========================= FXAA Post-Process ========================= + +bool Renderer::initFXAAResources() { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D ext = vkCtx->getSwapchainExtent(); + VkSampleCountFlagBits msaa = vkCtx->getMsaaSamples(); + bool useMsaa = (msaa > VK_SAMPLE_COUNT_1_BIT); + bool useDepthResolve = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + + LOG_INFO("FXAA: initializing at ", ext.width, "x", ext.height, + " (MSAA=", static_cast(msaa), "x)"); + + VkFormat colorFmt = vkCtx->getSwapchainFormat(); + VkFormat depthFmt = vkCtx->getDepthFormat(); + + // sceneColor: 1x resolved color target — FXAA reads from here + fxaa_.sceneColor = createImage(device, alloc, ext.width, ext.height, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + if (!fxaa_.sceneColor.image) { + LOG_ERROR("FXAA: failed to create scene color image"); + return false; + } + + // sceneDepth: depth buffer at current MSAA sample count + fxaa_.sceneDepth = createImage(device, alloc, ext.width, ext.height, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaa); + if (!fxaa_.sceneDepth.image) { + LOG_ERROR("FXAA: failed to create scene depth image"); + destroyFXAAResources(); + return false; + } + + if (useMsaa) { + fxaa_.sceneMsaaColor = createImage(device, alloc, ext.width, ext.height, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, msaa); + if (!fxaa_.sceneMsaaColor.image) { + LOG_ERROR("FXAA: failed to create MSAA color image"); + destroyFXAAResources(); + return false; + } + if (useDepthResolve) { + fxaa_.sceneDepthResolve = createImage(device, alloc, ext.width, ext.height, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT); + if (!fxaa_.sceneDepthResolve.image) { + LOG_ERROR("FXAA: failed to create depth resolve image"); + destroyFXAAResources(); + return false; + } + } + } + + // Framebuffer — same attachment layout as main render pass + VkImageView fbAttachments[4]{}; + uint32_t fbCount; + if (useMsaa) { + fbAttachments[0] = fxaa_.sceneMsaaColor.imageView; + fbAttachments[1] = fxaa_.sceneDepth.imageView; + fbAttachments[2] = fxaa_.sceneColor.imageView; // resolve target + fbCount = 3; + if (useDepthResolve) { + fbAttachments[3] = fxaa_.sceneDepthResolve.imageView; + fbCount = 4; + } + } else { + fbAttachments[0] = fxaa_.sceneColor.imageView; + fbAttachments[1] = fxaa_.sceneDepth.imageView; + fbCount = 2; + } + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = vkCtx->getImGuiRenderPass(); + fbInfo.attachmentCount = fbCount; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = ext.width; + fbInfo.height = ext.height; + fbInfo.layers = 1; + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &fxaa_.sceneFramebuffer) != VK_SUCCESS) { + LOG_ERROR("FXAA: failed to create scene framebuffer"); + destroyFXAAResources(); + return false; + } + + // Sampler + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + if (vkCreateSampler(device, &samplerInfo, nullptr, &fxaa_.sceneSampler) != VK_SUCCESS) { + LOG_ERROR("FXAA: failed to create sampler"); + destroyFXAAResources(); + return false; + } + + // Descriptor set layout: binding 0 = combined image sampler + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 1; + layoutInfo.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &fxaa_.descSetLayout); + + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = 1; + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = 1; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &fxaa_.descPool); + + VkDescriptorSetAllocateInfo dsAllocInfo{}; + dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + dsAllocInfo.descriptorPool = fxaa_.descPool; + dsAllocInfo.descriptorSetCount = 1; + dsAllocInfo.pSetLayouts = &fxaa_.descSetLayout; + vkAllocateDescriptorSets(device, &dsAllocInfo, &fxaa_.descSet); + + // Bind the resolved 1x sceneColor + VkDescriptorImageInfo imgInfo{}; + imgInfo.sampler = fxaa_.sceneSampler; + imgInfo.imageView = fxaa_.sceneColor.imageView; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fxaa_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + // Pipeline layout — push constant holds vec2 rcpFrame + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 8; // vec2 + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.setLayoutCount = 1; + plCI.pSetLayouts = &fxaa_.descSetLayout; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &fxaa_.pipelineLayout); + + // FXAA pipeline — fullscreen triangle into the swapchain render pass + // Uses VK_SAMPLE_COUNT_1_BIT: it always runs after MSAA resolve. + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/fxaa.frag.spv")) { + LOG_ERROR("FXAA: failed to load shaders"); + destroyFXAAResources(); + return false; + } + + fxaa_.pipeline = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(VK_SAMPLE_COUNT_1_BIT) // swapchain pass is always 1x + .setLayout(fxaa_.pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertMod.destroy(); + fragMod.destroy(); + + if (!fxaa_.pipeline) { + LOG_ERROR("FXAA: failed to create pipeline"); + destroyFXAAResources(); + return false; + } + + LOG_INFO("FXAA: initialized successfully"); + return true; +} + +void Renderer::destroyFXAAResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + vkDeviceWaitIdle(device); + + if (fxaa_.pipeline) { vkDestroyPipeline(device, fxaa_.pipeline, nullptr); fxaa_.pipeline = VK_NULL_HANDLE; } + if (fxaa_.pipelineLayout) { vkDestroyPipelineLayout(device, fxaa_.pipelineLayout, nullptr); fxaa_.pipelineLayout = VK_NULL_HANDLE; } + if (fxaa_.descPool) { vkDestroyDescriptorPool(device, fxaa_.descPool, nullptr); fxaa_.descPool = VK_NULL_HANDLE; fxaa_.descSet = VK_NULL_HANDLE; } + if (fxaa_.descSetLayout) { vkDestroyDescriptorSetLayout(device, fxaa_.descSetLayout, nullptr); fxaa_.descSetLayout = VK_NULL_HANDLE; } + if (fxaa_.sceneFramebuffer) { vkDestroyFramebuffer(device, fxaa_.sceneFramebuffer, nullptr); fxaa_.sceneFramebuffer = VK_NULL_HANDLE; } + if (fxaa_.sceneSampler) { vkDestroySampler(device, fxaa_.sceneSampler, nullptr); fxaa_.sceneSampler = VK_NULL_HANDLE; } + destroyImage(device, alloc, fxaa_.sceneDepthResolve); + destroyImage(device, alloc, fxaa_.sceneMsaaColor); + destroyImage(device, alloc, fxaa_.sceneDepth); + destroyImage(device, alloc, fxaa_.sceneColor); +} + +void Renderer::renderFXAAPass() { + if (!fxaa_.pipeline || currentCmd == VK_NULL_HANDLE) return; + VkExtent2D ext = vkCtx->getSwapchainExtent(); + + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipeline); + vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); + + // Push rcpFrame = vec2(1/width, 1/height) + float rcpFrame[2] = { + 1.0f / static_cast(ext.width), + 1.0f / static_cast(ext.height) + }; + vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame); + + vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle +} + +void Renderer::setFXAAEnabled(bool enabled) { + if (fxaa_.enabled == enabled) return; + fxaa_.enabled = enabled; + if (!enabled) { + fxaa_.needsRecreate = true; // defer destruction to next beginFrame() + } +} + +// ========================= End FXAA ========================= + void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { (void)world; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7f14109d..bf02b2b4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -512,6 +512,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { msaaSettingsApplied_ = true; } + // Apply saved FXAA setting once when renderer is available + if (!fxaaSettingsApplied_) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + renderer->setFXAAEnabled(pendingFXAA); + fxaaSettingsApplied_ = true; + } + } + // Apply saved water refraction setting once when renderer is available if (!waterRefractionApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -13969,6 +13978,21 @@ void GameScreen::renderSettingsWindow() { updateGraphicsPresetFromCurrentSettings(); saveSettings(); } + // FXAA — post-process, combinable with any MSAA level (disabled with FSR2) + if (fsr2Active) { + ImGui::BeginDisabled(); + bool fxaaOff = false; + ImGui::Checkbox("FXAA (disabled with FSR3)", &fxaaOff); + ImGui::EndDisabled(); + } else { + if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { + if (renderer) renderer->setFXAAEnabled(pendingFXAA); + updateGraphicsPresetFromCurrentSettings(); + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); + } } // FSR Upscaling { @@ -16474,6 +16498,7 @@ void GameScreen::saveSettings() { out << "shadow_distance=" << pendingShadowDistance << "\n"; out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; + out << "fxaa=" << (pendingFXAA ? 1 : 0) << "\n"; out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; @@ -16608,6 +16633,7 @@ void GameScreen::loadSettings() { else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "fxaa") pendingFXAA = (std::stoi(val) != 0); else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); else if (key == "pom") pendingPOM = (std::stoi(val) != 0); From 925d15713c619aed58e90a9bd72fe9ccccc4a6c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:47:42 -0700 Subject: [PATCH 63/82] feat: make quest tracker draggable with persistent position --- include/ui/game_screen.hpp | 2 ++ src/ui/game_screen.cpp | 40 ++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a6dd4920..bbb5ffa8 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -157,6 +157,8 @@ private: bool chatWindowLocked = true; ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; + ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default + bool questTrackerPosInit_ = false; bool showEscapeMenu = false; bool showEscapeSettingsNotice = false; bool showSettingsWindow = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bf02b2b4..68069c3d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16518,6 +16518,10 @@ void GameScreen::saveSettings() { out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; out << "fov=" << pendingFov << "\n"; + // Quest tracker position + out << "quest_tracker_x=" << questTrackerPos_.x << "\n"; + out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; + // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; out << "chat_timestamps=" << (chatShowTimestamps_ ? 1 : 0) << "\n"; @@ -16662,6 +16666,15 @@ void GameScreen::loadSettings() { if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); } } + // Quest tracker position + else if (key == "quest_tracker_x") { + questTrackerPos_.x = std::stof(val); + questTrackerPosInit_ = true; + } + else if (key == "quest_tracker_y") { + questTrackerPos_.y = std::stof(val); + questTrackerPosInit_ = true; + } // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); @@ -20018,16 +20031,20 @@ void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { ImVec2 display = ImGui::GetIO().DisplaySize; float screenW = display.x > 0.0f ? display.x : 1280.0f; + float screenH = display.y > 0.0f ? display.y : 720.0f; float trackerW = 220.0f; - float trackerX = screenW - trackerW - 12.0f; - float trackerY = 230.0f; // below minimap + + // Default position: top-right, below minimap + if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) { + questTrackerPos_ = ImVec2(screenW - trackerW - 12.0f, 230.0f); + questTrackerPosInit_ = true; + } ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoFocusOnAppearing; + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoFocusOnAppearing; - ImGui::SetNextWindowPos(ImVec2(trackerX, trackerY), ImGuiCond_Always); + ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(trackerW, 0.0f), ImGuiCond_Always); ImGui::SetNextWindowBgAlpha(0.5f); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); @@ -20087,6 +20104,17 @@ void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { ImGui::Dummy(ImVec2(0.0f, 2.0f)); } + + // Track drag — save new position when the window is moved + ImVec2 newPos = ImGui::GetWindowPos(); + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || + std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { + // Clamp to screen bounds + newPos.x = std::clamp(newPos.x, 0.0f, screenW - trackerW); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + questTrackerPos_ = newPos; + saveSettings(); + } } ImGui::End(); ImGui::PopStyleVar(2); From 806744c483187836afbfc87f0c166ab0356aadc8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 16:52:12 -0700 Subject: [PATCH 64/82] refactor: consolidate duplicate quest trackers; make primary tracker draggable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the redundant renderObjectiveTracker (simpler, fixed-position) and apply draggable position tracking to renderQuestObjectiveTracker (the primary tracker with context menus, item icons, and click-to-open-quest-log). - questTrackerPos_ / questTrackerPosInit_ now drive the primary tracker - Default position is top-right (below minimap at y=320) - Drag saves to settings.cfg immediately (quest_tracker_x/y keys) - Both trackers were rendering simultaneously — this eliminates the duplicate --- src/ui/game_screen.cpp | 141 +++++++---------------------------------- 1 file changed, 23 insertions(+), 118 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 68069c3d..5b880d51 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -712,7 +712,6 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderInspectWindow(gameHandler); renderThreatWindow(gameHandler); renderBgScoreboard(gameHandler); - renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -7792,14 +7791,19 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } if (toShow.empty()) return; - float x = screenW - TRACKER_W - RIGHT_MARGIN; - float y = 320.0f; // below minimap (210) + buff bar space (up to 3 rows ≈ 114px) + float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; - ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + // Default position: top-right, below minimap + buff bar space + if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) { + questTrackerPos_ = ImVec2(screenW - TRACKER_W - RIGHT_MARGIN, 320.0f); + questTrackerPosInit_ = true; + } + + ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); @@ -7938,6 +7942,16 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::Spacing(); } } + + // Capture position after drag + ImVec2 newPos = ImGui::GetWindowPos(); + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || + std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { + newPos.x = std::clamp(newPos.x, 0.0f, screenW - TRACKER_W); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + questTrackerPos_ = newPos; + saveSettings(); + } } ImGui::End(); @@ -20005,119 +20019,10 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { ImGui::End(); } -// ─── Quest Objective Tracker ────────────────────────────────────────────────── -void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { - if (gameHandler.getState() != game::WorldState::IN_WORLD) return; - - const auto& questLog = gameHandler.getQuestLog(); - const auto& tracked = gameHandler.getTrackedQuestIds(); - - // Collect quests to show: tracked ones first, then in-progress quests up to a max of 5 total. - std::vector toShow; - for (const auto& q : questLog) { - if (q.questId == 0) continue; - if (tracked.count(q.questId)) toShow.push_back(&q); - } - if (toShow.empty()) { - // No explicitly tracked quests — show up to 5 in-progress quests - for (const auto& q : questLog) { - if (q.questId == 0) continue; - if (!tracked.count(q.questId)) toShow.push_back(&q); - if (toShow.size() >= 5) break; - } - } - - if (toShow.empty()) return; - - ImVec2 display = ImGui::GetIO().DisplaySize; - float screenW = display.x > 0.0f ? display.x : 1280.0f; - float screenH = display.y > 0.0f ? display.y : 720.0f; - float trackerW = 220.0f; - - // Default position: top-right, below minimap - if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) { - questTrackerPos_ = ImVec2(screenW - trackerW - 12.0f, 230.0f); - questTrackerPosInit_ = true; - } - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoFocusOnAppearing; - - ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(trackerW, 0.0f), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.5f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); - - if (ImGui::Begin("##ObjectiveTracker", nullptr, flags)) { - for (const auto* q : toShow) { - // Quest title - ImVec4 titleColor = q->complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f) - : ImVec4(1.0f, 0.84f, 0.0f, 1.0f); - std::string titleStr = q->title.empty() - ? ("Quest #" + std::to_string(q->questId)) : q->title; - // Truncate to fit - if (titleStr.size() > 26) { titleStr.resize(23); titleStr += "..."; } - ImGui::TextColored(titleColor, "%s", titleStr.c_str()); - - // Kill/entity objectives - bool hasObjectives = false; - for (const auto& ko : q->killObjectives) { - if (ko.npcOrGoId == 0 || ko.required == 0) continue; - hasObjectives = true; - uint32_t entry = (uint32_t)std::abs(ko.npcOrGoId); - auto it = q->killCounts.find(entry); - uint32_t cur = it != q->killCounts.end() ? it->second.first : 0; - std::string name = gameHandler.getCachedCreatureName(entry); - if (name.empty()) { - if (ko.npcOrGoId < 0) { - const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); - if (goInfo) name = goInfo->name; - } - if (name.empty()) name = "Objective"; - } - if (name.size() > 20) { name.resize(17); name += "..."; } - bool done = (cur >= ko.required); - ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, ko.required); - } - - // Item objectives - for (const auto& io : q->itemObjectives) { - if (io.itemId == 0 || io.required == 0) continue; - hasObjectives = true; - auto it = q->itemCounts.find(io.itemId); - uint32_t cur = it != q->itemCounts.end() ? it->second : 0; - std::string name; - if (const auto* info = gameHandler.getItemInfo(io.itemId)) name = info->name; - if (name.empty()) name = "Item #" + std::to_string(io.itemId); - if (name.size() > 20) { name.resize(17); name += "..."; } - bool done = (cur >= io.required); - ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, io.required); - } - - if (!hasObjectives && q->complete) { - ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), " Ready to turn in!"); - } - - ImGui::Dummy(ImVec2(0.0f, 2.0f)); - } - - // Track drag — save new position when the window is moved - ImVec2 newPos = ImGui::GetWindowPos(); - if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || - std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { - // Clamp to screen bounds - newPos.x = std::clamp(newPos.x, 0.0f, screenW - trackerW); - newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); - questTrackerPos_ = newPos; - saveSettings(); - } - } - ImGui::End(); - ImGui::PopStyleVar(2); +// ─── 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. } // ─── Inspect Window ─────────────────────────────────────────────────────────── From fbcec9e7bfb0a1cde4894d38cd2314c85352511a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 17:01:20 -0700 Subject: [PATCH 65/82] feat: show FXAA status in performance HUD --- src/rendering/performance_hud.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 09430dce..08029fc9 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -219,6 +219,9 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text(" Upscale Dispatches: %zu", renderer->getAmdFsr3UpscaleDispatchCount()); ImGui::Text(" FG Fallbacks: %zu", renderer->getAmdFsr3FallbackCount()); } + if (renderer->isFXAAEnabled()) { + ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON"); + } ImGui::Spacing(); } From b7c1aa39a9537c3d49ab7fdb6bfd4d0968524bda Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 17:25:00 -0700 Subject: [PATCH 66/82] feat: add WotLK vehicle exit UI with Leave Vehicle button Parse SMSG_PLAYER_VEHICLE_DATA (PackedGuid + uint32 vehicleId) and track in-vehicle state. Add sendRequestVehicleExit() which sends CMSG_REQUEST_VEHICLE_EXIT. Render a floating red "Leave Vehicle" button above the action bar whenever vehicleId_ is non-zero. State cleared on world leave and zone transfer. --- include/game/game_handler.hpp | 8 ++++++++ src/game/game_handler.cpp | 25 ++++++++++++++++++++++--- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9c1015a7..47f1e2b3 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1594,6 +1594,11 @@ public: return it != taxiNpcHasRoutes_.end() && it->second; } + // Vehicle (WotLK) + bool isInVehicle() const { return vehicleId_ != 0; } + uint32_t getVehicleId() const { return vehicleId_; } + void sendRequestVehicleExit(); + // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); @@ -2536,6 +2541,9 @@ private: return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown } + // Vehicle (WotLK): non-zero when player is seated in a vehicle + uint32_t vehicleId_ = 0; + // Taxi / Flight Paths std::unordered_map taxiNpcHasRoutes_; // guid -> has new/available routes std::unordered_map taxiNodes_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f70a3c39..d7bf2db0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5211,10 +5211,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // GM ticket status (new/updated); no ticket UI yet packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_PLAYER_VEHICLE_DATA: - // Vehicle data update for player in vehicle; no vehicle UI yet - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PLAYER_VEHICLE_DATA: { + // PackedGuid (player guid) + uint32 vehicleId + // vehicleId == 0 means the player left the vehicle + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); // player guid (unused) + } + if (packet.getSize() - packet.getReadPos() >= 4) { + vehicleId_ = packet.readUInt32(); + } else { + vehicleId_ = 0; + } break; + } case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: packet.setReadPos(packet.getSize()); break; @@ -6868,6 +6877,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { taxiStartGrace_ = 0.0f; currentMountDisplayId_ = 0; taxiMountDisplayId_ = 0; + vehicleId_ = 0; if (mountCallback_) { mountCallback_(0); } @@ -7891,6 +7901,14 @@ void GameHandler::sendPing() { socket->send(packet); } +void GameHandler::sendRequestVehicleExit() { + if (state != WorldState::IN_WORLD || vehicleId_ == 0) return; + // CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only + network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT)); + socket->send(pkt); + vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) +} + void GameHandler::sendMinimapPing(float wowX, float wowY) { if (state != WorldState::IN_WORLD) return; @@ -8202,6 +8220,7 @@ void GameHandler::forceClearTaxiAndMovementState() { taxiMountActive_ = false; taxiMountDisplayId_ = 0; currentMountDisplayId_ = 0; + vehicleId_ = 0; resurrectPending_ = false; resurrectRequestPending_ = false; playerDead_ = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5b880d51..8be6697a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6968,6 +6968,38 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(4); } + // Vehicle exit button (WotLK): floating button above action bar when player is in a vehicle + if (gameHandler.isInVehicle()) { + const float btnW = 120.0f; + const float btnH = 32.0f; + const float btnX = (screenW - btnW) / 2.0f; + const float btnY = barY - btnH - 6.0f; + + ImGui::SetNextWindowPos(ImVec2(btnX, btnY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW, btnH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGuiWindowFlags vFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##VehicleExit", nullptr, vFlags)) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.4f, 0.0f, 0.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + if (ImGui::Button("Leave Vehicle", ImVec2(btnW - 8.0f, btnH - 8.0f))) { + gameHandler.sendRequestVehicleExit(); + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); + } + // Handle action bar drag: render icon at cursor and detect drop outside if (actionBarDragSlot_ >= 0) { ImVec2 mousePos = ImGui::GetMousePos(); From 882cb1bae3ce71aa5eba72bc394844eb3e8b26b0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 17:39:35 -0700 Subject: [PATCH 67/82] feat: implement WotLK glyph display in talent screen Store glyph IDs from SMSG_TALENTS_INFO (previously discarded) in learnedGlyphs_[2][6] per talent spec. Load GlyphProperties.dbc to map glyphId to spellId and major/minor type. Add a Glyphs tab to the talent screen showing all 6 slots with spell icons and names. Also clear vehicleId_ on SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA. --- include/game/game_handler.hpp | 9 +++ include/ui/talent_screen.hpp | 7 +++ src/game/game_handler.cpp | 9 ++- src/ui/talent_screen.cpp | 106 ++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 47f1e2b3..ae4ed3fd 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -706,6 +706,14 @@ public: static std::unordered_map empty; return spec < 2 ? learnedTalents_[spec] : empty; } + + // Glyphs (WotLK): up to 6 glyph slots per spec (3 major + 3 minor) + static constexpr uint8_t MAX_GLYPH_SLOTS = 6; + const std::array& getGlyphs() const { return learnedGlyphs_[activeTalentSpec_]; } + const std::array& getGlyphs(uint8_t spec) const { + static std::array empty{}; + return spec < 2 ? learnedGlyphs_[spec] : empty; + } uint8_t getTalentRank(uint32_t talentId) const { auto it = learnedTalents_[activeTalentSpec_].find(talentId); return (it != learnedTalents_[activeTalentSpec_].end()) ? it->second : 0; @@ -2308,6 +2316,7 @@ private: uint8_t activeTalentSpec_ = 0; // Currently active spec (0 or 1) uint8_t unspentTalentPoints_[2] = {0, 0}; // Unspent points per spec std::unordered_map learnedTalents_[2]; // Learned talents per spec + std::array, 2> learnedGlyphs_{}; // Glyphs per spec std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 18bbe152..72eafc2a 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -28,6 +28,8 @@ private: void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); + void loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager); + void renderGlyphs(game::GameHandler& gameHandler); VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); bool open = false; @@ -36,11 +38,16 @@ private: // DBC caches bool spellDbcLoaded = false; bool iconDbcLoaded = false; + bool glyphDbcLoaded = false; std::unordered_map spellIconIds; // spellId -> iconId std::unordered_map spellIconPaths; // iconId -> path std::unordered_map spellIconCache; // iconId -> texture std::unordered_map spellTooltips; // spellId -> description std::unordered_map bgTextureCache_; // tabId -> bg texture + + // GlyphProperties.dbc cache: glyphId -> { spellId, isMajor } + struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; }; + std::unordered_map glyphProperties_; // glyphId -> info }; } // namespace ui diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d7bf2db0..c7396f8f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6252,6 +6252,9 @@ void GameHandler::handlePacket(network::Packet& packet) { handleQuestPoiQueryResponse(packet); break; case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: + vehicleId_ = 0; // Vehicle ride cancelled; clear UI + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: packet.setReadPos(packet.getSize()); @@ -6891,6 +6894,8 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { talentsInitialized_ = false; learnedTalents_[0].clear(); learnedTalents_[1].clear(); + learnedGlyphs_[0].fill(0); + learnedGlyphs_[1].fill(0); unspentTalentPoints_[0] = 0; unspentTalentPoints_[1] = 0; activeTalentSpec_ = 0; @@ -11338,10 +11343,12 @@ void GameHandler::handleInspectResults(network::Packet& packet) { learnedTalents_[g][talentId] = rank; } if (packet.getSize() - packet.getReadPos() < 1) break; + learnedGlyphs_[g].fill(0); uint8_t glyphCount = packet.readUInt8(); for (uint8_t gl = 0; gl < glyphCount; ++gl) { if (packet.getSize() - packet.getReadPos() < 2) break; - packet.readUInt16(); // glyphId (skip) + uint16_t glyphId = packet.readUInt16(); + if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; } } diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index 5c6bdaf9..b1231f24 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -76,6 +76,7 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { gameHandler.loadTalentDbc(); loadSpellDBC(assetManager); loadSpellIconDBC(assetManager); + loadGlyphPropertiesDBC(assetManager); } uint8_t playerClass = gameHandler.getPlayerClass(); @@ -161,6 +162,18 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } } + + // Glyphs tab (WotLK only — visible when any glyph slot is populated or DBC data loaded) + if (!glyphProperties_.empty() || [&]() { + const auto& g = gameHandler.getGlyphs(); + for (auto id : g) if (id != 0) return true; + return false; }()) { + if (ImGui::BeginTabItem("Glyphs")) { + renderGlyphs(gameHandler); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } } @@ -616,6 +629,99 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { } } +void TalentScreen::loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager) { + if (glyphDbcLoaded) return; + glyphDbcLoaded = true; + + if (!assetManager || !assetManager->isInitialized()) return; + + auto dbc = assetManager->loadDBC("GlyphProperties.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + // GlyphProperties.dbc: field 0=ID, field 1=SpellID, field 2=GlyphSlotFlags (1=minor), field 3=SpellIconID + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); + uint32_t spellId = dbc->getUInt32(i, 1); + uint32_t flags = dbc->getUInt32(i, 2); + if (id == 0) continue; + GlyphInfo info; + info.spellId = spellId; + info.isMajor = (flags == 0); // flag 0 = major, flag 1 = minor + glyphProperties_[id] = info; + } +} + +void TalentScreen::renderGlyphs(game::GameHandler& gameHandler) { + auto* assetManager = core::Application::getInstance().getAssetManager(); + const auto& glyphs = gameHandler.getGlyphs(); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Major Glyphs"); + ImGui::Separator(); + + // WotLK: 6 glyph slots total. Slots 0,2,4 are major by convention from the server, + // but we check GlyphProperties.dbc flags when available. + // Display all 6 slots grouped: show major (non-minor) first, then minor. + std::vector> majorSlots, minorSlots; + for (int i = 0; i < game::GameHandler::MAX_GLYPH_SLOTS; i++) { + uint16_t glyphId = glyphs[i]; + bool isMajor = true; + if (glyphId != 0) { + auto git = glyphProperties_.find(glyphId); + if (git != glyphProperties_.end()) isMajor = git->second.isMajor; + else isMajor = (i % 2 == 0); // fallback: even slots = major + } else { + isMajor = (i % 2 == 0); // empty slots follow same pattern + } + if (isMajor) majorSlots.push_back({i, true}); + else minorSlots.push_back({i, false}); + } + + auto renderGlyphSlot = [&](int slotIdx) { + uint16_t glyphId = glyphs[slotIdx]; + char label[64]; + if (glyphId == 0) { + snprintf(label, sizeof(label), "Slot %d [Empty]", slotIdx + 1); + ImGui::TextDisabled("%s", label); + return; + } + + uint32_t spellId = 0; + uint32_t iconId = 0; + auto git = glyphProperties_.find(glyphId); + if (git != glyphProperties_.end()) { + spellId = git->second.spellId; + auto iit = spellIconIds.find(spellId); + if (iit != spellIconIds.end()) iconId = iit->second; + } + + // Icon (24x24) + VkDescriptorSet icon = getSpellIcon(iconId, assetManager); + if (icon != VK_NULL_HANDLE) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(24, 24)); + ImGui::SameLine(0, 6); + } else { + ImGui::Dummy(ImVec2(24, 24)); + ImGui::SameLine(0, 6); + } + + // Spell name + const std::string& name = spellId ? gameHandler.getSpellName(spellId) : ""; + if (!name.empty()) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", name.c_str()); + } else { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Glyph #%u", (uint32_t)glyphId); + } + }; + + for (auto& [idx, major] : majorSlots) renderGlyphSlot(idx); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "Minor Glyphs"); + ImGui::Separator(); + for (auto& [idx, major] : minorSlots) renderGlyphSlot(idx); +} + VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { if (iconId == 0 || !assetManager) return VK_NULL_HANDLE; From 6df8c72cf7a3ae0eff4149df94002cda7504879b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 17:48:08 -0700 Subject: [PATCH 68/82] feat: add WotLK equipment set UI to character screen - Expose equipment sets via public EquipmentSetInfo getter - Populate equipmentSetInfo_ from handleEquipmentSetList() - Implement useEquipmentSet() sending CMSG_EQUIPMENT_SET_USE - Add "Outfits" tab in character screen listing saved sets with Equip button --- include/game/game_handler.hpp | 11 +++++++++++ src/game/game_handler.cpp | 19 +++++++++++++++++++ src/ui/inventory_screen.cpp | 28 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ae4ed3fd..037a0ea4 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1252,6 +1252,16 @@ public: void sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType); // rollType: 0=need, 1=greed, 2=disenchant, 96=pass + // Equipment Sets (WotLK): saved gear loadouts + struct EquipmentSetInfo { + uint64_t setGuid = 0; + uint32_t setId = 0; + std::string name; + std::string iconName; + }; + const std::vector& getEquipmentSets() const { return equipmentSetInfo_; } + void useEquipmentSet(uint32_t setId); + // NPC Gossip void interactWithNpc(uint64_t guid); void interactWithGameObject(uint64_t guid); @@ -2840,6 +2850,7 @@ private: std::array itemGuids{}; }; std::vector equipmentSets_; + std::vector equipmentSetInfo_; // public-facing copy // ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ---- std::unordered_map forcedReactions_; // factionId -> reaction tier diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c7396f8f..485d96aa 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7914,6 +7914,14 @@ void GameHandler::sendRequestVehicleExit() { vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) } +void GameHandler::useEquipmentSet(uint32_t setId) { + if (state != WorldState::IN_WORLD) return; + // CMSG_EQUIPMENT_SET_USE: uint32 setId + network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE)); + pkt.writeUInt32(setId); + socket->send(pkt); +} + void GameHandler::sendMinimapPing(float wowX, float wowY) { if (state != WorldState::IN_WORLD) return; @@ -20633,6 +20641,17 @@ void GameHandler::handleEquipmentSetList(network::Packet& packet) { } equipmentSets_.push_back(std::move(es)); } + // Populate public-facing info + equipmentSetInfo_.clear(); + equipmentSetInfo_.reserve(equipmentSets_.size()); + for (const auto& es : equipmentSets_) { + EquipmentSetInfo info; + info.setGuid = es.setGuid; + info.setId = es.setId; + info.name = es.name; + info.iconName = es.iconName; + equipmentSetInfo_.push_back(std::move(info)); + } LOG_INFO("SMSG_EQUIPMENT_SET_LIST: ", equipmentSets_.size(), " equipment sets received"); } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index acce3a27..1c029217 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1342,6 +1342,34 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // Equipment Sets tab (WotLK only) + const auto& eqSets = gameHandler.getEquipmentSets(); + if (!eqSets.empty()) { + if (ImGui::BeginTabItem("Outfits")) { + ImGui::Spacing(); + ImGui::TextDisabled("Saved Equipment Sets"); + ImGui::Separator(); + ImGui::BeginChild("##EqSetsList", ImVec2(0, 0), false); + for (const auto& es : eqSets) { + ImGui::PushID(static_cast(es.setId)); + // Icon placeholder or name + const char* displayName = es.name.empty() ? "(Unnamed)" : es.name.c_str(); + ImGui::Text("%s", displayName); + if (!es.iconName.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", es.iconName.c_str()); + } + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(es.setId); + } + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } From 6957ba97ea516bea12b2f2c958af5b98994fdcb3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 17:54:49 -0700 Subject: [PATCH 69/82] feat: show stat gains in level-up toast from SMSG_LEVELUP_INFO Parse hp/mana/str/agi/sta/int/spi deltas from SMSG_LEVELUP_INFO payload and display them in green below the "You have reached level X!" banner. Extends DING_DURATION to 4s to give players time to read the gains. --- include/game/game_handler.hpp | 9 ++++++ include/ui/game_screen.hpp | 9 ++++-- src/game/game_handler.cpp | 14 +++++++-- src/ui/game_screen.cpp | 57 ++++++++++++++++++++++++++++++++--- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 037a0ea4..db702467 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1438,6 +1438,14 @@ public: using LevelUpCallback = std::function; void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback_ = std::move(cb); } + // Stat deltas from the last SMSG_LEVELUP_INFO (valid until next level-up) + struct LevelUpDeltas { + uint32_t hp = 0; + uint32_t mana = 0; + uint32_t str = 0, agi = 0, sta = 0, intel = 0, spi = 0; + }; + const LevelUpDeltas& getLastLevelUpDeltas() const { return lastLevelUpDeltas_; } + // Other player level-up callback — fires when another player gains a level using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } @@ -2793,6 +2801,7 @@ private: NpcVendorCallback npcVendorCallback_; ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; + LevelUpDeltas lastLevelUpDeltas_; OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; AreaDiscoveryCallback areaDiscoveryCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index bbb5ffa8..09f80551 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -511,9 +511,12 @@ private: bool leftClickWasPress_ = false; // Level-up ding animation - static constexpr float DING_DURATION = 3.0f; + static constexpr float DING_DURATION = 4.0f; float dingTimer_ = 0.0f; uint32_t dingLevel_ = 0; + uint32_t dingHpDelta_ = 0; + uint32_t dingManaDelta_ = 0; + uint32_t dingStats_[5] = {}; // str/agi/sta/int/spi deltas void renderDingEffect(); // Achievement toast banner @@ -616,7 +619,9 @@ private: size_t dpsLogSeenCount_ = 0; // log entries already scanned public: - void triggerDing(uint32_t newLevel); + void triggerDing(uint32_t newLevel, uint32_t hpDelta = 0, uint32_t manaDelta = 0, + uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, + uint32_t intel = 0, uint32_t spi = 0); void triggerAchievementToast(uint32_t achievementId, std::string name = {}); }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 485d96aa..6bc076da 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3823,10 +3823,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LEVELUP_INFO: case Opcode::SMSG_LEVELUP_INFO_ALT: { // Server-authoritative level-up event. - // First field is always the new level in Classic/TBC/WotLK-era layouts. + // WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t newLevel = packet.readUInt32(); if (newLevel > 0) { + // Parse stat deltas (WotLK layout has 7 more uint32s) + lastLevelUpDeltas_ = {}; + if (packet.getSize() - packet.getReadPos() >= 28) { + lastLevelUpDeltas_.hp = packet.readUInt32(); + lastLevelUpDeltas_.mana = packet.readUInt32(); + lastLevelUpDeltas_.str = packet.readUInt32(); + lastLevelUpDeltas_.agi = packet.readUInt32(); + lastLevelUpDeltas_.sta = packet.readUInt32(); + lastLevelUpDeltas_.intel = packet.readUInt32(); + lastLevelUpDeltas_.spi = packet.readUInt32(); + } uint32_t oldLevel = serverPlayerLevel_; serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel); for (auto& ch : characters) { @@ -3840,7 +3851,6 @@ void GameHandler::handlePacket(network::Packet& packet) { } } } - // Remaining payload (hp/mana/stat deltas) is optional for our client. packet.setReadPos(packet.getSize()); break; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8be6697a..7429f04b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -284,10 +284,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Set up level-up callback (once) if (!levelUpCallbackSet_) { - gameHandler.setLevelUpCallback([this](uint32_t newLevel) { + gameHandler.setLevelUpCallback([this, &gameHandler](uint32_t newLevel) { levelUpFlashAlpha_ = 1.0f; levelUpDisplayLevel_ = newLevel; - triggerDing(newLevel); + const auto& d = gameHandler.getLastLevelUpDeltas(); + triggerDing(newLevel, d.hp, d.mana, d.str, d.agi, d.sta, d.intel, d.spi); }); levelUpCallbackSet_ = true; } @@ -18058,9 +18059,18 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Level-Up Ding Animation // ============================================================ -void GameScreen::triggerDing(uint32_t newLevel) { - dingTimer_ = DING_DURATION; - dingLevel_ = newLevel; +void GameScreen::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t manaDelta, + uint32_t str, uint32_t agi, uint32_t sta, + uint32_t intel, uint32_t spi) { + dingTimer_ = DING_DURATION; + dingLevel_ = newLevel; + dingHpDelta_ = hpDelta; + dingManaDelta_ = manaDelta; + dingStats_[0] = str; + dingStats_[1] = agi; + dingStats_[2] = sta; + dingStats_[3] = intel; + dingStats_[4] = spi; auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { @@ -18106,6 +18116,43 @@ void GameScreen::renderDingEffect() { // Gold text draw->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); + + // Stat gains below the main text (shown only if server sent deltas) + bool hasStatGains = (dingHpDelta_ > 0 || dingManaDelta_ > 0 || + dingStats_[0] || dingStats_[1] || dingStats_[2] || + dingStats_[3] || dingStats_[4]); + if (hasStatGains) { + float smallSize = baseSize * 0.95f; + float yOff = ty + sz.y + 6.0f; + + // Build stat delta string: "+150 HP +80 Mana +2 Str +2 Agi ..." + static const char* kStatLabels[] = { "Str", "Agi", "Sta", "Int", "Spi" }; + char statBuf[128]; + int written = 0; + if (dingHpDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u HP ", dingHpDelta_); + if (dingManaDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u Mana ", dingManaDelta_); + for (int i = 0; i < 5 && written < (int)sizeof(statBuf) - 1; ++i) { + if (dingStats_[i] > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u %s ", dingStats_[i], kStatLabels[i]); + } + // Trim trailing spaces + while (written > 0 && statBuf[written - 1] == ' ') --written; + statBuf[written] = '\0'; + + if (written > 0) { + ImVec2 ssz = font->CalcTextSizeA(smallSize, FLT_MAX, 0.0f, statBuf); + float stx = cx - ssz.x * 0.5f; + draw->AddText(font, smallSize, ImVec2(stx + 1, yOff + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), statBuf); + draw->AddText(font, smallSize, ImVec2(stx, yOff), + IM_COL32(100, 220, 100, (int)(alpha * 230)), statBuf); + } + } } void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { From 2f479c6230323d0cb085144fde090fdcc73c645d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 17:58:24 -0700 Subject: [PATCH 70/82] feat: implement master loot UI for SMSG_LOOT_MASTER_LIST Parse master loot candidate GUIDs from SMSG_LOOT_MASTER_LIST and display a "Give to..." popup menu on item click when master loot is active. Sends CMSG_LOOT_MASTER_GIVE with loot GUID, slot, and target GUID. Clears candidates when loot window is closed. --- include/game/game_handler.hpp | 6 ++++++ src/game/game_handler.cpp | 26 +++++++++++++++++++++--- src/ui/game_screen.cpp | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index db702467..0e56ea59 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1230,6 +1230,11 @@ public: void setAutoLoot(bool enabled) { autoLoot_ = enabled; } bool isAutoLoot() const { return autoLoot_; } + // Master loot candidates (from SMSG_LOOT_MASTER_LIST) + const std::vector& getMasterLootCandidates() const { return masterLootCandidates_; } + bool hasMasterLootCandidates() const { return !masterLootCandidates_.empty(); } + void lootMasterGive(uint8_t lootSlot, uint64_t targetGuid); + // Group loot roll struct LootRollEntry { uint64_t objectGuid = 0; @@ -2493,6 +2498,7 @@ private: bool lootWindowOpen = false; bool autoLoot_ = false; LootResponseData currentLoot; + std::vector masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST // Group loot roll state bool pendingLootRollActive_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6bc076da..0670e917 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3342,10 +3342,19 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LOOT_ROLL_WON: handleLootRollWon(packet); break; - case Opcode::SMSG_LOOT_MASTER_LIST: - // Master looter list — no UI yet; consume to avoid unhandled warning. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_MASTER_LIST: { + // uint8 count + count * uint64 guid — eligible recipients for master looter + masterLootCandidates_.clear(); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t mlCount = packet.readUInt8(); + masterLootCandidates_.reserve(mlCount); + for (uint8_t i = 0; i < mlCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + masterLootCandidates_.push_back(packet.readUInt64()); + } + LOG_INFO("SMSG_LOOT_MASTER_LIST: ", (int)masterLootCandidates_.size(), " candidates"); break; + } case Opcode::SMSG_GOSSIP_MESSAGE: handleGossipMessage(packet); break; @@ -15585,6 +15594,7 @@ void GameHandler::lootItem(uint8_t slotIndex) { void GameHandler::closeLoot() { if (!lootWindowOpen) return; lootWindowOpen = false; + masterLootCandidates_.clear(); if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) { clearTarget(); } @@ -15595,6 +15605,16 @@ void GameHandler::closeLoot() { currentLoot = LootResponseData{}; } +void GameHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_LOOT_MASTER_GIVE: uint64 lootGuid + uint8 slotIndex + uint64 targetGuid + network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_MASTER_GIVE)); + pkt.writeUInt64(currentLoot.lootGuid); + pkt.writeUInt8(lootSlot); + pkt.writeUInt64(targetGuid); + socket->send(pkt); +} + void GameHandler::interactWithNpc(uint64_t guid) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GossipHelloPacket::build(guid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7429f04b..69cc6cef 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12164,7 +12164,43 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Process deferred loot pickup (after loop to avoid iterator invalidation) if (lootSlotClicked >= 0) { - gameHandler.lootItem(static_cast(lootSlotClicked)); + if (gameHandler.hasMasterLootCandidates()) { + // Master looter: open popup to choose recipient + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", lootSlotClicked); + ImGui::OpenPopup(popupId); + } else { + gameHandler.lootItem(static_cast(lootSlotClicked)); + } + } + + // Master loot "Give to" popups + if (gameHandler.hasMasterLootCandidates()) { + for (const auto& item : loot.items) { + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", item.slotIndex); + if (ImGui::BeginPopup(popupId)) { + ImGui::TextDisabled("Give to:"); + ImGui::Separator(); + const auto& candidates = gameHandler.getMasterLootCandidates(); + for (uint64_t candidateGuid : candidates) { + auto entity = gameHandler.getEntityManager().getEntity(candidateGuid); + auto* unit = entity ? dynamic_cast(entity.get()) : nullptr; + const char* cName = unit ? unit->getName().c_str() : nullptr; + char nameBuf[64]; + if (!cName || cName[0] == '\0') { + snprintf(nameBuf, sizeof(nameBuf), "Player 0x%llx", + static_cast(candidateGuid)); + cName = nameBuf; + } + if (ImGui::MenuItem(cName)) { + gameHandler.lootMasterGive(item.slotIndex, candidateGuid); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } } if (loot.items.empty() && loot.gold == 0) { From 218d68e2753de7a11d6f259164e0403455530b3f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 18:15:51 -0700 Subject: [PATCH 71/82] feat: parse Classic SMSG_INSPECT gear + implement temp weapon enchant timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic 1.12 SMSG_INSPECT (wire 0x115): parse PackedGUID + 19×uint32 itemEntries to populate InspectResult and inspectedPlayerItemEntries_ cache, enabling gear inspection of other players on Classic servers. Triggers item queries for all filled slots so the inspect window shows names/ilevels. SMSG_ITEM_ENCHANT_TIME_UPDATE: parse itemGuid/slot/durationSec/playerGuid and store per-slot expire timestamps in tempEnchantTimers_. Fires 5min/1min chat warnings before expiry. getTempEnchantRemainingMs() helper queries live remaining time. Buff bar renders timed slot buttons (gold/teal/purple per slot) that pulse red below 60s — useful for Shaman imbues, Rogue poisons, whetstones and oils across all three expansions. --- include/game/game_handler.hpp | 12 ++++ src/game/game_handler.cpp | 116 ++++++++++++++++++++++++++++++++-- src/ui/game_screen.cpp | 54 ++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 0e56ea59..621a8586 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1451,6 +1451,17 @@ public: }; const LevelUpDeltas& getLastLevelUpDeltas() const { return lastLevelUpDeltas_; } + // Temporary weapon enchant timers (from SMSG_ITEM_ENCHANT_TIME_UPDATE) + // Slot: 0=main-hand, 1=off-hand, 2=ranged. Value: expire time (steady_clock ms). + struct TempEnchantTimer { + uint32_t slot = 0; + uint64_t expireMs = 0; // std::chrono::steady_clock ms timestamp when it expires + }; + const std::vector& getTempEnchantTimers() const { return tempEnchantTimers_; } + // Returns remaining ms for a given slot, or 0 if absent/expired. + uint32_t getTempEnchantRemainingMs(uint32_t slot) const; + static constexpr const char* kTempEnchantSlotNames[] = { "Main Hand", "Off Hand", "Ranged" }; + // Other player level-up callback — fires when another player gains a level using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } @@ -2808,6 +2819,7 @@ private: ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; LevelUpDeltas lastLevelUpDeltas_; + std::vector tempEnchantTimers_; OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; AreaDiscoveryCallback areaDiscoveryCallback_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0670e917..052c0cbc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5698,9 +5698,56 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Misc consume ---- + case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: { + // Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid + // slot: 0=main-hand, 1=off-hand, 2=ranged + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t enchSlot = packet.readUInt32(); + uint32_t durationSec = packet.readUInt32(); + /*uint64_t playerGuid =*/ packet.readUInt64(); + + // Clamp to known slots (0-2) + if (enchSlot > 2) { break; } + + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + if (durationSec == 0) { + // Enchant expired / removed — erase the slot entry + tempEnchantTimers_.erase( + std::remove_if(tempEnchantTimers_.begin(), tempEnchantTimers_.end(), + [enchSlot](const TempEnchantTimer& t) { return t.slot == enchSlot; }), + tempEnchantTimers_.end()); + } else { + uint64_t expireMs = nowMs + static_cast(durationSec) * 1000u; + bool found = false; + for (auto& t : tempEnchantTimers_) { + if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; } + } + if (!found) tempEnchantTimers_.push_back({enchSlot, expireMs}); + + // Warn at important thresholds + if (durationSec <= 60 && durationSec > 55) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName); + addSystemChatMessage(buf); + } else if (durationSec <= 300 && durationSec > 295) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName); + addSystemChatMessage(buf); + } + } + LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); + break; + } case Opcode::SMSG_COMPLAIN_RESULT: case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: - case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: // Consume — not yet processed packet.setReadPos(packet.getSize()); @@ -6212,10 +6259,58 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; - // ---- Inspect (full character inspection) ---- - case Opcode::SMSG_INSPECT: - packet.setReadPos(packet.getSize()); + // ---- Inspect (Classic 1.12 gear inspection) ---- + case Opcode::SMSG_INSPECT: { + // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) + // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to + // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0) { packet.setReadPos(packet.getSize()); break; } + + constexpr int kGearSlots = 19; + size_t needed = kGearSlots * sizeof(uint32_t); + if (packet.getSize() - packet.getReadPos() < needed) { + packet.setReadPos(packet.getSize()); break; + } + + std::array items{}; + for (int s = 0; s < kGearSlots; ++s) + items[s] = packet.readUInt32(); + + // Resolve player name + auto ent = entityManager.getEntity(guid); + std::string playerName = "Target"; + if (ent) { + auto pl = std::dynamic_pointer_cast(ent); + if (pl && !pl->getName().empty()) playerName = pl->getName(); + } + + // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = 0; + inspectResult_.unspentTalents = 0; + inspectResult_.talentGroups = 0; + inspectResult_.activeTalentGroup = 0; + inspectResult_.itemEntries = items; + inspectResult_.enchantIds = {}; + + // Also cache for future talent-inspect cross-reference + inspectedPlayerItemEntries_[guid] = items; + + // Trigger item queries for non-empty slots + for (int s = 0; s < kGearSlots; ++s) { + if (items[s] != 0) queryItemInfo(items[s], 0); + } + + LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", + std::count_if(items.begin(), items.end(), + [](uint32_t e) { return e != 0; }), "/19 slots"); break; + } // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: @@ -14383,6 +14478,19 @@ void GameHandler::cancelAura(uint32_t spellId) { socket->send(packet); } +uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + for (const auto& t : tempEnchantTimers_) { + if (t.slot == slot) { + return (t.expireMs > nowMs) + ? static_cast(t.expireMs - nowMs) : 0u; + } + } + return 0u; +} + void GameHandler::handlePetSpells(network::Packet& packet) { const size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 8) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 69cc6cef..0ee760e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12036,6 +12036,60 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } ImGui::PopStyleColor(2); } + + // Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.) + { + const auto& timers = gameHandler.getTempEnchantTimers(); + if (!timers.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + static const ImVec4 kEnchantSlotColors[] = { + ImVec4(0.9f, 0.6f, 0.1f, 1.0f), // main-hand: gold + ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal + ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple + }; + uint64_t enchNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (const auto& t : timers) { + if (t.slot > 2) continue; + uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0; + if (remMs == 0) continue; + + ImVec4 col = kEnchantSlotColors[t.slot]; + // Flash red when < 60s remaining + if (remMs < 60000) { + float pulse = 0.6f + 0.4f * std::sin( + static_cast(ImGui::GetTime()) * 4.0f); + col = ImVec4(pulse, 0.2f, 0.1f, 1.0f); + } + + // Format remaining time + uint32_t secs = static_cast((remMs + 999) / 1000); + char timeStr[16]; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%ds", secs); + + ImGui::PushID(static_cast(t.slot) + 5000); + ImGui::PushStyleColor(ImGuiCol_Button, col); + char label[40]; + snprintf(label, sizeof(label), "~%s %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr); + ImGui::Button(label, ImVec2(-1, 16)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], + timeStr); + ImGui::PopStyleColor(); + ImGui::PopID(); + } + } + } } ImGui::End(); From 5883654e1efc049ffd015ceb8360834407594d39 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 18:21:50 -0700 Subject: [PATCH 72/82] feat: replace page-text chat-dump with proper book/scroll window handlePageTextQueryResponse() now collects pages into bookPages_ vector instead of dumping lines to system chat. Multi-page items (nextPageId != 0) are automatically chained by requesting subsequent pages. The book window opens automatically when pages arrive, shows formatted text in a parchment- styled ImGui window with Prev/Next page navigation and a Close button. SMSG_READ_ITEM_OK clears bookPages_ so each item read starts fresh; handleGameObjectPageText() does the same before querying the first page. Closes the long-standing issue where reading scrolls and tattered notes spammed many separate chat messages instead of showing a readable UI. --- include/game/game_handler.hpp | 8 ++++ include/ui/game_screen.hpp | 5 +++ src/game/game_handler.cpp | 35 +++++++++++----- src/ui/game_screen.cpp | 77 +++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 621a8586..9c8c36d5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1462,6 +1462,13 @@ public: uint32_t getTempEnchantRemainingMs(uint32_t slot) const; static constexpr const char* kTempEnchantSlotNames[] = { "Main Hand", "Off Hand", "Ranged" }; + // ---- Readable text (books / scrolls / notes) ---- + // Populated by handlePageTextQueryResponse(); multi-page items chain via nextPageId. + struct BookPage { uint32_t pageId = 0; std::string text; }; + const std::vector& getBookPages() const { return bookPages_; } + bool hasBookOpen() const { return !bookPages_.empty(); } + void clearBook() { bookPages_.clear(); } + // Other player level-up callback — fires when another player gains a level using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } @@ -2820,6 +2827,7 @@ private: LevelUpCallback levelUpCallback_; LevelUpDeltas lastLevelUpDeltas_; std::vector tempEnchantTimers_; + std::vector bookPages_; // pages collected for the current readable item OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; AreaDiscoveryCallback areaDiscoveryCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 09f80551..65a0f7ce 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -437,6 +437,11 @@ private: bool showInspectWindow_ = false; void renderInspectWindow(game::GameHandler& gameHandler); + // Readable text window (books / scrolls / notes) + bool showBookWindow_ = false; + int bookCurrentPage_ = 0; + void renderBookWindow(game::GameHandler& gameHandler); + // Threat window bool showThreatWindow_ = false; void renderThreatWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 052c0cbc..f46a12d4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6098,7 +6098,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: - addSystemChatMessage("You read the item."); + bookPages_.clear(); // fresh book for this item read packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: @@ -11327,6 +11327,7 @@ void GameHandler::handleGameObjectPageText(network::Packet& packet) { else if (info.type == 10) pageId = info.data[7]; if (pageId != 0 && socket && state == WorldState::IN_WORLD) { + bookPages_.clear(); // start a fresh book for this interaction auto req = PageTextQueryPacket::build(pageId, guid); socket->send(req); return; @@ -11341,19 +11342,31 @@ void GameHandler::handlePageTextQueryResponse(network::Packet& packet) { PageTextQueryResponseData data; if (!PageTextQueryResponseParser::parse(packet, data)) return; - if (!data.text.empty()) { - std::istringstream iss(data.text); - std::string line; - bool wrote = false; - while (std::getline(iss, line)) { - if (line.empty()) continue; - addSystemChatMessage(line); - wrote = true; + if (!data.isValid()) return; + + // Append page if not already collected + bool alreadyHave = false; + for (const auto& bp : bookPages_) { + if (bp.pageId == data.pageId) { alreadyHave = true; break; } + } + if (!alreadyHave) { + bookPages_.push_back({data.pageId, data.text}); + } + + // Follow the chain: if there's a next page we haven't fetched yet, request it + if (data.nextPageId != 0) { + bool nextHave = false; + for (const auto& bp : bookPages_) { + if (bp.pageId == data.nextPageId) { nextHave = true; break; } } - if (!wrote) { - addSystemChatMessage(data.text); + if (!nextHave && socket && state == WorldState::IN_WORLD) { + auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid); + socket->send(req); } } + LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, + " nextPage=", data.nextPageId, + " totalPages=", bookPages_.size()); } // ============================================================ diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0ee760e8..f4ea21d6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -711,6 +711,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); + renderBookWindow(gameHandler); renderThreatWindow(gameHandler); renderBgScoreboard(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now @@ -20194,6 +20195,82 @@ void GameScreen::renderObjectiveTracker(game::GameHandler&) { // full-featured draggable tracker with context menus and item icons. } +// ─── Book / Scroll / Note Window ────────────────────────────────────────────── +void GameScreen::renderBookWindow(game::GameHandler& gameHandler) { + // Auto-open when new pages arrive + if (gameHandler.hasBookOpen() && !showBookWindow_) { + showBookWindow_ = true; + bookCurrentPage_ = 0; + } + if (!showBookWindow_) return; + + const auto& pages = gameHandler.getBookPages(); + if (pages.empty()) { showBookWindow_ = false; return; } + + // Clamp page index + if (bookCurrentPage_ < 0) bookCurrentPage_ = 0; + if (bookCurrentPage_ >= static_cast(pages.size())) + bookCurrentPage_ = static_cast(pages.size()) - 1; + + ImGui::SetNextWindowSize(ImVec2(420, 340), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImVec2(400, 180), ImGuiCond_Appearing); + + bool open = showBookWindow_; + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.12f, 0.09f, 0.06f, 0.98f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.25f, 0.18f, 0.08f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.37f, 0.18f, 1.0f)); + + char title[64]; + if (pages.size() > 1) + snprintf(title, sizeof(title), "Page %d / %d###BookWin", + bookCurrentPage_ + 1, static_cast(pages.size())); + else + snprintf(title, sizeof(title), "###BookWin"); + + if (ImGui::Begin(title, &open, ImGuiWindowFlags_NoCollapse)) { + // Parchment text colour + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.85f, 0.78f, 0.62f, 1.0f)); + + const std::string& text = pages[bookCurrentPage_].text; + // Use a child region with word-wrap + ImGui::SetNextWindowContentSize(ImVec2(ImGui::GetContentRegionAvail().x, 0)); + if (ImGui::BeginChild("##BookText", + ImVec2(0, ImGui::GetContentRegionAvail().y - 34), + false, ImGuiWindowFlags_HorizontalScrollbar)) { + ImGui::SetNextItemWidth(-1); + ImGui::TextWrapped("%s", text.c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); + + // Navigation row + ImGui::Separator(); + bool canPrev = (bookCurrentPage_ > 0); + bool canNext = (bookCurrentPage_ < static_cast(pages.size()) - 1); + + if (!canPrev) ImGui::BeginDisabled(); + if (ImGui::Button("< Prev", ImVec2(80, 0))) bookCurrentPage_--; + if (!canPrev) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!canNext) ImGui::BeginDisabled(); + if (ImGui::Button("Next >", ImVec2(80, 0))) bookCurrentPage_++; + if (!canNext) ImGui::EndDisabled(); + + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); + if (ImGui::Button("Close", ImVec2(60, 0))) { + open = false; + } + } + ImGui::End(); + ImGui::PopStyleColor(3); + + if (!open) { + showBookWindow_ = false; + gameHandler.clearBook(); + } +} + // ─── Inspect Window ─────────────────────────────────────────────────────────── void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { if (!showInspectWindow_) return; From a97941f062545603c216511a335ff1a0080a9c19 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 18:25:02 -0700 Subject: [PATCH 73/82] feat: implement SMSG_PET_ACTION_FEEDBACK with human-readable messages Parse pet action feedback opcodes and display messages in system chat: dead, nothing_to_attack, cant_attack_target, target_too_far, no_path, cant_attack_immune. Replaces consume stub. --- src/game/game_handler.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f46a12d4..570a999e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1794,8 +1794,23 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PET_ACTION_FEEDBACK: { - // uint8 action + uint8 flags - packet.setReadPos(packet.getSize()); // Consume; no UI for pet feedback yet. + // uint8 msg: 1=dead, 2=nothing_to_attack, 3=cant_attack_target, + // 4=target_too_far, 5=no_path, 6=cant_attack_immune + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t msg = packet.readUInt8(); + static const char* kPetFeedback[] = { + nullptr, + "Your pet is dead.", + "Your pet has nothing to attack.", + "Your pet cannot attack that target.", + "That target is too far away.", + "Your pet cannot find a path to the target.", + "Your pet cannot attack an immune target.", + }; + if (msg > 0 && msg < 7 && kPetFeedback[msg]) { + addSystemChatMessage(kPetFeedback[msg]); + } + packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: { From eafd09aca045c1e9b0c150f49a9c9c4109c098c0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 18:58:30 -0700 Subject: [PATCH 74/82] feat: allow FXAA alongside FSR1 and FSR3 simultaneously - Remove !fsr_.enabled / !fsr2_.enabled guards that blocked FXAA init - FXAA can now coexist with FSR1 and FSR3 simultaneously - Priority: FSR3 > FXAA > FSR1 - FSR3 + FXAA: scene renders at FSR3 internal res, temporal AA runs, then FXAA reads FSR3 history and applies spatial AA to swapchain (replaces RCAS sharpening for ultra-quality native mode) - FXAA + FSR1: scene renders at native res, FXAA post-processes; FSR1 resources exist but are idle (FXAA wins for better quality) - FSR3 only / FSR1 only: unchanged paths - Fix missing fxaa.frag.spv: shader was present but uncompiled; the CMake compile_shaders() function will now pick it up on next build --- src/rendering/renderer.cpp | 173 ++++++++++++++++++++++++------------- 1 file changed, 115 insertions(+), 58 deletions(-) diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 20e2d472..a7cbb3e7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -1019,13 +1019,15 @@ void Renderer::beginFrame() { } } - // FXAA resource management (disabled when FSR2 is active — FSR2 has its own AA) + // FXAA resource management — FXAA can coexist with FSR1 and FSR3. + // When both FXAA and FSR3 are enabled, FXAA runs as a post-FSR3 pass. + // When both FXAA and FSR1 are enabled, FXAA takes priority (native res render). if (fxaa_.needsRecreate && fxaa_.sceneFramebuffer) { destroyFXAAResources(); fxaa_.needsRecreate = false; if (!fxaa_.enabled) LOG_INFO("FXAA: disabled"); } - if (fxaa_.enabled && !fsr2_.enabled && !fsr_.enabled && !fxaa_.sceneFramebuffer) { + if (fxaa_.enabled && !fxaa_.sceneFramebuffer) { if (!initFXAAResources()) { LOG_ERROR("FXAA: initialization failed, disabling"); fxaa_.enabled = false; @@ -1049,7 +1051,8 @@ void Renderer::beginFrame() { initFSR2Resources(); } // Recreate FXAA resources for new swapchain dimensions - if (fxaa_.enabled && !fsr2_.enabled && !fsr_.enabled) { + // FXAA can coexist with FSR1 and FSR3 simultaneously. + if (fxaa_.enabled) { destroyFXAAResources(); initFXAAResources(); } @@ -1139,12 +1142,14 @@ void Renderer::beginFrame() { if (fsr2_.enabled && fsr2_.sceneFramebuffer) { rpInfo.framebuffer = fsr2_.sceneFramebuffer; renderExtent = { fsr2_.internalWidth, fsr2_.internalHeight }; + } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { + // FXAA takes priority over FSR1: renders at native res with AA post-process. + // When both FSR1 and FXAA are enabled, FXAA wins (native res, no downscale). + rpInfo.framebuffer = fxaa_.sceneFramebuffer; + renderExtent = vkCtx->getSwapchainExtent(); // native resolution — no downscaling } else if (fsr_.enabled && fsr_.sceneFramebuffer) { rpInfo.framebuffer = fsr_.sceneFramebuffer; renderExtent = { fsr_.internalWidth, fsr_.internalHeight }; - } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { - rpInfo.framebuffer = fxaa_.sceneFramebuffer; - renderExtent = vkCtx->getSwapchainExtent(); // native resolution — no downscaling } else { rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; renderExtent = vkCtx->getSwapchainExtent(); @@ -1231,6 +1236,35 @@ void Renderer::endFrame() { VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); } + // FSR3+FXAA combined: re-point FXAA's descriptor to the FSR3 temporal output + // so renderFXAAPass() applies spatial AA on the temporally-stabilized frame. + // This must happen outside the render pass (descriptor updates are CPU-side). + if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler) { + VkImageView fsr3OutputView = VK_NULL_HANDLE; + if (fsr2_.useAmdBackend) { + if (fsr2_.amdFsr3FramegenRuntimeActive && fsr2_.framegenOutput.image) + fsr3OutputView = fsr2_.framegenOutput.imageView; + else if (fsr2_.history[fsr2_.currentHistory].image) + fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView; + } else if (fsr2_.history[fsr2_.currentHistory].image) { + fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView; + } + if (fsr3OutputView) { + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = fsr3OutputView; + imgInfo.sampler = fxaa_.sceneSampler; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fxaa_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + } + } + // Begin swapchain render pass at full resolution for sharpening + ImGui VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; @@ -1260,8 +1294,33 @@ void Renderer::endFrame() { sc.extent = ext; vkCmdSetScissor(currentCmd, 0, 1, &sc); - // Draw RCAS sharpening from accumulated history buffer - renderFSR2Sharpen(); + // When FXAA is also enabled: apply FXAA on the FSR3 temporal output instead + // of RCAS sharpening. FXAA descriptor is temporarily pointed to the FSR3 + // history buffer (which is already in SHADER_READ_ONLY_OPTIMAL). This gives + // FSR3 temporal stability + FXAA spatial edge smoothing ("ultra quality native"). + if (fxaa_.enabled && fxaa_.pipeline && fxaa_.descSet) { + renderFXAAPass(); + } else { + // Draw RCAS sharpening from accumulated history buffer + renderFSR2Sharpen(); + } + + // Restore FXAA descriptor to its normal scene color source so standalone + // FXAA frames are not affected by the FSR3 history pointer set above. + if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler && fxaa_.sceneColor.imageView) { + VkDescriptorImageInfo restoreInfo{}; + restoreInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + restoreInfo.imageView = fxaa_.sceneColor.imageView; + restoreInfo.sampler = fxaa_.sceneSampler; + VkWriteDescriptorSet restoreWrite{}; + restoreWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + restoreWrite.dstSet = fxaa_.descSet; + restoreWrite.dstBinding = 0; + restoreWrite.descriptorCount = 1; + restoreWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + restoreWrite.pImageInfo = &restoreInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &restoreWrite, 0, nullptr); + } // Maintain frame bookkeeping fsr2_.prevViewProjection = camera->getViewProjectionMatrix(); @@ -1272,56 +1331,6 @@ void Renderer::endFrame() { } fsr2_.frameIndex = (fsr2_.frameIndex + 1) % 256; // Wrap to keep Halton values well-distributed - } else if (fsr_.enabled && fsr_.sceneFramebuffer) { - // End the off-screen scene render pass - vkCmdEndRenderPass(currentCmd); - - // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY - // The render pass finalLayout puts the resolve/color attachment in PRESENT_SRC_KHR - transitionImageLayout(currentCmd, fsr_.sceneColor.image, - VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, - VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, - VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); - - // Begin swapchain render pass at full resolution - VkRenderPassBeginInfo rpInfo{}; - rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; - rpInfo.renderPass = vkCtx->getImGuiRenderPass(); - rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; - rpInfo.renderArea.offset = {0, 0}; - rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); - - // Clear values must match the render pass attachment count - bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); - VkClearValue clearValues[4]{}; - clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; - clearValues[1].depthStencil = {1.0f, 0}; - clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; - clearValues[3].depthStencil = {1.0f, 0}; - if (msaaOn) { - bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); - rpInfo.clearValueCount = depthRes ? 4 : 3; - } else { - rpInfo.clearValueCount = 2; - } - rpInfo.pClearValues = clearValues; - - vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); - - // Set full-resolution viewport and scissor - VkExtent2D ext = vkCtx->getSwapchainExtent(); - VkViewport vp{}; - vp.width = static_cast(ext.width); - vp.height = static_cast(ext.height); - vp.maxDepth = 1.0f; - vkCmdSetViewport(currentCmd, 0, 1, &vp); - VkRect2D sc{}; - sc.extent = ext; - vkCmdSetScissor(currentCmd, 0, 1, &sc); - - // Draw FSR upscale fullscreen quad - renderFSRUpscale(); - } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { // End the off-screen scene render pass vkCmdEndRenderPass(currentCmd); @@ -1361,9 +1370,57 @@ void Renderer::endFrame() { // Draw FXAA pass renderFXAAPass(); + + } else if (fsr_.enabled && fsr_.sceneFramebuffer) { + // FSR1 upscale path — only runs when FXAA is not active. + // When both FSR1 and FXAA are enabled, FXAA took priority above. + vkCmdEndRenderPass(currentCmd); + + // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY + transitionImageLayout(currentCmd, fsr_.sceneColor.image, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + + // Begin swapchain render pass at full resolution + VkRenderPassBeginInfo fsrRpInfo{}; + fsrRpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + fsrRpInfo.renderPass = vkCtx->getImGuiRenderPass(); + fsrRpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + fsrRpInfo.renderArea.offset = {0, 0}; + fsrRpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + bool fsrMsaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); + VkClearValue fsrClearValues[4]{}; + fsrClearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fsrClearValues[1].depthStencil = {1.0f, 0}; + fsrClearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fsrClearValues[3].depthStencil = {1.0f, 0}; + if (fsrMsaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + fsrRpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + fsrRpInfo.clearValueCount = 2; + } + fsrRpInfo.pClearValues = fsrClearValues; + + vkCmdBeginRenderPass(currentCmd, &fsrRpInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkExtent2D fsrExt = vkCtx->getSwapchainExtent(); + VkViewport fsrVp{}; + fsrVp.width = static_cast(fsrExt.width); + fsrVp.height = static_cast(fsrExt.height); + fsrVp.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &fsrVp); + VkRect2D fsrSc{}; + fsrSc.extent = fsrExt; + vkCmdSetScissor(currentCmd, 0, 1, &fsrSc); + + renderFSRUpscale(); } // ImGui rendering — must respect subpass contents mode + // Parallel recording only applies when no post-process pass is active. if (!fsr_.enabled && !fsr2_.enabled && !fxaa_.enabled && parallelRecordingEnabled_) { // Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS, // so ImGui must be recorded into a secondary command buffer. From cd01d07a9170bd374a14eb47f52d57ba0257d289 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:01:15 -0700 Subject: [PATCH 75/82] fix: wait for GPU idle before freeing M2/WMO model buffers to prevent device lost cleanupUnusedModels() runs every 5 seconds and freed vertex/index buffers without waiting for the GPU to finish the previous frame's command buffer. This caused VK_ERROR_DEVICE_LOST (-4) after extended gameplay when tiles stream out and their models are freed mid-render. Add vkDeviceWaitIdle() before the buffer destroy loop in both M2Renderer and WMORenderer cleanupUnusedModels(). The wait only happens when there are models to remove, so quiet sessions have no overhead. --- src/rendering/m2_renderer.cpp | 9 ++++++++- src/rendering/wmo_renderer.cpp | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index c5ef43b2..96659828 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -3905,7 +3905,14 @@ void M2Renderer::cleanupUnusedModels() { } } - // Delete GPU resources and remove from map + // Delete GPU resources and remove from map. + // Wait for the GPU to finish all in-flight frames before destroying any + // buffers — the previous frame's command buffer may still be referencing + // vertex/index buffers that are about to be freed. Without this wait, + // the GPU reads freed memory, which can cause VK_ERROR_DEVICE_LOST. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } for (uint32_t id : toRemove) { auto it = models.find(id); if (it != models.end()) { diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index c2a81301..bc9aa362 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -835,7 +835,12 @@ void WMORenderer::cleanupUnusedModels() { } } - // Delete GPU resources and remove from map + // Delete GPU resources and remove from map. + // Ensure all in-flight frames are complete before freeing vertex/index buffers — + // the GPU may still be reading them from the previous frame's command buffer. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } for (uint32_t id : toRemove) { unloadModel(id); } From 81b95b4af7e1eea2e87ad58f26f2e40c82a934fd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:05:54 -0700 Subject: [PATCH 76/82] feat: resolve title names from CharTitles.dbc in SMSG_TITLE_EARNED Previously SMSG_TITLE_EARNED only showed the numeric bit index. Now it lazy-loads CharTitles.dbc and formats the full title string with the player's name (e.g. "Title earned: Commander Kelsi!"). - Add CharTitles layout to WotLK (TitleBit=36) and TBC (TitleBit=20) layouts - loadTitleNameCache() maps each titleBit to its English title string - SMSG_TITLE_EARNED substitutes %s placeholder with local player's name - Falls back to "Title earned (bit N)!" if DBC is unavailable --- Data/expansions/tbc/dbc_layouts.json | 1 + Data/expansions/wotlk/dbc_layouts.json | 1 + include/game/game_handler.hpp | 6 +++ src/game/game_handler.cpp | 67 +++++++++++++++++++++++--- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 7929446f..26ac235e 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -31,6 +31,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, + "CharTitles": { "ID": 0, "Title": 2, "TitleBit": 20 }, "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 70c80d61..de137ad8 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -31,6 +31,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, + "CharTitles": { "ID": 0, "Title": 2, "TitleBit": 36 }, "Achievement": { "ID": 0, "Title": 4, "Description": 21, "Points": 39 }, "AchievementCriteria": { "ID": 0, "AchievementID": 1, "Quantity": 4, "Description": 9 }, "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9c8c36d5..573b3f44 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2694,6 +2694,12 @@ private: std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; + // Title cache: maps titleBit → title string (lazy-loaded from CharTitles.dbc) + // The strings use "%s" as a player-name placeholder (e.g. "Commander %s", "%s the Explorer"). + std::unordered_map titleNameCache_; + bool titleNameCacheLoaded_ = false; + void loadTitleNameCache(); + // Achievement caches (lazy-loaded from Achievement.dbc on first earned event) std::unordered_map achievementNameCache_; std::unordered_map achievementDescCache_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 570a999e..aaf340f8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2095,12 +2095,40 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t titleBit = packet.readUInt32(); uint32_t isLost = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!", - titleBit); - addSystemChatMessage(buf); - LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost); + loadTitleNameCache(); + + // Format the title string using the player's own name + std::string titleStr; + auto tit = titleNameCache_.find(titleBit); + if (tit != titleNameCache_.end() && !tit->second.empty()) { + // Title strings contain "%s" as a player-name placeholder. + // Replace it with the local player's name if known. + auto nameIt = playerNameCache.find(playerGuid); + const std::string& pName = (nameIt != playerNameCache.end()) + ? nameIt->second : "you"; + const std::string& fmt = tit->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) { + titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + } else { + titleStr = fmt; + } + } + + std::string msg; + if (!titleStr.empty()) { + msg = isLost ? ("Title removed: " + titleStr + ".") + : ("Title earned: " + titleStr + "!"); + } else { + char buf[64]; + std::snprintf(buf, sizeof(buf), + isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", + titleBit); + msg = buf; + } + addSystemChatMessage(msg); + LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, + " title='", titleStr, "'"); break; } @@ -20539,6 +20567,33 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT // PackedTime date — uint32 bitfield (seconds since epoch) // uint32 realmFirst — how many on realm also got it (0 = realm first) // --------------------------------------------------------------------------- +void GameHandler::loadTitleNameCache() { + if (titleNameCacheLoaded_) return; + titleNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("CharTitles.dbc"); + if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 5) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("CharTitles") : nullptr; + + uint32_t titleField = layout ? layout->field("Title") : 2; + uint32_t bitField = layout ? layout->field("TitleBit") : 36; + if (titleField == 0xFFFFFFFF) titleField = 2; + if (bitField == 0xFFFFFFFF) bitField = static_cast(dbc->getFieldCount() - 1); + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t bit = dbc->getUInt32(i, bitField); + if (bit == 0) continue; + std::string name = dbc->getString(i, titleField); + if (!name.empty()) titleNameCache_[bit] = std::move(name); + } + LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC"); +} + void GameHandler::loadAchievementNameCache() { if (achievementNameCacheLoaded_) return; achievementNameCacheLoaded_ = true; From 284b98d93a5ad06273a7fc342872146ffe868980 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:15:52 -0700 Subject: [PATCH 77/82] feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET) - Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with petNumber, entry, level, name, displayId, and active status - Detect stable master via gossip option text/keyword matching and auto-send MSG_LIST_STABLED_PETS request to open the stable UI - Refresh list automatically after SMSG_STABLE_RESULT to reflect state - New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket - New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber) - Stable window UI: shows active/stabled pets with store/retrieve buttons, slot count, refresh, and close; opens when server sends pet list - Clear stable state on world logout/disconnect --- include/game/game_handler.hpp | 25 +++++++ include/game/world_packets.hpp | 19 ++++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 98 +++++++++++++++++++++++++++ src/game/world_packets.cpp | 24 +++++++ src/ui/game_screen.cpp | 118 +++++++++++++++++++++++++++++++++ 6 files changed, 285 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 573b3f44..87f35809 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -634,6 +634,24 @@ public: void sendPetAction(uint32_t action, uint64_t targetGuid = 0); const std::unordered_set& getKnownSpells() const { return knownSpells; } + // ---- Pet Stable ---- + struct StabledPet { + uint32_t petNumber = 0; // server-side pet number (used for unstable/swap) + uint32_t entry = 0; // creature entry ID + uint32_t level = 0; + std::string name; + uint32_t displayId = 0; + bool isActive = false; // true = currently summoned/active slot + }; + bool isStableWindowOpen() const { return stableWindowOpen_; } + void closeStableWindow() { stableWindowOpen_ = false; } + uint64_t getStableMasterGuid() const { return stableMasterGuid_; } + uint8_t getStableSlots() const { return stableNumSlots_; } + const std::vector& getStabledPets() const { return stabledPets_; } + void requestStabledPetList(); // CMSG MSG_LIST_STABLED_PETS + void stablePet(uint8_t slot); // CMSG_STABLE_PET (store active pet in slot) + void unstablePet(uint32_t petNumber); // CMSG_UNSTABLE_PET (retrieve to active) + // Player proficiency bitmasks (from SMSG_SET_PROFICIENCY) // itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing) // itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield) @@ -2390,6 +2408,13 @@ private: std::vector petSpellList_; // known pet spells std::unordered_set petAutocastSpells_; // spells with autocast on + // ---- Pet Stable ---- + bool stableWindowOpen_ = false; + uint64_t stableMasterGuid_ = 0; + uint8_t stableNumSlots_ = 0; + std::vector stabledPets_; + void handleListStabledPets(network::Packet& packet); + // ---- Battleground queue state ---- std::array bgQueues_{}; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 24d795f7..2bb89907 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2699,5 +2699,24 @@ public: static bool parse(network::Packet& packet, AuctionCommandResult& data); }; +/** Pet Stable packet builders */ +class ListStabledPetsPacket { +public: + /** MSG_LIST_STABLED_PETS (CMSG): request list from stable master */ + static network::Packet build(uint64_t stableMasterGuid); +}; + +class StablePetPacket { +public: + /** CMSG_STABLE_PET: store active pet in the given stable slot (1-based) */ + static network::Packet build(uint64_t stableMasterGuid, uint8_t slot); +}; + +class UnstablePetPacket { +public: + /** CMSG_UNSTABLE_PET: retrieve a stabled pet by its server-side petNumber */ + static network::Packet build(uint64_t stableMasterGuid, uint32_t petNumber); +}; + } // namespace game } // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 65a0f7ce..69e0ff44 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -344,6 +344,7 @@ private: void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); void renderTrainerWindow(game::GameHandler& gameHandler); + void renderStableWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index aaf340f8..3f8183ad 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2070,6 +2070,11 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } + // ---- Pet stable list ---- + case Opcode::MSG_LIST_STABLED_PETS: + if (state == WorldState::IN_WORLD) handleListStabledPets(packet); + break; + // ---- Pet stable result ---- case Opcode::SMSG_STABLE_RESULT: { // uint8 result @@ -2086,6 +2091,11 @@ void GameHandler::handlePacket(network::Packet& packet) { } if (msg) addSystemChatMessage(msg); LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); + // Refresh the stable list after a result to reflect the new state + if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) { + auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(refreshPkt); + } break; } @@ -6916,6 +6926,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { unitAurasCache_.clear(); unitCastStates_.clear(); petGuid_ = 0; + stableWindowOpen_ = false; + stableMasterGuid_ = 0; + stableNumSlots_ = 0; + stabledPets_.clear(); playerXp_ = 0; playerNextLevelXp_ = 0; serverPlayerLevel_ = 1; @@ -14622,6 +14636,78 @@ void GameHandler::dismissPet() { socket->send(packet); } +void GameHandler::requestStabledPetList() { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; + auto pkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(pkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, stableMasterGuid_, std::dec); +} + +void GameHandler::stablePet(uint8_t slot) { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; + if (petGuid_ == 0) { + addSystemChatMessage("You do not have an active pet to stable."); + return; + } + auto pkt = StablePetPacket::build(stableMasterGuid_, slot); + socket->send(pkt); + LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast(slot)); +} + +void GameHandler::unstablePet(uint32_t petNumber) { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0 || petNumber == 0) return; + auto pkt = UnstablePetPacket::build(stableMasterGuid_, petNumber); + socket->send(pkt); + LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber); +} + +void GameHandler::handleListStabledPets(network::Packet& packet) { + // SMSG MSG_LIST_STABLED_PETS: + // uint64 stableMasterGuid + // uint8 petCount + // uint8 numSlots + // per pet: + // uint32 petNumber + // uint32 entry + // uint32 level + // string name (null-terminated) + // uint32 displayId + // uint8 isActive (1 = active/summoned, 0 = stabled) + constexpr size_t kMinHeader = 8 + 1 + 1; + if (packet.getSize() - packet.getReadPos() < kMinHeader) { + LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")"); + return; + } + stableMasterGuid_ = packet.readUInt64(); + uint8_t petCount = packet.readUInt8(); + stableNumSlots_ = packet.readUInt8(); + + stabledPets_.clear(); + stabledPets_.reserve(petCount); + + for (uint8_t i = 0; i < petCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 4 + 4 + 4) break; + StabledPet pet; + pet.petNumber = packet.readUInt32(); + pet.entry = packet.readUInt32(); + pet.level = packet.readUInt32(); + pet.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 4 + 1) break; + pet.displayId = packet.readUInt32(); + pet.isActive = (packet.readUInt8() != 0); + stabledPets_.push_back(std::move(pet)); + } + + stableWindowOpen_ = true; + LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, stableMasterGuid_, std::dec, + " petCount=", (int)petCount, " numSlots=", (int)stableNumSlots_); + for (const auto& p : stabledPets_) { + LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry, + " level=", p.level, " name='", p.name, "' displayId=", p.displayId, + " active=", p.isActive); + } +} + void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) { if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; actionBar[slot].type = type; @@ -15958,6 +16044,18 @@ void GameHandler::selectGossipOption(uint32_t optionId) { socket->send(bindPkt); LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec); } + + // Stable master detection: GOSSIP_OPTION_STABLE or text keywords + if (text == "GOSSIP_OPTION_STABLE" || + textLower.find("stable") != std::string::npos || + textLower.find("my pet") != std::string::npos) { + stableMasterGuid_ = currentGossip.npcGuid; + stableWindowOpen_ = false; // will open when list arrives + auto listPkt = ListStabledPetsPacket::build(currentGossip.npcGuid); + socket->send(listPkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to npc=0x", + std::hex, currentGossip.npcGuid, std::dec); + } break; } } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index dd7dc33f..f5bb0e44 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -5397,5 +5397,29 @@ bool AuctionCommandResultParser::parse(network::Packet& packet, AuctionCommandRe return true; } +// ============================================================ +// Pet Stable System +// ============================================================ + +network::Packet ListStabledPetsPacket::build(uint64_t stableMasterGuid) { + network::Packet p(wireOpcode(Opcode::MSG_LIST_STABLED_PETS)); + p.writeUInt64(stableMasterGuid); + return p; +} + +network::Packet StablePetPacket::build(uint64_t stableMasterGuid, uint8_t slot) { + network::Packet p(wireOpcode(Opcode::CMSG_STABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt8(slot); + return p; +} + +network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t petNumber) { + network::Packet p(wireOpcode(Opcode::CMSG_UNSTABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt32(petNumber); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f4ea21d6..54fa0b7f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -698,6 +698,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); renderTrainerWindow(gameHandler); + renderStableWindow(gameHandler); renderTaxiWindow(gameHandler); renderMailWindow(gameHandler); renderMailComposeWindow(gameHandler); @@ -13593,6 +13594,123 @@ void GameScreen::renderEscapeMenu() { ImGui::End(); } +// ============================================================ +// Pet Stable Window +// ============================================================ + +void GameScreen::renderStableWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isStableWindowOpen()) 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; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), + ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (!ImGui::Begin("Pet Stable", &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::End(); + if (!open) { + // User closed the window; clear stable state + gameHandler.closeStableWindow(); + } + return; + } + + const auto& pets = gameHandler.getStabledPets(); + uint8_t numSlots = gameHandler.getStableSlots(); + + ImGui::TextDisabled("Stable slots: %u", static_cast(numSlots)); + ImGui::Separator(); + + // Active pets section + bool hasActivePets = false; + for (const auto& p : pets) { + if (p.isActive) { hasActivePets = true; break; } + } + + if (hasActivePets) { + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Active / Summoned"); + for (const auto& p : pets) { + if (!p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber) * -1 - 1); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u)", displayName.c_str(), p.level); + ImGui::SameLine(); + ImGui::TextDisabled("[Active]"); + + // Offer to stable the active pet if there are free slots + uint8_t usedSlots = 0; + for (const auto& sp : pets) { if (!sp.isActive) ++usedSlots; } + if (usedSlots < numSlots) { + ImGui::SameLine(); + if (ImGui::SmallButton("Store in stable")) { + // Slot 1 is first stable slot; server handles free slot assignment. + gameHandler.stablePet(1); + } + } + ImGui::PopID(); + } + ImGui::Separator(); + } + + // Stabled pets section + ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), "Stabled Pets"); + + bool hasStabledPets = false; + for (const auto& p : pets) { + if (!p.isActive) { hasStabledPets = true; break; } + } + + if (!hasStabledPets) { + ImGui::TextDisabled(" (No pets in stable)"); + } else { + for (const auto& p : pets) { + if (p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber)); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u, Entry %u)", + displayName.c_str(), p.level, p.entry); + ImGui::SameLine(); + if (ImGui::SmallButton("Retrieve")) { + gameHandler.unstablePet(p.petNumber); + } + ImGui::PopID(); + } + } + + // Empty slots + uint8_t usedStableSlots = 0; + for (const auto& p : pets) { if (!p.isActive) ++usedStableSlots; } + if (usedStableSlots < numSlots) { + ImGui::TextDisabled(" %u empty slot(s) available", + static_cast(numSlots - usedStableSlots)); + } + + ImGui::Separator(); + if (ImGui::Button("Refresh")) { + gameHandler.requestStabledPetList(); + } + ImGui::SameLine(); + if (ImGui::Button("Close")) { + gameHandler.closeStableWindow(); + } + + ImGui::End(); + if (!open) { + gameHandler.closeStableWindow(); + } +} + // ============================================================ // Taxi Window // ============================================================ From 214c1a9ff8ed252b512a21f54a8ad4f0e5bc46fb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:27:00 -0700 Subject: [PATCH 78/82] feat: enable FXAA alongside FSR3 in settings and add FXAA to Ultra preset - Remove fsr2Active guard that prevented FXAA when FSR3 was active - FXAA checkbox now always enabled; tooltip adapts to explain FSR3+FXAA combo when FSR3 is active ('recommended ultra-quality combination') - Performance HUD shows 'FXAA: ON (FSR3+FXAA combined)' when both active - Ultra graphics preset now enables FXAA (8x MSAA + FXAA for max smoothness) - Preset detection updated to require FXAA for Ultra match --- src/rendering/performance_hud.cpp | 6 +++++- src/ui/game_screen.cpp | 21 +++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 08029fc9..1351e715 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -220,7 +220,11 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text(" FG Fallbacks: %zu", renderer->getAmdFsr3FallbackCount()); } if (renderer->isFXAAEnabled()) { - ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON"); + if (renderer->isFSR2Enabled()) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.8f, 1.0f), "FXAA: ON (FSR3+FXAA combined)"); + } else { + ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON"); + } } ImGui::Spacing(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 54fa0b7f..7093120d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14234,20 +14234,19 @@ void GameScreen::renderSettingsWindow() { updateGraphicsPresetFromCurrentSettings(); saveSettings(); } - // FXAA — post-process, combinable with any MSAA level (disabled with FSR2) - if (fsr2Active) { - ImGui::BeginDisabled(); - bool fxaaOff = false; - ImGui::Checkbox("FXAA (disabled with FSR3)", &fxaaOff); - ImGui::EndDisabled(); - } else { + // FXAA — post-process, combinable with MSAA or FSR3 + { if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { if (renderer) renderer->setFXAAEnabled(pendingFXAA); updateGraphicsPresetFromCurrentSettings(); saveSettings(); } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); + if (ImGui::IsItemHovered()) { + if (fsr2Active) + ImGui::SetTooltip("FXAA applies spatial anti-aliasing after FSR3 upscaling.\nFSR3 + FXAA is the recommended ultra-quality combination."); + else + ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); + } } } // FSR Upscaling @@ -15187,6 +15186,7 @@ void GameScreen::applyGraphicsPreset(GraphicsPreset preset) { pendingShadows = true; pendingShadowDistance = 500.0f; pendingAntiAliasing = 3; // 8x MSAA + pendingFXAA = true; // FXAA on top of MSAA for maximum smoothness pendingNormalMapping = true; pendingNormalMapStrength = 1.2f; pendingPOM = true; @@ -15196,6 +15196,7 @@ void GameScreen::applyGraphicsPreset(GraphicsPreset preset) { renderer->setShadowsEnabled(true); renderer->setShadowDistance(500.0f); renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT); + renderer->setFXAAEnabled(true); if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMappingEnabled(true); wr->setNormalMapStrength(1.2f); @@ -15241,7 +15242,7 @@ void GameScreen::updateGraphicsPresetFromCurrentSettings() { pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110; case GraphicsPreset::ULTRA: return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 && - pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; + pendingFXAA && pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; default: return false; } From 9aa4b223dcca7779f89ef25f2505c5374175138e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:37:53 -0700 Subject: [PATCH 79/82] feat: implement SMSG_CAMERA_SHAKE with sinusoidal camera shake effect - Add triggerShake(magnitude, frequency, duration) to CameraController - Apply envelope-decaying sinusoidal XYZ offset to camera in update() - Handle SMSG_CAMERA_SHAKE opcode in GameHandler dispatch - Translate shakeId to magnitude (minor <50: 0.04, larger: 0.08 world units) - Wire CameraShakeCallback from GameHandler through to CameraController - Shake uses 18Hz oscillation with 30% fade-out envelope at end of duration --- include/game/game_handler.hpp | 6 ++++++ include/rendering/camera_controller.hpp | 12 +++++++++++ src/core/application.cpp | 5 +++++ src/game/game_handler.cpp | 19 +++++++++++++++++ src/rendering/camera_controller.cpp | 28 +++++++++++++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 87f35809..2fe0dcec 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -882,6 +882,11 @@ public: using KnockBackCallback = std::function; void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); } + // Camera shake callback: called when server sends SMSG_CAMERA_SHAKE. + // Parameters: magnitude (world units), frequency (Hz), duration (seconds). + using CameraShakeCallback = std::function; + void setCameraShakeCallback(CameraShakeCallback cb) { cameraShakeCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) using UnstuckCallback = std::function; void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } @@ -2323,6 +2328,7 @@ private: // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; KnockBackCallback knockBackCallback_; + CameraShakeCallback cameraShakeCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 7401ffdd..fbddd523 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -129,6 +129,12 @@ public: // vspeed: raw packet vspeed field (server sends negative for upward launch) void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed); + // Trigger a camera shake effect (e.g. from SMSG_CAMERA_SHAKE). + // magnitude: peak positional offset in world units + // frequency: oscillation frequency in Hz + // duration: shake duration in seconds + void triggerShake(float magnitude, float frequency, float duration); + // For first-person player hiding void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { characterRenderer = cr; @@ -369,6 +375,12 @@ private: glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s) // Horizontal velocity decays via WoW-like drag so the player doesn't slide forever. static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s) + + // Camera shake state (SMSG_CAMERA_SHAKE) + float shakeElapsed_ = 0.0f; + float shakeDuration_ = 0.0f; + float shakeMagnitude_ = 0.0f; + float shakeFrequency_ = 0.0f; }; } // namespace rendering diff --git a/src/core/application.cpp b/src/core/application.cpp index 9ad75cc6..396c260f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -646,6 +646,11 @@ void Application::setState(AppState newState) { renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); } }); + gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->triggerShake(magnitude, frequency, duration); + } + }); } // Load quest marker models loadQuestMarkerModels(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3f8183ad..903c5799 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2760,6 +2760,25 @@ void GameHandler::handlePacket(network::Packet& packet) { handleMoveKnockBack(packet); break; + case Opcode::SMSG_CAMERA_SHAKE: { + // uint32 shakeID (CameraShakes.dbc), uint32 shakeType + // We don't parse CameraShakes.dbc; apply a hardcoded moderate shake. + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t shakeId = packet.readUInt32(); + uint32_t shakeType = packet.readUInt32(); + (void)shakeType; + // Map shakeId ranges to approximate magnitudes: + // IDs < 50: minor environmental (0.04), others: larger boss effects (0.08) + float magnitude = (shakeId < 50) ? 0.04f : 0.08f; + if (cameraShakeCallback_) { + cameraShakeCallback_(magnitude, 18.0f, 0.5f); + } + LOG_DEBUG("SMSG_CAMERA_SHAKE: id=", shakeId, " type=", shakeType, + " magnitude=", magnitude); + } + break; + } + case Opcode::SMSG_CLIENT_CONTROL_UPDATE: { // Minimal parse: PackedGuid + uint8 allowMovement. if (packet.getSize() - packet.getReadPos() < 2) { diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 50872d46..a34f05f1 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -140,6 +140,17 @@ std::optional CameraController::getCachedFloorHeight(float x, float y, fl return result; } +void CameraController::triggerShake(float magnitude, float frequency, float duration) { + // Allow stronger shake to override weaker; don't allow zero magnitude. + if (magnitude <= 0.0f || duration <= 0.0f) return; + if (magnitude > shakeMagnitude_ || shakeElapsed_ >= shakeDuration_) { + shakeMagnitude_ = magnitude; + shakeFrequency_ = frequency; + shakeDuration_ = duration; + shakeElapsed_ = 0.0f; + } +} + void CameraController::update(float deltaTime) { if (!enabled || !camera) { return; @@ -1859,6 +1870,23 @@ void CameraController::update(float deltaTime) { wasFalling = !grounded && verticalVelocity <= 0.0f; // R key is now handled above with chat safeguard (WantTextInput check) + + // Camera shake (SMSG_CAMERA_SHAKE): apply sinusoidal offset to final camera position. + if (shakeElapsed_ < shakeDuration_) { + shakeElapsed_ += deltaTime; + float t = shakeElapsed_ / shakeDuration_; + // Envelope: fade out over the last 30% of shake duration + float envelope = (t < 0.7f) ? 1.0f : (1.0f - (t - 0.7f) / 0.3f); + float theta = shakeElapsed_ * shakeFrequency_ * 2.0f * 3.14159265f; + glm::vec3 offset( + shakeMagnitude_ * envelope * std::sin(theta), + shakeMagnitude_ * envelope * std::cos(theta * 1.3f), + shakeMagnitude_ * envelope * std::sin(theta * 0.7f) * 0.5f + ); + if (camera) { + camera->setPosition(camera->getPosition() + offset); + } + } } void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { From bba2f205888a8589717f6e56ce99e09f2fb23c07 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:42:31 -0700 Subject: [PATCH 80/82] feat: implement CMSG_PET_RENAME with rename dialog in pet frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PetRenamePacket::build(petGuid, name, isDeclined) builder - Add GameHandler::renamePet(newName) — sends packet via petGuid_ - Add 'Rename Pet' to pet frame context menu (right-click pet name) - Modal input dialog with 12-char limit matches server validation --- include/game/game_handler.hpp | 1 + include/game/world_packets.hpp | 9 +++++++++ include/ui/game_screen.hpp | 4 ++++ src/game/game_handler.cpp | 8 ++++++++ src/game/world_packets.cpp | 8 ++++++++ src/ui/game_screen.cpp | 30 ++++++++++++++++++++++++++++++ 6 files changed, 60 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2fe0dcec..e8c22327 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -609,6 +609,7 @@ public: void cancelCast(); void cancelAura(uint32_t spellId); void dismissPet(); + void renamePet(const std::string& newName); bool hasPet() const { return petGuid_ != 0; } uint64_t getPetGuid() const { return petGuid_; } diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 2bb89907..71be1501 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2718,5 +2718,14 @@ public: static network::Packet build(uint64_t stableMasterGuid, uint32_t petNumber); }; +class PetRenamePacket { +public: + /** CMSG_PET_RENAME: rename the player's active pet. + * petGuid: the pet's object GUID (from GameHandler::getPetGuid()) + * name: new name (max 12 chars; server validates and may reject) + * isDeclined: 0 for non-Cyrillic locales (no declined name forms) */ + static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0); +}; + } // namespace game } // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 69e0ff44..56e133cd 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -434,6 +434,10 @@ private: char gmTicketBuf_[2048] = {}; void renderGmTicketWindow(game::GameHandler& gameHandler); + // Pet rename modal (triggered from pet frame context menu) + bool petRenameOpen_ = false; + char petRenameBuf_[16] = {}; + // Inspect window bool showInspectWindow_ = false; void renderInspectWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 903c5799..28414f65 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14655,6 +14655,14 @@ void GameHandler::dismissPet() { socket->send(packet); } +void GameHandler::renamePet(const std::string& newName) { + if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return; + if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars + auto packet = PetRenamePacket::build(petGuid_, newName, 0); + socket->send(packet); + LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, petGuid_, std::dec, " name='", newName, "'"); +} + void GameHandler::requestStabledPetList() { if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; auto pkt = ListStabledPetsPacket::build(stableMasterGuid_); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index f5bb0e44..98ddd9d3 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -5421,5 +5421,13 @@ network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t pet return p; } +network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name, uint8_t isDeclined) { + network::Packet p(wireOpcode(Opcode::CMSG_PET_RENAME)); + p.writeUInt64(petGuid); + p.writeString(name); // null-terminated + p.writeUInt8(isDeclined); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7093120d..6a3cc61d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3030,11 +3030,41 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Target Pet")) { gameHandler.setTarget(petGuid); } + if (ImGui::MenuItem("Rename Pet")) { + ImGui::CloseCurrentPopup(); + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } if (ImGui::MenuItem("Dismiss Pet")) { gameHandler.dismissPet(); } ImGui::EndPopup(); } + // Pet rename modal (opened via context menu) + if (petRenameOpen_) { + ImGui::OpenPopup("Rename Pet###PetRename"); + petRenameOpen_ = false; + } + if (ImGui::BeginPopupModal("Rename Pet###PetRename", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Enter new pet name (max 12 characters):"); + ImGui::SetNextItemWidth(180.0f); + bool submitted = ImGui::InputText("##PetRenameInput", petRenameBuf_, sizeof(petRenameBuf_), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (ImGui::Button("OK") || submitted) { + std::string newName(petRenameBuf_); + if (!newName.empty() && newName.size() <= 12) { + gameHandler.renamePet(newName); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); if (petLevel > 0) { ImGui::SameLine(); From a7261a0d15a3d93687cdf4966306a908f5674d36 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 19:46:01 -0700 Subject: [PATCH 81/82] feat: trigger camera rumble shake on storm weather transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SMSG_WEATHER sets storm (type 3) with intensity > 0.3, fire a low-frequency (6Hz) camera shake to simulate thunder. Magnitude scales with intensity: 0.03–0.07 world units. --- src/game/game_handler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 28414f65..8ed25e79 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4333,6 +4333,11 @@ void GameHandler::handlePacket(network::Packet& packet) { weatherIntensity_ = wIntensity; const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); + // Storm transition: trigger a low-frequency thunder rumble shake + if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { + float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units + cameraShakeCallback_(mag, 6.0f, 0.6f); + } } break; } From a87d62abf811cfb70485dd20d043331ac5efca33 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 20:05:36 -0700 Subject: [PATCH 82/82] feat: add melee swing timer bar to player frame during auto-attack Shows a thin progress bar below the player health/power bars whenever the player is auto-attacking. The bar fills from the last swing timestamp to the next expected swing based on the main-hand weapon's delay (from ItemQueryResponseData::delayMs). Falls back to 2.0s for unarmed. Turns gold and shows "Swing!" when the timer is complete to signal readiness. Hides when not auto-attacking. --- include/game/game_handler.hpp | 4 ++++ src/game/game_handler.cpp | 7 ++++-- src/ui/game_screen.cpp | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e8c22327..1f6a029b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -573,6 +573,9 @@ public: } uint64_t getAutoAttackTargetGuid() const { return autoAttackTarget; } bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } + // Timestamp (ms since epoch) of the most recent player melee auto-attack. + // Zero if no swing has occurred this session. + uint64_t getLastMeleeSwingMs() const { return lastMeleeSwingMs_; } const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); @@ -2854,6 +2857,7 @@ private: StandStateCallback standStateCallback_; GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; + uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing SpellCastAnimCallback spellCastAnimCallback_; UnitAnimHintCallback unitAnimHintCallback_; UnitMoveFlagsCallback unitMoveFlagsCallback_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8ed25e79..f3d3eb2c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14328,8 +14328,11 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { bool isPlayerTarget = (data.targetGuid == playerGuid); if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat - if (isPlayerAttacker && meleeSwingCallback_) { - meleeSwingCallback_(); + if (isPlayerAttacker) { + lastMeleeSwingMs_ = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + if (meleeSwingCallback_) meleeSwingCallback_(); } if (!isPlayerAttacker && npcSwingCallback_) { npcSwingCallback_(data.attackerGuid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6a3cc61d..6d86c1a1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2977,6 +2977,47 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); } } + + // Melee swing timer — shown when player is auto-attacking + if (gameHandler.isAutoAttacking()) { + const uint64_t lastSwingMs = gameHandler.getLastMeleeSwingMs(); + if (lastSwingMs > 0) { + // Determine weapon speed from the equipped main-hand weapon + uint32_t weaponDelayMs = 2000; // Default: 2.0s unarmed + const auto& mainSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND); + if (!mainSlot.empty() && mainSlot.item.itemId != 0) { + const auto* info = gameHandler.getItemInfo(mainSlot.item.itemId); + if (info && info->delayMs > 0) { + weaponDelayMs = info->delayMs; + } + } + + // Compute elapsed since last swing + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + uint64_t elapsedMs = (nowMs >= lastSwingMs) ? (nowMs - lastSwingMs) : 0; + + // Clamp to weapon delay (cap at 1.0 so the bar fills but doesn't exceed) + float pct = std::min(static_cast(elapsedMs) / static_cast(weaponDelayMs), 1.0f); + + // Light silver-orange color indicating auto-attack readiness + ImVec4 swingColor = (pct >= 0.95f) + ? ImVec4(1.0f, 0.75f, 0.15f, 1.0f) // gold when ready to swing + : ImVec4(0.65f, 0.55f, 0.40f, 1.0f); // muted brown-orange while filling + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, swingColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.12f, 0.08f, 0.8f)); + char swingLabel[24]; + float remainSec = std::max(0.0f, (weaponDelayMs - static_cast(elapsedMs)) / 1000.0f); + if (pct >= 0.98f) + snprintf(swingLabel, sizeof(swingLabel), "Swing!"); + else + snprintf(swingLabel, sizeof(swingLabel), "%.1fs", remainSec); + ImGui::ProgressBar(pct, ImVec2(-1.0f, 8.0f), swingLabel); + ImGui::PopStyleColor(2); + } + } + ImGui::End(); ImGui::PopStyleColor(2);