From 6275a45ec0c9bf8ab0c9f7a9142edbc01b95bb45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 20:53:21 -0700 Subject: [PATCH] feat: achievement name in toast, parse earned achievements, loot item tooltips - Parse SMSG_ALL_ACHIEVEMENT_DATA on login to populate earnedAchievements_ set - Pass achievement name through callback so toast shows name instead of ID - Add renderItemTooltip(ItemQueryResponseData) overload for loot/non-inventory contexts - Loot window now shows full item tooltip on hover (stats, sell price, bind type, etc.) --- include/game/game_handler.hpp | 6 +- include/ui/game_screen.hpp | 3 +- include/ui/inventory_screen.hpp | 1 + src/core/application.cpp | 4 +- src/game/game_handler.cpp | 37 +++++++- src/ui/game_screen.cpp | 22 ++++- src/ui/inventory_screen.cpp | 162 ++++++++++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2307f849..e48a585b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1134,8 +1134,9 @@ public: void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } // Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received - using AchievementEarnedCallback = std::function; + using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } + const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -2246,6 +2247,9 @@ private: std::unordered_map achievementNameCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); + // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_set earnedAchievements_; + void handleAllAchievementData(network::Packet& packet); // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) std::unordered_map areaNameCache_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5dc0aa6d..1ffc804f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -366,6 +366,7 @@ private: static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; float achievementToastTimer_ = 0.0f; uint32_t achievementToastId_ = 0; + std::string achievementToastName_; void renderAchievementToast(); // Zone discovery text ("Entering: ") @@ -377,7 +378,7 @@ private: public: void triggerDing(uint32_t newLevel); - void triggerAchievementToast(uint32_t achievementId); + void triggerAchievementToast(uint32_t achievementId, std::string name = {}); }; } // namespace ui diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index a0a19386..4f3e7970 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -96,6 +96,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); + void renderItemTooltip(const game::ItemQueryResponseData& info); private: // Character model preview diff --git a/src/core/application.cpp b/src/core/application.cpp index 91126c66..b265d45c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2335,9 +2335,9 @@ void Application::setupUICallbacks() { }); // Achievement earned callback — show toast banner - gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) { + gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { if (uiManager) { - uiManager->getGameScreen().triggerAchievementToast(achievementId); + uiManager->getGameScreen().triggerAchievementToast(achievementId, name); } }); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d18ec99c..ac47f913 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2695,7 +2695,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAchievementEarned(packet); break; case Opcode::SMSG_ALL_ACHIEVEMENT_DATA: - // Initial data burst on login — ignored for now (no achievement tracker UI). + handleAllAchievementData(packet); break; case Opcode::SMSG_ITEM_COOLDOWN: { // uint64 itemGuid + uint32 spellId + uint32 cooldownMs @@ -18711,8 +18711,9 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { } addSystemChatMessage(buf); + earnedAchievements_.insert(achievementId); if (achievementEarnedCallback_) { - achievementEarnedCallback_(achievementId); + achievementEarnedCallback_(achievementId, achName); } } else { // Another player in the zone earned an achievement @@ -18743,6 +18744,38 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { achName.empty() ? "" : " name=", achName); } +// --------------------------------------------------------------------------- +// SMSG_ALL_ACHIEVEMENT_DATA (WotLK 3.3.5a) +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 packedDate, ... } until 0xFFFFFFFF +// --------------------------------------------------------------------------- +void GameHandler::handleAllAchievementData(network::Packet& packet) { + loadAchievementNameCache(); + earnedAchievements_.clear(); + + // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t date =*/ packet.readUInt32(); + earnedAchievements_.insert(id); + } + + // Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unknown(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + packet.readUInt64(); // counter + packet.readUInt32(); // date + packet.readUInt32(); // unknown / flags + } + + LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements"); +} + // --------------------------------------------------------------------------- // Faction name cache (lazily loaded from Faction.dbc) // --------------------------------------------------------------------------- diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cbe89daf..282d1ecb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6499,6 +6499,13 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } bool hovered = ImGui::IsItemHovered(); + // Show item tooltip on hover + if (hovered && info && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered && !itemName.empty() && itemName[0] != 'I') { + ImGui::SetTooltip("%s", itemName.c_str()); + } + ImDrawList* drawList = ImGui::GetWindowDrawList(); // Draw hover highlight @@ -10799,8 +10806,9 @@ void GameScreen::renderDingEffect() { IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); } -void GameScreen::triggerAchievementToast(uint32_t achievementId) { +void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { achievementToastId_ = achievementId; + achievementToastName_ = std::move(name); achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; // Play a UI sound if available @@ -10859,9 +10867,15 @@ void GameScreen::renderAchievementToast() { draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), IM_COL32(255, 215, 0, (int)(alpha * 255)), title); - // Achievement ID line (until we have Achievement.dbc name lookup) - char idBuf[64]; - std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + // Achievement name (falls back to ID if name not available) + char idBuf[256]; + const char* achText = achievementToastName_.empty() + ? nullptr : achievementToastName_.c_str(); + if (achText) { + std::snprintf(idBuf, sizeof(idBuf), "%s", achText); + } else { + std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + } float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; float idX = toastX + (TOAST_W - idW) * 0.5f; draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8567c3ce..b9cf88ef 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2027,5 +2027,167 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::EndTooltip(); } +// --------------------------------------------------------------------------- +// Tooltip overload for ItemQueryResponseData (used by loot window, etc.) +// --------------------------------------------------------------------------- +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) { + ImGui::BeginTooltip(); + + ImVec4 qColor = getQualityColor(static_cast(info.quality)); + ImGui::TextColored(qColor, "%s", info.name.c_str()); + if (info.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel); + } + + // Binding type + switch (info.bindType) { + case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; + case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break; + case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break; + case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break; + default: break; + } + + // Slot / subclass + if (info.inventoryType > 0) { + const char* slotName = ""; + switch (info.inventoryType) { + case 1: slotName = "Head"; break; + case 2: slotName = "Neck"; break; + case 3: slotName = "Shoulder"; break; + case 4: slotName = "Shirt"; break; + case 5: slotName = "Chest"; break; + case 6: slotName = "Waist"; break; + case 7: slotName = "Legs"; break; + case 8: slotName = "Feet"; break; + case 9: slotName = "Wrist"; break; + case 10: slotName = "Hands"; break; + case 11: slotName = "Finger"; break; + case 12: slotName = "Trinket"; break; + case 13: slotName = "One-Hand"; break; + case 14: slotName = "Shield"; break; + case 15: slotName = "Ranged"; break; + case 16: slotName = "Back"; break; + case 17: slotName = "Two-Hand"; break; + case 18: slotName = "Bag"; break; + case 19: slotName = "Tabard"; break; + case 20: slotName = "Robe"; break; + case 21: slotName = "Main Hand"; break; + case 22: slotName = "Off Hand"; break; + case 23: slotName = "Held In Off-hand"; break; + case 25: slotName = "Thrown"; break; + case 26: slotName = "Ranged"; break; + default: break; + } + if (slotName[0]) { + if (!info.subclassName.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info.subclassName.c_str()); + else + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); + } + } + + // Weapon stats + auto isWeaponInvType = [](uint32_t t) { + return t == 13 || t == 15 || t == 17 || t == 21 || t == 25 || t == 26; + }; + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + if (isWeaponInvType(info.inventoryType) && info.damageMax > 0.0f && info.delayMs > 0) { + float speed = static_cast(info.delayMs) / 1000.0f; + float dps = ((info.damageMin + info.damageMax) * 0.5f) / speed; + ImGui::Text("%.0f - %.0f Damage", info.damageMin, info.damageMax); + ImGui::SameLine(160.0f); + ImGui::TextDisabled("Speed %.2f", speed); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps); + } + + if (info.armor > 0) ImGui::Text("%d Armor", info.armor); + + auto appendBonus = [](std::string& out, int32_t val, const char* name) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " " + name; + }; + std::string bonusLine; + appendBonus(bonusLine, info.strength, "Str"); + appendBonus(bonusLine, info.agility, "Agi"); + appendBonus(bonusLine, info.stamina, "Sta"); + appendBonus(bonusLine, info.intellect, "Int"); + appendBonus(bonusLine, info.spirit, "Spi"); + if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); + + // Extra stats + for (const auto& es : info.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 16: case 17: case 18: case 31: statName = "Hit Rating"; break; + case 19: case 20: case 21: case 32: statName = "Crit Rating"; break; + case 28: case 29: case 30: case 36: statName = "Haste Rating"; break; + case 35: statName = "Resilience"; break; + case 37: statName = "Expertise Rating"; break; + case 38: statName = "Attack Power"; break; + case 39: statName = "Ranged Attack Power"; break; + case 41: statName = "Healing Power"; break; + case 42: statName = "Spell Damage"; break; + case 43: statName = "Mana per 5 sec"; break; + case 44: statName = "Armor Penetration"; break; + case 45: statName = "Spell Power"; break; + case 46: statName = "Health per 5 sec"; break; + case 47: statName = "Spell Penetration"; break; + case 48: statName = "Block Value"; break; + default: statName = nullptr; break; + } + char buf[64]; + if (statName) + std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName); + else + std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType); + ImGui::TextColored(green, "%s", buf); + } + + if (info.requiredLevel > 1) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", info.requiredLevel); + } + + // Spell effects + for (const auto& sp : info.spells) { + if (sp.spellId == 0) continue; + const char* trigger = nullptr; + switch (sp.spellTrigger) { + case 0: trigger = "Use"; break; + case 1: trigger = "Equip"; break; + case 2: trigger = "Chance on Hit"; break; + default: break; + } + if (!trigger) continue; + if (gameHandler_) { + const std::string& spName = gameHandler_->getSpellName(sp.spellId); + if (!spName.empty()) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str()); + else + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); + } + } + + if (info.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + if (!info.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", info.description.c_str()); + } + + if (info.sellPrice > 0) { + uint32_t g = info.sellPrice / 10000; + uint32_t s = (info.sellPrice / 100) % 100; + uint32_t c = info.sellPrice % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + } + + ImGui::EndTooltip(); +} + } // namespace ui } // namespace wowee