diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index de137ad8..73d50a87 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -113,5 +113,8 @@ "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, "Threshold8": 46, "Threshold9": 47 + }, + "LFGDungeons": { + "ID": 0, "Name": 1 } } diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index b35422a3..1628b94c 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -23,6 +23,8 @@ "UNIT_FIELD_STAT3": 87, "UNIT_FIELD_STAT4": 88, "UNIT_END": 148, + "UNIT_FIELD_ATTACK_POWER": 123, + "UNIT_FIELD_RANGED_ATTACK_POWER": 126, "PLAYER_FLAGS": 150, "PLAYER_BYTES": 153, "PLAYER_BYTES_2": 154, @@ -38,6 +40,15 @@ "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, "PLAYER_CHOSEN_TITLE": 1349, + "PLAYER_FIELD_MOD_DAMAGE_DONE_POS": 1171, + "PLAYER_FIELD_MOD_HEALING_DONE_POS": 1192, + "PLAYER_BLOCK_PERCENTAGE": 1024, + "PLAYER_DODGE_PERCENTAGE": 1025, + "PLAYER_PARRY_PERCENTAGE": 1026, + "PLAYER_CRIT_PERCENTAGE": 1029, + "PLAYER_RANGED_CRIT_PERCENTAGE": 1030, + "PLAYER_SPELL_CRIT_PERCENTAGE1": 1032, + "PLAYER_FIELD_COMBAT_RATING_1": 1231, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_DURABILITY": 60, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3481285b..1f04eb22 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -38,8 +38,11 @@ namespace game { struct PlayerSkill { uint32_t skillId = 0; - uint16_t value = 0; + uint16_t value = 0; // base + permanent item bonuses uint16_t maxValue = 0; + uint16_t bonusTemp = 0; // temporary buff bonus (food, potions, etc.) + uint16_t bonusPerm = 0; // permanent spec/misc bonus (rarely non-zero) + uint16_t effectiveValue() const { return value + bonusTemp + bonusPerm; } }; /** @@ -218,6 +221,7 @@ public: pos = homeBindPos_; return true; } + uint32_t getHomeBindZoneId() const { return homeBindZoneId_; } /** * Send a movement packet @@ -283,6 +287,7 @@ public: * @return Vector of chat messages */ const std::deque& getChatHistory() const { return chatHistory; } + void clearChatHistory() { chatHistory.clear(); } /** * Add a locally-generated chat message (e.g., emote feedback) @@ -309,6 +314,43 @@ public: return playerStats_[idx]; } + // Server-authoritative attack power (WotLK: UNIT_FIELD_ATTACK_POWER / RANGED). + // Returns -1 if not yet received. + int32_t getMeleeAttackPower() const { return playerMeleeAP_; } + int32_t getRangedAttackPower() const { return playerRangedAP_; } + + // Server-authoritative spell damage / healing bonus (WotLK: PLAYER_FIELD_MOD_*). + // getSpellPower returns the max damage bonus across magic schools 1-6 (Holy/Fire/Nature/Frost/Shadow/Arcane). + // Returns -1 if not yet received. + int32_t getSpellPower() const { + int32_t sp = -1; + for (int i = 1; i <= 6; ++i) { + if (playerSpellDmgBonus_[i] > sp) sp = playerSpellDmgBonus_[i]; + } + return sp; + } + int32_t getHealingPower() const { return playerHealBonus_; } + + // Server-authoritative combat chance percentages (WotLK: PLAYER_* float fields). + // Returns -1.0f if not yet received. + float getDodgePct() const { return playerDodgePct_; } + float getParryPct() const { return playerParryPct_; } + float getBlockPct() const { return playerBlockPct_; } + float getCritPct() const { return playerCritPct_; } + float getRangedCritPct() const { return playerRangedCritPct_; } + // Spell crit by school (0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane) + float getSpellCritPct(int school = 1) const { + if (school < 0 || school > 6) return -1.0f; + return playerSpellCritPct_[school]; + } + + // Server-authoritative combat ratings (WotLK: PLAYER_FIELD_COMBAT_RATING_1+idx). + // Returns -1 if not yet received. Indices match AzerothCore CombatRating enum. + int32_t getCombatRating(int cr) const { + if (cr < 0 || cr > 24) return -1; + return playerCombatRatings_[cr]; + } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -402,6 +444,8 @@ public: uint8_t arenaType = 0; uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress uint32_t inviteTimeout = 80; + uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE) + uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE) std::chrono::steady_clock::time_point inviteReceivedTime{}; }; @@ -457,6 +501,8 @@ public: // Logout commands void requestLogout(); void cancelLogout(); + bool isLoggingOut() const { return loggingOut_; } + float getLogoutCountdown() const { return logoutCountdown_; } // Stand state void setStandState(uint8_t state); // 0=stand, 1=sit, 2=sit_chair, 3=sleep, 4=sit_low_chair, 5=sit_medium_chair, 6=sit_high_chair, 7=dead, 8=kneel, 9=submerged @@ -1089,6 +1135,10 @@ public: const Character* ch = getActiveCharacter(); return ch ? static_cast(ch->characterClass) : 0; } + uint8_t getPlayerRace() const { + const Character* ch = getActiveCharacter(); + return ch ? static_cast(ch->race) : 0; + } void setPlayerGuid(uint64_t guid) { playerGuid = guid; } // Player death state @@ -1274,6 +1324,8 @@ public: bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } bool isLfgInDungeon() const { return lfgState_ == LfgState::InDungeon; } uint32_t getLfgDungeonId() const { return lfgDungeonId_; } + std::string getCurrentLfgDungeonName() const { return getLfgDungeonName(lfgDungeonId_); } + std::string getMapName(uint32_t mapId) const; uint32_t getLfgProposalId() const { return lfgProposalId_; } int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } @@ -1833,6 +1885,7 @@ public: bool isTaxiMountActive() const { return taxiMountActive_; } bool isTaxiActivationPending() const { return taxiActivatePending_; } void forceClearTaxiAndMovementState(); + const std::string& getTaxiDestName() const { return taxiDestName_; } const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; } uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; } @@ -2314,7 +2367,8 @@ private: void handleLogoutResponse(network::Packet& packet); void handleLogoutComplete(network::Packet& packet); - void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource); + void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0, + uint64_t srcGuid = 0, uint64_t dstGuid = 0); void addSystemChatMessage(const std::string& message); /** @@ -2421,6 +2475,7 @@ private: uint32_t currentMapId_ = 0; bool hasHomeBind_ = false; uint32_t homeBindMapId_ = 0; + uint32_t homeBindZoneId_ = 0; glm::vec3 homeBindPos_{0.0f}; // ---- Phase 1: Name caches ---- @@ -2448,7 +2503,8 @@ private: std::unordered_map ignoreCache; // name -> guid // ---- Logout state ---- - bool loggingOut_ = false; + bool loggingOut_ = false; + float logoutCountdown_ = 0.0f; // seconds remaining before server logs us out (0 = instant/done) // ---- Display state ---- bool helmVisible_ = true; @@ -2778,6 +2834,9 @@ private: float timer = 0.0f; }; std::vector pendingGameObjectLootOpens_; + // Tracks the last GO we sent CMSG_GAMEOBJ_USE to; used in handleSpellGo + // to send CMSG_LOOT after a gather cast (mining/herbalism) completes. + uint64_t lastInteractedGoGuid_ = 0; uint64_t pendingLootMoneyGuid_ = 0; uint32_t pendingLootMoneyAmount_ = 0; float pendingLootMoneyNotifyTimer_ = 0.0f; @@ -2787,6 +2846,18 @@ private: 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}; + // WotLK secondary combat stats (-1 = not yet received) + int32_t playerMeleeAP_ = -1; + int32_t playerRangedAP_ = -1; + int32_t playerSpellDmgBonus_[7] = {-1,-1,-1,-1,-1,-1,-1}; // per school 0-6 + int32_t playerHealBonus_ = -1; + float playerDodgePct_ = -1.0f; + float playerParryPct_ = -1.0f; + float playerBlockPct_ = -1.0f; + float playerCritPct_ = -1.0f; + float playerRangedCritPct_ = -1.0f; + float playerSpellCritPct_[7] = {-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f}; + int32_t playerCombatRatings_[25] = {-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating // money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime. uint32_t pendingMoneyDelta_ = 0; @@ -2845,6 +2916,7 @@ private: ShowTaxiNodesData currentTaxiData_; uint64_t taxiNpcGuid_ = 0; bool onTaxiFlight_ = false; + std::string taxiDestName_; bool taxiMountActive_ = false; uint32_t taxiMountDisplayId_ = 0; bool taxiActivatePending_ = false; @@ -2968,6 +3040,17 @@ private: bool areaNameCacheLoaded_ = false; void loadAreaNameCache(); std::string getAreaName(uint32_t areaId) const; + + // Map name cache (lazy-loaded from Map.dbc; maps mapId → localized display name) + std::unordered_map mapNameCache_; + bool mapNameCacheLoaded_ = false; + void loadMapNameCache(); + + // LFG dungeon name cache (lazy-loaded from LFGDungeons.dbc; WotLK only) + std::unordered_map lfgDungeonNameCache_; + bool lfgDungeonNameCacheLoaded_ = false; + void loadLfgDungeonDbc(); + std::string getLfgDungeonName(uint32_t dungeonId) const; std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index 7a3bcb8c..ac6af201 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -15,6 +15,8 @@ enum class ItemQuality : uint8_t { RARE = 3, // Blue EPIC = 4, // Purple LEGENDARY = 5, // Orange + ARTIFACT = 6, // Yellow (unused in 3.3.5a but valid quality value) + HEIRLOOM = 7, // Yellow/gold (WotLK bind-on-account heirlooms) }; enum class EquipSlot : uint8_t { diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index dc38f813..a3944a0e 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -52,13 +52,15 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER + ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER, + DISPEL, INTERRUPT }; Type type; int32_t amount = 0; uint32_t spellId = 0; float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this + uint8_t powerType = 0; // For ENERGIZE: 0=mana,1=rage,2=focus,3=energy,6=runicpower static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index bc8a53f6..5e42a049 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -42,6 +42,10 @@ enum class UF : uint16_t { UNIT_FIELD_STAT4, // Spirit UNIT_END, + // Unit combat fields (WotLK: PRIVATE+OWNER — only visible for the player character) + UNIT_FIELD_ATTACK_POWER, // Melee attack power (int32) + UNIT_FIELD_RANGED_ATTACK_POWER, // Ranged attack power (int32) + // Player fields PLAYER_FLAGS, PLAYER_BYTES, @@ -59,6 +63,19 @@ enum class UF : uint16_t { PLAYER_EXPLORED_ZONES_START, PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) + // Player spell power / healing bonus (WotLK: PRIVATE — int32 per school) + PLAYER_FIELD_MOD_DAMAGE_DONE_POS, // Spell damage bonus (first of 7 schools) + PLAYER_FIELD_MOD_HEALING_DONE_POS, // Healing bonus + + // Player combat stats (WotLK: PRIVATE — float values) + PLAYER_BLOCK_PERCENTAGE, // Block chance % + PLAYER_DODGE_PERCENTAGE, // Dodge chance % + PLAYER_PARRY_PERCENTAGE, // Parry chance % + PLAYER_CRIT_PERCENTAGE, // Melee crit chance % + PLAYER_RANGED_CRIT_PERCENTAGE, // Ranged crit chance % + PLAYER_SPELL_CRIT_PERCENTAGE1, // Spell crit chance % (first school; 7 consecutive float fields) + PLAYER_FIELD_COMBAT_RATING_1, // First of 25 int32 combat rating slots (CR_* indices) + // GameObject fields GAMEOBJECT_DISPLAYID, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index e5c7e63c..d864b57e 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -947,6 +947,21 @@ public: static network::Packet build(uint8_t state); }; +// ============================================================ +// Action Bar +// ============================================================ + +/** CMSG_SET_ACTION_BUTTON packet builder */ +class SetActionButtonPacket { +public: + // button: 0-based slot index + // type: ActionBarSlot::Type (SPELL=0, ITEM=1, MACRO=2, EMPTY=0) + // id: spellId, itemId, or macroId (0 to clear) + // isClassic: true for Vanilla/Turtle format (5-byte payload), + // false for TBC/WotLK (5-byte packed uint32) + static network::Packet build(uint8_t button, uint8_t type, uint32_t id, bool isClassic); +}; + // ============================================================ // Display Toggles // ============================================================ @@ -1572,13 +1587,21 @@ struct ItemQueryResponseData { uint32_t subClass = 0; uint32_t displayInfoId = 0; uint32_t quality = 0; + uint32_t itemFlags = 0; // Item flag bitmask (Heroic=0x8, Unique-Equipped=0x1000000) uint32_t inventoryType = 0; + int32_t maxCount = 0; // Max that can be carried (1 = Unique, 0 = unlimited) int32_t maxStack = 1; uint32_t containerSlots = 0; float damageMin = 0.0f; float damageMax = 0.0f; uint32_t delayMs = 0; int32_t armor = 0; + int32_t holyRes = 0; + int32_t fireRes = 0; + int32_t natureRes = 0; + int32_t frostRes = 0; + int32_t shadowRes = 0; + int32_t arcaneRes = 0; int32_t stamina = 0; int32_t strength = 0; int32_t agility = 0; @@ -1604,6 +1627,13 @@ struct ItemQueryResponseData { 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 + // Requirement fields + uint32_t requiredSkill = 0; // SkillLine.dbc ID (0 = no skill required) + uint32_t requiredSkillRank = 0; // Minimum skill value + uint32_t allowableClass = 0; // Class bitmask (0 = all classes) + uint32_t allowableRace = 0; // Race bitmask (0 = all races) + uint32_t requiredReputationFaction = 0; // Faction.dbc ID (0 = none) + uint32_t requiredReputationRank = 0; // 0=Hated..8=Exalted bool valid = false; }; diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index a198b0c7..dd727caf 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -39,6 +39,7 @@ class StarField; class Clouds; class LensFlare; class Weather; +class Lightning; class LightingManager; class SwimEffects; class MountDust; @@ -127,6 +128,7 @@ public: Clouds* getClouds() const { return skySystem ? skySystem->getClouds() : nullptr; } LensFlare* getLensFlare() const { return skySystem ? skySystem->getLensFlare() : nullptr; } Weather* getWeather() const { return weather.get(); } + Lightning* getLightning() const { return lightning.get(); } CharacterRenderer* getCharacterRenderer() const { return characterRenderer.get(); } WMORenderer* getWMORenderer() const { return wmoRenderer.get(); } M2Renderer* getM2Renderer() const { return m2Renderer.get(); } @@ -216,6 +218,7 @@ private: std::unique_ptr clouds; std::unique_ptr lensFlare; std::unique_ptr weather; + std::unique_ptr lightning; std::unique_ptr lightingManager; std::unique_ptr skySystem; // Coordinator for sky rendering std::unique_ptr swimEffects; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e8fbca0f..f22ba4da 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -348,6 +348,7 @@ private: void renderTrainerWindow(game::GameHandler& gameHandler); void renderStableWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); + void renderLogoutCountdown(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 7a40f43b..65ef41c9 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -149,7 +149,8 @@ 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* serverResists = nullptr); + const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr, + const game::GameHandler* gh = 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 90fa6a12..13e6171b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -960,6 +960,12 @@ void GameHandler::update(float deltaTime) { updateCombatText(deltaTime); tickMinimapPings(deltaTime); + // Tick logout countdown + if (loggingOut_ && logoutCountdown_ > 0.0f) { + logoutCountdown_ -= deltaTime; + if (logoutCountdown_ < 0.0f) logoutCountdown_ = 0.0f; + } + // Update taxi landing cooldown if (taxiLandingCooldown_ > 0.0f) { taxiLandingCooldown_ -= deltaTime; @@ -1845,9 +1851,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), "Quest %u failed!", questId); - addSystemChatMessage(buf); + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + addSystemChatMessage(questTitle.empty() + ? std::string("Quest failed!") + : ('"' + questTitle + "\" failed!")); } break; } @@ -1855,9 +1864,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), "Quest %u timed out!", questId); - addSystemChatMessage(buf); + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + addSystemChatMessage(questTitle.empty() + ? std::string("Quest timed out!") + : ('"' + questTitle + "\" has timed out.")); } break; } @@ -2002,6 +2014,7 @@ void GameHandler::handlePacket(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + lastInteractedGoGuid_ = 0; // Pass player's power type so result 85 says "Not enough rage/energy/etc." int playerPowerType = -1; if (auto pe = entityManager.getEntity(playerGuid)) { @@ -2048,13 +2061,13 @@ void GameHandler::handlePacket(network::Packet& packet) { return UpdateObjectParser::readPackedGuid(packet); }; if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; - /*uint64_t caster =*/ readPrGuid(); + uint64_t caster = readPrGuid(); if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); if (victim == playerGuid) - addCombatText(CombatTextEntry::RESIST, 0, spellId, false); + addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); packet.setReadPos(packet.getSize()); break; } @@ -2083,6 +2096,10 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + // Ensure item info is queried so the roll popup can show the name/icon. + // The popup re-reads getItemInfo() live, so the name will populate once + // SMSG_ITEM_QUERY_SINGLE_RESPONSE arrives (usually within ~100 ms). + queryItemInfo(itemId, 0); auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; @@ -2185,17 +2202,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 binderGuid + uint32 mapId + uint32 zoneId if (packet.getSize() - packet.getReadPos() < 16) break; /*uint64_t binderGuid =*/ packet.readUInt64(); - uint32_t mapId = packet.readUInt32(); + uint32_t mapId = packet.readUInt32(); uint32_t zoneId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Your home location has been set (map %u, zone %u).", mapId, zoneId); - addSystemChatMessage(buf); + // Update home bind location so hearthstone tooltip reflects the new zone + homeBindMapId_ = mapId; + homeBindZoneId_ = zoneId; + std::string pbMsg = "Your home location has been set"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + pbMsg += " to " + zoneName; + pbMsg += '.'; + addSystemChatMessage(pbMsg); break; } case Opcode::SMSG_BINDER_CONFIRM: { - // uint64 npcGuid — server confirming bind point has been set - addSystemChatMessage("This innkeeper is now your home location."); + // uint64 npcGuid — fires just before SMSG_PLAYERBOUND; PLAYERBOUND shows + // the zone name so this confirm is redundant. Consume silently. packet.setReadPos(packet.getSize()); break; } @@ -2248,7 +2270,20 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Character name changed to: " + newName); } else { - addSystemChatMessage("Character rename failed (error " + std::to_string(result) + ")."); + // ResponseCodes for name changes (shared with char create) + static const char* kRenameErrors[] = { + nullptr, // 0 = success + "Name already in use.", // 1 + "Name too short.", // 2 + "Name too long.", // 3 + "Name contains invalid characters.", // 4 + "Name contains a profanity.", // 5 + "Name is reserved.", // 6 + "Character name does not meet requirements.", // 7 + }; + const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; + addSystemChatMessage(errMsg ? std::string("Rename failed: ") + errMsg + : "Character rename failed."); } LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); } @@ -2337,6 +2372,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Server forces player into dead state (GM command, scripted event, etc.) playerDead_ = true; if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet + addSystemChatMessage("You have been killed."); LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); packet.setReadPos(packet.getSize()); break; @@ -2629,7 +2665,7 @@ void GameHandler::handlePacket(network::Packet& packet) { count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; - /*uint64_t victimGuid =*/ readSpellMissGuid(); + uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult @@ -2642,21 +2678,24 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } } - // Show combat text only for local player's spell misses + static const CombatTextEntry::Type missTypes[] = { + CombatTextEntry::MISS, // 0=MISS + CombatTextEntry::DODGE, // 1=DODGE + CombatTextEntry::PARRY, // 2=PARRY + CombatTextEntry::BLOCK, // 3=BLOCK + CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::IMMUNE, // 5=IMMUNE + CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::ABSORB, // 7=ABSORB + CombatTextEntry::RESIST, // 8=RESIST + }; + CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; if (casterGuid == playerGuid) { - static const CombatTextEntry::Type missTypes[] = { - CombatTextEntry::MISS, // 0=MISS - CombatTextEntry::DODGE, // 1=DODGE - CombatTextEntry::PARRY, // 2=PARRY - CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE - CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::ABSORB, // 7=ABSORB - CombatTextEntry::RESIST, // 8=RESIST - }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; - addCombatText(ct, 0, 0, true); + // We cast a spell and it missed the target + addCombatText(ct, 0, 0, true, 0, casterGuid, victimGuid); + } else if (victimGuid == playerGuid) { + // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) + addCombatText(ct, 0, 0, false, 0, casterGuid, victimGuid); } } break; @@ -2672,12 +2711,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t absorb = packet.readUInt32(); uint32_t resist = packet.readUInt32(); if (victimGuid == playerGuid) { + // Environmental damage: no caster GUID, victim = player if (damage > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false, 0, 0, victimGuid); if (absorb > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false); + addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false, 0, 0, victimGuid); if (resist > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false); + addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false, 0, 0, victimGuid); } break; } @@ -2990,7 +3030,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const size_t remainingFields = isClassic ? 5u : 6u; // spellId(4)+reason(1) [+castCount(1)] if (packet.getSize() - packet.getReadPos() >= remainingFields) { if (!isClassic) /*uint8_t castCount =*/ packet.readUInt8(); - /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t failSpellId = packet.readUInt32(); uint8_t rawFailReason = packet.readUInt8(); // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; @@ -3002,20 +3042,26 @@ void GameHandler::handlePacket(network::Packet& packet) { pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); if (reason) { - addUIError(reason); + // Prefix with spell name for context, e.g. "Fireball: Not in range" + const std::string& sName = getSpellName(failSpellId); + std::string fullMsg = sName.empty() ? reason + : sName + ": " + reason; + addUIError(fullMsg); MessageChatData emsg; emsg.type = ChatType::SYSTEM; emsg.language = ChatLanguage::UNIVERSAL; - emsg.message = reason; + emsg.message = std::move(fullMsg); addLocalChatMessage(emsg); } } } if (failGuid == playerGuid || failGuid == 0) { - // Player's own cast failed + // Player's own cast failed — clear gather-node loot target so the + // next timed cast doesn't try to loot a stale interrupted gather node. casting = false; castIsChannel = false; currentCastSpellId = 0; + lastInteractedGoGuid_ = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); @@ -3158,20 +3204,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // [+ count × uint32 failedSpellId] const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint32_t dispelSpellId = 0; + uint64_t dispelCasterGuid = 0; if (dispelTbcLike) { if (packet.getSize() - packet.getReadPos() < 20) break; - /*uint64_t caster =*/ packet.readUInt64(); + dispelCasterGuid = packet.readUInt64(); /*uint64_t victim =*/ packet.readUInt64(); dispelSpellId = packet.readUInt32(); } else { if (packet.getSize() - packet.getReadPos() < 4) break; dispelSpellId = packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < 1) break; - /*uint64_t caster =*/ UpdateObjectParser::readPackedGuid(packet); + dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); } - { + // Only show failure to the player who attempted the dispel + if (dispelCasterGuid == playerGuid) { loadSpellNameCache(); auto it = spellNameCache_.find(dispelSpellId); char buf[128]; @@ -3221,8 +3269,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DURABILITY_DAMAGE_DEATH: { // uint32 percent (how much durability was lost due to death) if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t pct =*/ packet.readUInt32(); - addSystemChatMessage("You have lost 10% of your gear's durability due to death."); + uint32_t pct = packet.readUInt32(); + char buf[80]; + std::snprintf(buf, sizeof(buf), + "You have lost %u%% of your gear's durability due to death.", pct); + addSystemChatMessage(buf); } break; } @@ -3499,12 +3550,18 @@ void GameHandler::handlePacket(network::Packet& packet) { bool wasSet = hasHomeBind_; hasHomeBind_ = true; homeBindMapId_ = data.mapId; + homeBindZoneId_ = data.zoneId; homeBindPos_ = canonical; if (bindPointCallback_) { bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); } if (wasSet) { - addSystemChatMessage("Your home has been set."); + std::string bindMsg = "Your home has been set"; + std::string zoneName = getAreaName(data.zoneId); + if (!zoneName.empty()) + bindMsg += " to " + zoneName; + bindMsg += '.'; + addSystemChatMessage(bindMsg); } } else { LOG_WARNING("Failed to parse SMSG_BINDPOINTUPDATE"); @@ -3802,10 +3859,27 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_EQUIPMENT_SET_SAVED: + case Opcode::SMSG_EQUIPMENT_SET_SAVED: { // uint32 setIndex + uint64 guid — equipment set was successfully saved + std::string setName; + if (packet.getSize() - packet.getReadPos() >= 12) { + uint32_t setIndex = packet.readUInt32(); + uint64_t setGuid = packet.readUInt64(); + for (const auto& es : equipmentSets_) { + if (es.setGuid == setGuid || + (es.setGuid == 0 && es.setId == setIndex)) { + setName = es.name; + break; + } + } + (void)setIndex; + } + addSystemChatMessage(setName.empty() + ? std::string("Equipment set saved.") + : "Equipment set \"" + setName + "\" saved."); LOG_DEBUG("Equipment set saved"); break; + } case Opcode::SMSG_PERIODICAURALOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects @@ -3842,13 +3916,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t res = packet.readUInt32(); if (dmg > 0) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (abs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(abs), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (res > 0) addCombatText(CombatTextEntry::RESIST, static_cast(res), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); } else if (auraType == 8 || auraType == 124 || auraType == 45) { // Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes // WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes @@ -3864,19 +3938,19 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint8_t isCrit=*/ packet.readUInt8(); } addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (hotAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), - spellId, isPlayerCaster); + spellId, isPlayerCaster, 0, casterGuid, victimGuid); } else if (auraType == 46 || auraType == 91) { // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. if (packet.getSize() - packet.getReadPos() < 8) break; - /*uint32_t powerType =*/ packet.readUInt32(); + uint8_t periodicPowerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); if ((isPlayerVictim || isPlayerCaster) && amount > 0) addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), - spellId, isPlayerCaster); + spellId, isPlayerCaster, periodicPowerType, casterGuid, victimGuid); } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; @@ -3886,7 +3960,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Show as periodic damage from victim's perspective (mana drained) if (isPlayerVictim && amount > 0) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), - spellId, false); + spellId, false, 0, casterGuid, victimGuid); } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -3909,13 +3983,13 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); rem = packet.getSize() - packet.getReadPos(); if (rem < 6) { packet.setReadPos(packet.getSize()); break; } - uint32_t spellId = packet.readUInt32(); - /*uint8_t powerType =*/ packet.readUInt8(); - int32_t amount = static_cast(packet.readUInt32()); + uint32_t spellId = packet.readUInt32(); + uint8_t energizePowerType = packet.readUInt8(); + int32_t amount = static_cast(packet.readUInt32()); bool isPlayerVictim = (victimGuid == playerGuid); bool isPlayerCaster = (casterGuid == playerGuid); if ((isPlayerVictim || isPlayerCaster) && amount > 0) - addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster); + addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType, casterGuid, victimGuid); packet.setReadPos(packet.getSize()); break; } @@ -3929,12 +4003,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t envAbs = packet.readUInt32(); uint32_t envRes = packet.readUInt32(); if (victimGuid == playerGuid) { + // Environmental damage: no caster GUID, victim = player if (dmg > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, 0, 0, victimGuid); if (envAbs > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false); + addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false, 0, 0, victimGuid); if (envRes > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false); + addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false, 0, 0, victimGuid); } packet.setReadPos(packet.getSize()); break; @@ -4028,8 +4103,9 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } } - if (newLevel > oldLevel && levelUpCallback_) { - levelUpCallback_(newLevel); + if (newLevel > oldLevel) { + addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); + if (levelUpCallback_) levelUpCallback_(newLevel); } } } @@ -4369,12 +4445,20 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount // Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT. if (packet.getSize() - packet.getReadPos() >= 20) { - uint64_t vendorGuid = packet.readUInt64(); - uint32_t vendorSlot = packet.readUInt32(); - int32_t newCount = static_cast(packet.readUInt32()); + /*uint64_t vendorGuid =*/ packet.readUInt64(); + /*uint32_t vendorSlot =*/ packet.readUInt32(); + /*int32_t newCount =*/ static_cast(packet.readUInt32()); uint32_t itemCount = packet.readUInt32(); - LOG_DEBUG("SMSG_BUY_ITEM: vendorGuid=0x", std::hex, vendorGuid, std::dec, - " slot=", vendorSlot, " newCount=", newCount, " bought=", itemCount); + // Show purchase confirmation with item name if available + if (pendingBuyItemId_ != 0) { + std::string itemLabel; + if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) + if (!info->name.empty()) itemLabel = info->name; + if (itemLabel.empty()) itemLabel = "item #" + std::to_string(pendingBuyItemId_); + std::string msg = "Purchased: " + itemLabel; + if (itemCount > 1) msg += " x" + std::to_string(itemCount); + addSystemChatMessage(msg); + } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; } @@ -4430,10 +4514,26 @@ void GameHandler::handlePacket(network::Packet& packet) { float wIntensity = packet.readFloat(); if (packet.getSize() - packet.getReadPos() >= 1) /*uint8_t isAbrupt =*/ packet.readUInt8(); + uint32_t prevWeatherType = weatherType_; weatherType_ = wType; weatherIntensity_ = wIntensity; const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); + // Announce weather changes (including initial zone weather) + if (wType != prevWeatherType) { + const char* weatherMsg = nullptr; + if (wIntensity < 0.05f || wType == 0) { + if (prevWeatherType != 0) + weatherMsg = "The weather clears."; + } else if (wType == 1) { + weatherMsg = "It begins to rain."; + } else if (wType == 2) { + weatherMsg = "It begins to snow."; + } else if (wType == 3) { + weatherMsg = "A storm rolls in."; + } + if (weatherMsg) addSystemChatMessage(weatherMsg); + } // 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 @@ -4525,6 +4625,25 @@ void GameHandler::handlePacket(network::Packet& packet) { if (gameObjectCustomAnimCallback_) { gameObjectCustomAnimCallback_(guid, animId); } + // animId == 0 is the fishing bobber splash ("fish hooked"). + // Detect by GO type 17 (FISHINGNODE) and notify the player so they + // know to click the bobber before the fish escapes. + if (animId == 0) { + auto goEnt = entityManager.getEntity(guid); + if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(goEnt); + auto* info = getCachedGameObjectInfo(go->getEntry()); + if (info && info->type == 17) { // GO_TYPE_FISHINGNODE + addSystemChatMessage("A fish is on your line!"); + // Play a distinctive UI sound to alert the player + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) { + sfx->playQuestUpdate(); // Distinct "ping" sound + } + } + } + } + } } break; } @@ -5126,17 +5245,18 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t msgType = packet.readUInt32(); uint32_t mapId = packet.readUInt32(); /*uint32_t diff =*/ packet.readUInt32(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); // type: 1=warning(time left), 2=saved, 3=welcome if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) { uint32_t timeLeft = packet.readUInt32(); uint32_t minutes = timeLeft / 60; - std::string msg = "Instance " + std::to_string(mapId) + - " will reset in " + std::to_string(minutes) + " minute(s)."; - addSystemChatMessage(msg); + addSystemChatMessage(mapLabel + " will reset in " + + std::to_string(minutes) + " minute(s)."); } else if (msgType == 2) { - addSystemChatMessage("You have been saved to instance " + std::to_string(mapId) + "."); + addSystemChatMessage("You have been saved to " + mapLabel + "."); } else if (msgType == 3) { - addSystemChatMessage("Welcome to instance " + std::to_string(mapId) + "."); + addSystemChatMessage("Welcome to " + mapLabel + "."); } LOG_INFO("SMSG_RAID_INSTANCE_MESSAGE: type=", msgType, " map=", mapId); } @@ -5149,7 +5269,9 @@ void GameHandler::handlePacket(network::Packet& packet) { auto it = std::remove_if(instanceLockouts_.begin(), instanceLockouts_.end(), [mapId](const InstanceLockout& lo){ return lo.mapId == mapId; }); instanceLockouts_.erase(it, instanceLockouts_.end()); - addSystemChatMessage("Instance " + std::to_string(mapId) + " has been reset."); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addSystemChatMessage(mapLabel + " has been reset."); LOG_INFO("SMSG_INSTANCE_RESET: mapId=", mapId); } break; @@ -5162,8 +5284,10 @@ void GameHandler::handlePacket(network::Packet& packet) { "Not max level.", "Offline party members.", "Party members inside.", "Party members changing zone.", "Heroic difficulty only." }; - const char* msg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; - addSystemChatMessage("Cannot reset instance " + std::to_string(mapId) + ": " + msg); + const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason); } break; @@ -5172,16 +5296,30 @@ void GameHandler::handlePacket(network::Packet& packet) { // Server asks player to confirm entering a saved instance. // We auto-confirm with CMSG_INSTANCE_LOCK_RESPONSE. if (socket && packet.getSize() - packet.getReadPos() >= 17) { - /*uint32_t mapId =*/ packet.readUInt32(); - /*uint32_t diff =*/ packet.readUInt32(); - /*uint32_t timeLeft =*/ packet.readUInt32(); + uint32_t ilMapId = packet.readUInt32(); + uint32_t ilDiff = packet.readUInt32(); + uint32_t ilTimeLeft = packet.readUInt32(); packet.readUInt32(); // unk - /*uint8_t locked =*/ packet.readUInt8(); + uint8_t ilLocked = packet.readUInt8(); + // Notify player which instance is being entered/resumed + std::string ilName = getMapName(ilMapId); + if (ilName.empty()) ilName = "instance #" + std::to_string(ilMapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + std::string ilMsg = "Entering " + ilName; + if (ilDiff < 4) ilMsg += std::string(" (") + kDiff[ilDiff] + ")"; + if (ilLocked && ilTimeLeft > 0) { + uint32_t ilMins = ilTimeLeft / 60; + ilMsg += " — " + std::to_string(ilMins) + " min remaining."; + } else { + ilMsg += "."; + } + addSystemChatMessage(ilMsg); // Send acceptance network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE)); resp.writeUInt8(1); // 1=accept socket->send(resp); - LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted"); + LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted mapId=", ilMapId, + " diff=", ilDiff, " timeLeft=", ilTimeLeft); } break; } @@ -5382,13 +5520,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 9) break; uint64_t memberGuid = packet.readUInt64(); uint8_t memberFlags = packet.readUInt8(); - // Look up the name from our entity manager + // Look up the name: entity manager > playerNameCache auto entity = entityManager.getEntity(memberGuid); - std::string name = "(unknown)"; + std::string name; if (entity) { auto player = std::dynamic_pointer_cast(entity); if (player && !player->getName().empty()) name = player->getName(); } + if (name.empty()) { + auto nit = playerNameCache.find(memberGuid); + if (nit != playerNameCache.end()) name = nit->second; + } + if (name.empty()) name = "(unknown)"; std::string entry = " " + name; if (memberFlags & 0x01) entry += " [Moderator]"; if (memberFlags & 0x02) entry += " [Muted]"; @@ -5433,16 +5576,21 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: { // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ... + // action: 0=sold/won, 1=expired, 2=bid placed on your auction if (packet.getSize() - packet.getReadPos() >= 16) { - uint32_t auctionId = packet.readUInt32(); - uint32_t action = packet.readUInt32(); - uint32_t error = packet.readUInt32(); + /*uint32_t auctionId =*/ packet.readUInt32(); + uint32_t action = packet.readUInt32(); + /*uint32_t error =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - (void)auctionId; (void)action; (void)error; ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); - addSystemChatMessage("Your auction of " + itemName + " has sold!"); + if (action == 1) + addSystemChatMessage("Your auction of " + itemName + " has expired."); + else if (action == 2) + addSystemChatMessage("A bid has been placed on your auction of " + itemName + "."); + else + addSystemChatMessage("Your auction of " + itemName + " has sold!"); } packet.setReadPos(packet.getSize()); break; @@ -5736,9 +5884,15 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t levelMin = packet.readUInt8(); uint8_t levelMax = packet.readUInt8(); char buf[128]; - std::snprintf(buf, sizeof(buf), - "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", - zoneId, levelMin, levelMax); + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for %s (levels %u-%u).", + zoneName.c_str(), levelMin, levelMax); + else + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", + zoneId, levelMin, levelMax); addSystemChatMessage(buf); LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, " levels=", (int)levelMin, "-", (int)levelMax); @@ -5950,18 +6104,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } - /*uint32_t spellId =*/ packet.readUInt32(); - uint32_t damage = packet.readUInt32(); + uint32_t shieldSpellId = packet.readUInt32(); + uint32_t damage = packet.readUInt32(); if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect if (casterGuid == playerGuid) { // We have a damage shield that reflected damage - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, true); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // A damage shield hit us (e.g. target's Thorns) - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, false); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, false, 0, casterGuid, victimGuid); } break; } @@ -5979,13 +6133,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = immuneTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; - /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t immuneSpellId = packet.readUInt32(); /*uint8_t saveType =*/ packet.readUInt8(); // Show IMMUNE text when the player is the caster (we hit an immune target) // or the victim (we are immune) if (casterGuid == playerGuid || victimGuid == playerGuid) { - addCombatText(CombatTextEntry::IMMUNE, 0, 0, - casterGuid == playerGuid); + addCombatText(CombatTextEntry::IMMUNE, 0, immuneSpellId, + casterGuid == playerGuid, 0, casterGuid, victimGuid); } break; } @@ -6006,20 +6160,22 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); uint32_t count = packet.readUInt32(); + // Collect first dispelled spell id/name; process all entries for combat log + // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) + uint32_t firstDispelledId = 0; + std::string firstSpellName; + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { + uint32_t dispelledId = packet.readUInt32(); + /*uint8_t isPositive =*/ packet.readUInt8(); + if (i == 0) { + firstDispelledId = dispelledId; + const std::string& nm = getSpellName(dispelledId); + firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + } + } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { const char* verb = isStolen ? "stolen" : "dispelled"; - // Collect first dispelled spell name for the message - // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) - std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { - uint32_t dispelledId = packet.readUInt32(); - /*uint8_t isPositive =*/ packet.readUInt8(); - if (i == 0) { - const std::string& nm = getSpellName(dispelledId); - firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; - } - } if (!firstSpellName.empty()) { char buf[256]; if (victimGuid == playerGuid && casterGuid != playerGuid) @@ -6030,6 +6186,12 @@ void GameHandler::handlePacket(network::Packet& packet) { std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); addSystemChatMessage(buf); } + // Add dispel event to combat log + if (firstDispelledId != 0) { + bool isPlayerCaster = (casterGuid == playerGuid); + addCombatText(CombatTextEntry::DISPEL, 0, firstDispelledId, isPlayerCaster, 0, + casterGuid, victimGuid); + } } packet.setReadPos(packet.getSize()); break; @@ -6044,7 +6206,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - /*uint64_t stealVictim =*/ stealTbcLike + uint64_t stealVictim = stealTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; @@ -6057,22 +6219,33 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t stealSpellId =*/ packet.readUInt32(); /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); - // Show feedback only when we are the caster (we stole something) - if (stealCaster == playerGuid) { - std::string stolenName; - for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { - uint32_t stolenId = packet.readUInt32(); - /*uint8_t isPos =*/ packet.readUInt8(); - if (i == 0) { - const std::string& nm = getSpellName(stolenId); - stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; - } + // Collect stolen spell info; show feedback when we are caster or victim + uint32_t firstStolenId = 0; + std::string stolenName; + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + uint32_t stolenId = packet.readUInt32(); + /*uint8_t isPos =*/ packet.readUInt8(); + if (i == 0) { + firstStolenId = stolenId; + const std::string& nm = getSpellName(stolenId); + stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; } + } + if (stealCaster == playerGuid || stealVictim == playerGuid) { if (!stolenName.empty()) { char buf[256]; - std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + if (stealCaster == playerGuid) + std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s was stolen.", stolenName.c_str()); addSystemChatMessage(buf); } + // Add dispel/steal to combat log using DISPEL type (isStolen=true for steals) + if (firstStolenId != 0) { + bool isPlayerCaster = (stealCaster == playerGuid); + addCombatText(CombatTextEntry::DISPEL, 0, firstStolenId, isPlayerCaster, 0, + stealCaster, stealVictim); + } } packet.setReadPos(packet.getSize()); break; @@ -6082,7 +6255,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 3) { packet.setReadPos(packet.getSize()); break; } - /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + uint64_t procTargetGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 2) { packet.setReadPos(packet.getSize()); break; } @@ -6093,7 +6266,8 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t procSpellId = packet.readUInt32(); // Show a "PROC!" floating text when the player triggers the proc if (procCasterGuid == playerGuid && procSpellId > 0) - addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true); + addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0, + procCasterGuid, procTargetGuid); packet.setReadPos(packet.getSize()); break; } @@ -6113,10 +6287,10 @@ void GameHandler::handlePacket(network::Packet& packet) { // Show kill/death feedback for the local player if (ikCaster == playerGuid) { // We killed a target instantly — show a KILL combat text hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true); + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { // We were instantly killed — show a large incoming hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false); + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false, 0, ikCaster, ikVictim); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, @@ -6166,9 +6340,11 @@ void GameHandler::handlePacket(network::Packet& packet) { /*float drainMult =*/ packet.readFloat(); if (drainAmount > 0) { if (drainTarget == playerGuid) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false); + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false, 0, + exeCaster, drainTarget); else if (isPlayerCaster) - addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true); + addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, drainTarget); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, " power=", drainPower, " amount=", drainAmount); @@ -6185,9 +6361,11 @@ void GameHandler::handlePacket(network::Packet& packet) { /*float leechMult =*/ packet.readFloat(); if (leechAmount > 0) { if (leechTarget == playerGuid) - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, + exeCaster, leechTarget); else if (isPlayerCaster) - addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true); + addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true, 0, + exeCaster, leechTarget); } LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); } @@ -6224,6 +6402,10 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t icSpellId = packet.readUInt32(); // Clear the interrupted unit's cast bar immediately unitCastStates_.erase(icTarget); + // Record interrupt in combat log when player is involved + if (isPlayerCaster || icTarget == playerGuid) + addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0, + exeCaster, icTarget); LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, " interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec); } @@ -6534,19 +6716,26 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { // uint32 questId + uint32 reason if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t questId =*/ packet.readUInt32(); + uint32_t questId = packet.readUInt32(); uint32_t reason = packet.readUInt32(); - const char* reasonStr = "Unknown reason"; + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + const char* reasonStr = nullptr; switch (reason) { - case 1: reasonStr = "Quest failed: failed conditions"; break; - case 2: reasonStr = "Quest failed: inventory full"; break; - case 3: reasonStr = "Quest failed: too far away"; break; - case 4: reasonStr = "Quest failed: another quest is blocking"; break; - case 5: reasonStr = "Quest failed: wrong time of day"; break; - case 6: reasonStr = "Quest failed: wrong race"; break; - case 7: reasonStr = "Quest failed: wrong class"; break; + case 1: reasonStr = "failed conditions"; break; + case 2: reasonStr = "inventory full"; break; + case 3: reasonStr = "too far away"; break; + case 4: reasonStr = "another quest is blocking"; break; + case 5: reasonStr = "wrong time of day"; break; + case 6: reasonStr = "wrong race"; break; + case 7: reasonStr = "wrong class"; break; } - addSystemChatMessage(reasonStr); + std::string msg = questTitle.empty() ? "Quest" : ('"' + questTitle + '"'); + msg += " failed"; + if (reasonStr) msg += std::string(": ") + reasonStr; + msg += '.'; + addSystemChatMessage(msg); } break; } @@ -6671,12 +6860,11 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); - (void)attackerGuid; // Show RESIST when player is the victim; show as caster-side MISS when player is attacker if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, false); + addCombatText(CombatTextEntry::MISS, 0, spellId, false, 0, attackerGuid, victimGuid); } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, true); + addCombatText(CombatTextEntry::MISS, 0, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; @@ -6806,6 +6994,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t spellId = packet.readUInt32(); petSpellList_.push_back(spellId); + const std::string& sname = getSpellName(spellId); + addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + ".")); LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); } packet.setReadPos(packet.getSize()); @@ -6827,10 +7017,20 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 5) { uint8_t castCount = packet.readUInt8(); uint32_t spellId = packet.readUInt32(); - uint32_t reason = (packet.getSize() - packet.getReadPos() >= 4) - ? packet.readUInt32() : 0; + uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1) + ? packet.readUInt8() : 0; LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, - " reason=", reason, " castCount=", (int)castCount); + " reason=", (int)reason, " castCount=", (int)castCount); + if (reason != 0) { + const char* reasonStr = getSpellCastResultString(reason); + const std::string& sName = getSpellName(spellId); + std::string errMsg; + if (reasonStr && *reasonStr) + errMsg = sName.empty() ? reasonStr : (sName + ": " + reasonStr); + else + errMsg = sName.empty() ? "Pet spell failed." : (sName + ": Pet spell failed."); + addSystemChatMessage(errMsg); + } } packet.setReadPos(packet.getSize()); break; @@ -6839,11 +7039,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: case Opcode::SMSG_PET_UNLEARN_CONFIRM: - case Opcode::SMSG_PET_NAME_INVALID: case Opcode::SMSG_PET_RENAMEABLE: case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PET_NAME_INVALID: + addSystemChatMessage("That pet name is invalid. Please choose a different name."); + packet.setReadPos(packet.getSize()); + break; // ---- Inspect (Classic 1.12 gear inspection) ---- case Opcode::SMSG_INSPECT: { @@ -6939,16 +7142,65 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Misc consume (no state change needed) ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: - case Opcode::SMSG_PROPOSE_LEVEL_GRANT: - case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: - case Opcode::SMSG_REFER_A_FRIEND_FAILURE: - case Opcode::SMSG_REPORT_PVP_AFK_RESULT: case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: case Opcode::SMSG_PLAYER_SKINNED: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PROPOSE_LEVEL_GRANT: { + // Recruit-A-Friend: a mentor is offering to grant you a level + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t mentorGuid = packet.readUInt64(); + std::string mentorName; + auto ent = entityManager.getEntity(mentorGuid); + if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); + if (mentorName.empty()) { + auto nit = playerNameCache.find(mentorGuid); + if (nit != playerNameCache.end()) mentorName = nit->second; + } + addSystemChatMessage(mentorName.empty() + ? "A player is offering to grant you a level." + : (mentorName + " is offering to grant you a level.")); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: + addSystemChatMessage("Your Recruit-A-Friend link has expired."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_REFER_A_FRIEND_FAILURE: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t reason = packet.readUInt32(); + static const char* kRafErrors[] = { + "Not eligible", // 0 + "Target not eligible", // 1 + "Too many referrals", // 2 + "Wrong faction", // 3 + "Not a recruit", // 4 + "Recruit requirements not met", // 5 + "Level above requirement", // 6 + "Friend needs account upgrade", // 7 + }; + const char* msg = (reason < 8) ? kRafErrors[reason] + : "Recruit-A-Friend failed."; + addSystemChatMessage(std::string("Recruit-A-Friend: ") + msg); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_REPORT_PVP_AFK_RESULT: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("AFK report submitted."); + else + addSystemChatMessage("Cannot report that player as AFK right now."); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: handleRespondInspectAchievements(packet); break; @@ -7086,8 +7338,15 @@ void GameHandler::handlePacket(network::Packet& packet) { bfMgrInvitePending_ = true; bfMgrZoneId_ = bfZoneId; char buf[128]; - std::snprintf(buf, sizeof(buf), - "You are invited to the outdoor battlefield in zone %u. Click to enter.", bfZoneId); + std::string bfZoneName = getAreaName(bfZoneId); + if (!bfZoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in %s. Click to enter.", + bfZoneName.c_str()); + else + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in zone %u. Click to enter.", + bfZoneId); addSystemChatMessage(buf); LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); break; @@ -7328,10 +7587,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t mapId = packet.readUInt32(); uint32_t difficulty = packet.readUInt32(); /*uint64_t resetTime =*/ packet.readUInt64(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Calendar: Raid lockout added for map %u (difficulty %u).", mapId, difficulty); - addSystemChatMessage(buf); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout added for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); } packet.setReadPos(packet.getSize()); @@ -7344,7 +7607,14 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint64_t eventId =*/ packet.readUInt64(); uint32_t mapId = packet.readUInt32(); uint32_t difficulty = packet.readUInt32(); - (void)mapId; (void)difficulty; + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout removed for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, " difficulty=", difficulty); } @@ -7405,6 +7675,8 @@ void GameHandler::handlePacket(network::Packet& packet) { msg = "You have been removed from the group: " + reason; else if (reasonType == 1) msg = "You have been removed from the group for being AFK."; + else if (reasonType == 2) + msg = "You have been removed from the group by vote."; addSystemChatMessage(msg); addUIError(msg); LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, @@ -7961,6 +8233,17 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { playerArmorRating_ = 0; std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); std::fill(std::begin(playerStats_), std::end(playerStats_), -1); + playerMeleeAP_ = -1; + playerRangedAP_ = -1; + std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1); + playerHealBonus_ = -1; + playerDodgePct_ = -1.0f; + playerParryPct_ = -1.0f; + playerBlockPct_ = -1.0f; + playerCritPct_ = -1.0f; + playerRangedCritPct_ = -1.0f; + std::fill(std::begin(playerSpellCritPct_), std::end(playerSpellCritPct_), -1.0f); + std::fill(std::begin(playerCombatRatings_), std::end(playerCombatRatings_), -1); knownSpells.clear(); spellCooldowns.clear(); spellFlatMods_.clear(); @@ -7996,6 +8279,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; playerDead_ = false; @@ -10139,6 +10423,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), fieldIndex(UF::UNIT_FIELD_STAT4) }; + const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } @@ -10174,6 +10469,23 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { chosenTitleBit_ = static_cast(val); LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); } + else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { + playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); + } + else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); + } + else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { + playerCombatRatings_[key - ufRating1] = static_cast(val); + } else { for (int si = 0; si < 5; ++si) { if (ufStats[si] != 0xFFFF && key == ufStats[si]) { @@ -10531,6 +10843,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), fieldIndex(UF::UNIT_FIELD_STAT4) }; + const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; @@ -10595,6 +10918,23 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(false); } } + else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { + playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); + } + else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); + } + else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { + playerCombatRatings_[key - ufRating1V] = static_cast(val); + } else { for (int si = 0; si < 5; ++si) { if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { @@ -11194,10 +11534,86 @@ void GameHandler::handleChannelNotify(network::Packet& packet) { } break; } - case ChannelNotifyType::NOT_IN_AREA: { + case ChannelNotifyType::NOT_IN_AREA: + addSystemChatMessage("You must be in the area to join '" + data.channelName + "'."); LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)"); break; - } + case ChannelNotifyType::WRONG_PASSWORD: + addSystemChatMessage("Wrong password for channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MEMBER: + addSystemChatMessage("You are not in channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MODERATOR: + addSystemChatMessage("You are not a moderator of '" + data.channelName + "'."); + break; + case ChannelNotifyType::MUTED: + addSystemChatMessage("You are muted in channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::BANNED: + addSystemChatMessage("You are banned from channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::THROTTLED: + addSystemChatMessage("Channel '" + data.channelName + "' is throttled. Please wait."); + break; + case ChannelNotifyType::NOT_IN_LFG: + addSystemChatMessage("You must be in a LFG queue to join '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_KICKED: + addSystemChatMessage("A player was kicked from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PASSWORD_CHANGED: + addSystemChatMessage("Password for '" + data.channelName + "' changed."); + break; + case ChannelNotifyType::OWNER_CHANGED: + addSystemChatMessage("Owner of '" + data.channelName + "' changed."); + break; + case ChannelNotifyType::NOT_OWNER: + addSystemChatMessage("You are not the owner of '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVALID_NAME: + addSystemChatMessage("Invalid channel name '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_NOT_FOUND: + addSystemChatMessage("Player not found."); + break; + case ChannelNotifyType::ANNOUNCEMENTS_ON: + addSystemChatMessage("Channel '" + data.channelName + "': announcements enabled."); + break; + case ChannelNotifyType::ANNOUNCEMENTS_OFF: + addSystemChatMessage("Channel '" + data.channelName + "': announcements disabled."); + break; + case ChannelNotifyType::MODERATION_ON: + addSystemChatMessage("Channel '" + data.channelName + "' is now moderated."); + break; + case ChannelNotifyType::MODERATION_OFF: + addSystemChatMessage("Channel '" + data.channelName + "' is no longer moderated."); + break; + case ChannelNotifyType::PLAYER_BANNED: + addSystemChatMessage("A player was banned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_UNBANNED: + addSystemChatMessage("A player was unbanned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_NOT_BANNED: + addSystemChatMessage("That player is not banned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVITE: + addSystemChatMessage("You have been invited to join channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVITE_WRONG_FACTION: + case ChannelNotifyType::WRONG_FACTION: + addSystemChatMessage("Wrong faction for channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MODERATED: + addSystemChatMessage("Channel '" + data.channelName + "' is not moderated."); + break; + case ChannelNotifyType::PLAYER_INVITED: + addSystemChatMessage("Player invited to channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_INVITE_BANNED: + addSystemChatMessage("That player is banned from '" + data.channelName + "'."); + break; default: LOG_DEBUG("Channel notify type ", static_cast(data.notifyType), " for channel ", data.channelName); @@ -11263,13 +11679,16 @@ void GameHandler::setFocus(uint64_t guid) { if (guid != 0) { auto entity = entityManager.getEntity(guid); if (entity) { - std::string name = "Unknown"; - if (entity->getType() == ObjectType::PLAYER) { - auto player = std::dynamic_pointer_cast(entity); - if (player && !player->getName().empty()) { - name = player->getName(); - } + std::string name; + auto unit = std::dynamic_pointer_cast(entity); + if (unit && !unit->getName().empty()) { + name = unit->getName(); } + if (name.empty()) { + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) name = nit->second; + } + if (name.empty()) name = "Unknown"; addSystemChatMessage("Focus set: " + name); LOG_INFO("Focus set: 0x", std::hex, guid, std::dec); } @@ -11308,9 +11727,8 @@ void GameHandler::targetEnemy(bool reverse) { for (const auto& [guid, entity] : entities) { if (entity->getType() == ObjectType::UNIT) { - // Check if hostile (this is simplified - would need faction checking) auto unit = std::dynamic_pointer_cast(entity); - if (unit && guid != playerGuid) { + if (unit && guid != playerGuid && unit->isHostile()) { hostiles.push_back(guid); } } @@ -11611,6 +12029,7 @@ void GameHandler::cancelLogout() { auto packet = LogoutCancelPacket::build(); socket->send(packet); loggingOut_ = false; + logoutCountdown_ = 0.0f; addSystemChatMessage("Logout cancelled."); LOG_INFO("Cancelled logout"); } @@ -11935,6 +12354,11 @@ void GameHandler::handleDuelRequested(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { duelChallengerName_ = unit->getName(); } + if (duelChallengerName_.empty()) { + auto nit = playerNameCache.find(duelChallengerGuid_); + if (nit != playerNameCache.end()) + duelChallengerName_ = nit->second; + } if (duelChallengerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -11962,13 +12386,18 @@ void GameHandler::handleDuelComplete(network::Packet& packet) { void GameHandler::handleDuelWinner(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 3) return; - /*uint8_t type =*/ packet.readUInt8(); // 0=normal, 1=flee + uint8_t duelType = packet.readUInt8(); // 0=normal win, 1=opponent fled duel area std::string winner = packet.readString(); std::string loser = packet.readString(); - std::string msg = winner + " has defeated " + loser + " in a duel!"; + std::string msg; + if (duelType == 1) { + msg = loser + " has fled from the duel. " + winner + " wins!"; + } else { + msg = winner + " has defeated " + loser + " in a duel!"; + } addSystemChatMessage(msg); - LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser); + LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser, " type=", static_cast(duelType)); } void GameHandler::toggleAfk(const std::string& message) { @@ -12219,6 +12648,7 @@ void GameHandler::stopCasting() { castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; @@ -13601,26 +14031,31 @@ void GameHandler::stopAutoAttack() { LOG_INFO("Stopping auto-attack"); } -void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) { +void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, + uint64_t srcGuid, uint64_t dstGuid) { CombatTextEntry entry; entry.type = type; entry.amount = amount; entry.spellId = spellId; entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; + entry.powerType = powerType; combatText.push_back(entry); - // Persistent combat log + // Persistent combat log — use explicit GUIDs if provided, else fall back to + // player/current-target (the old behaviour for events without specific participants). CombatLogEntry log; log.type = type; log.amount = amount; log.spellId = spellId; log.isPlayerSource = isPlayerSource; log.timestamp = std::time(nullptr); - std::string pname(lookupName(playerGuid)); - std::string tname((targetGuid != 0) ? lookupName(targetGuid) : std::string()); - log.sourceName = isPlayerSource ? pname : tname; - log.targetName = isPlayerSource ? tname : pname; + uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid + : (isPlayerSource ? playerGuid : targetGuid); + uint64_t effectiveDst = (dstGuid != 0) ? dstGuid + : (isPlayerSource ? targetGuid : playerGuid); + log.sourceName = lookupName(effectiveSrc); + log.targetName = (effectiveDst != 0) ? lookupName(effectiveDst) : std::string{}; if (combatLog_.size() >= MAX_COMBAT_LOG) combatLog_.pop_front(); combatLog_.push_back(std::move(log)); @@ -14111,18 +14546,45 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t statusId = packet.readUInt32(); - std::string bgName = "Battleground #" + std::to_string(bgTypeId); + // Map BG type IDs to their names (stable across all three expansions) + static const std::pair kBgNames[] = { + {1, "Alterac Valley"}, + {2, "Warsong Gulch"}, + {3, "Arathi Basin"}, + {6, "Eye of the Storm"}, + {9, "Strand of the Ancients"}, + {11, "Isle of Conquest"}, + {30, "Nagrand Arena"}, + {31, "Blade's Edge Arena"}, + {32, "Dalaran Sewers"}, + {33, "Ring of Valor"}, + {34, "Ruins of Lordaeron"}, + }; + std::string bgName = "Battleground"; + for (const auto& kv : kBgNames) { + if (kv.first == bgTypeId) { bgName = kv.second; break; } + } + if (bgName == "Battleground") + bgName = "Battleground #" + std::to_string(bgTypeId); if (arenaType > 0) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; + // If bgTypeId matches a named arena, prefer that name + for (const auto& kv : kBgNames) { + if (kv.first == bgTypeId) { + bgName += " (" + std::string(kv.second) + ")"; + break; + } + } } // Parse status-specific fields uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) + uint32_t avgWaitSec = 0, timeInQueueSec = 0; if (statusId == 1) { // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t avgWait =*/ packet.readUInt32(); - /*uint32_t inQueue =*/ packet.readUInt32(); + avgWaitSec = packet.readUInt32() / 1000; // ms → seconds + timeInQueueSec = packet.readUInt32() / 1000; } } else if (statusId == 2) { // STATUS_WAIT_JOIN: timeout(4) + mapId(4) @@ -14147,6 +14609,10 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; + if (statusId == 1) { + bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec; + bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec; + } if (statusId == 2 && !wasInvite) { bgQueues_[queueSlot].inviteTimeout = inviteTimeout; bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now(); @@ -14378,6 +14844,7 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) { // MSG_SET_DUNGEON_DIFFICULTY: uint32 difficulty[, uint32 isInGroup, uint32 savedBool] (4 or 12 bytes) auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 4) return; + uint32_t prevDifficulty = instanceDifficulty_; instanceDifficulty_ = packet.readUInt32(); if (rem() >= 4) { uint32_t secondField = packet.readUInt32(); @@ -14396,6 +14863,15 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) { instanceIsHeroic_ = (instanceDifficulty_ == 1); } LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); + + // Announce difficulty change to the player (only when it actually changes) + // difficulty values: 0=Normal, 1=Heroic, 2=25-Man Normal, 3=25-Man Heroic + if (instanceDifficulty_ != prevDifficulty) { + static const char* kDiffLabels[] = {"Normal", "Heroic", "25-Man Normal", "25-Man Heroic"}; + const char* diffLabel = (instanceDifficulty_ < 4) ? kDiffLabels[instanceDifficulty_] : nullptr; + if (diffLabel) + addSystemChatMessage(std::string("Dungeon difficulty set to ") + diffLabel + "."); + } } // --------------------------------------------------------------------------- @@ -14447,7 +14923,13 @@ void GameHandler::handleLfgJoinResult(network::Packet& packet) { // Success — state tells us what phase we're entering lfgState_ = static_cast(state); LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast(state)); - addSystemChatMessage("Dungeon Finder: Joined the queue."); + { + std::string dName = getLfgDungeonName(lfgDungeonId_); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: Joined the queue for " + dName + "."); + else + addSystemChatMessage("Dungeon Finder: Joined the queue."); + } } else { const char* msg = lfgJoinResultString(result); std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); @@ -14498,15 +14980,25 @@ void GameHandler::handleLfgProposalUpdate(network::Packet& packet) { lfgProposalId_ = 0; addSystemChatMessage("Dungeon Finder: Group proposal failed."); break; - case 1: + case 1: { lfgState_ = LfgState::InDungeon; lfgProposalId_ = 0; - addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); + std::string dName = getLfgDungeonName(dungeonId); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: Group found for " + dName + "! Entering dungeon..."); + else + addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); break; - case 2: + } + case 2: { lfgState_ = LfgState::Proposal; - addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); + std::string dName = getLfgDungeonName(dungeonId); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: A group has been found for " + dName + ". Accept or decline."); + else + addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); break; + } default: break; } @@ -14628,7 +15120,10 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { uint32_t itemCount = packet.readUInt32(); packet.readUInt8(); // unk if (i == 0) { - rewardMsg += ", item #" + std::to_string(itemId); + std::string itemLabel = "item #" + std::to_string(itemId); + if (const ItemQueryResponseData* info = getItemInfo(itemId)) + if (!info->name.empty()) itemLabel = info->name; + rewardMsg += ", " + itemLabel; if (itemCount > 1) rewardMsg += " x" + std::to_string(itemCount); } } @@ -14644,15 +15139,13 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { if (remaining < 7 + 4 + 4 + 4 + 4) return; bool inProgress = packet.readUInt8() != 0; - bool myVote = packet.readUInt8() != 0; - bool myAnswer = packet.readUInt8() != 0; + /*bool myVote =*/ packet.readUInt8(); // whether local player has voted + /*bool myAnswer =*/ packet.readUInt8(); // local player's vote (yes/no) — unused; result derived from counts uint32_t totalVotes = packet.readUInt32(); uint32_t bootVotes = packet.readUInt32(); uint32_t timeLeft = packet.readUInt32(); uint32_t votesNeeded = packet.readUInt32(); - (void)myVote; - lfgBootVotes_ = bootVotes; lfgBootTotal_ = totalVotes; lfgBootTimeLeft_ = timeLeft; @@ -14667,12 +15160,14 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { if (inProgress) { lfgState_ = LfgState::Boot; } else { - // Boot vote ended — return to InDungeon state regardless of outcome + // Boot vote ended — pass/fail determined by whether enough yes votes were cast, + // not by the local player's own vote (myAnswer = what *I* voted, not the result). + const bool bootPassed = (bootVotes >= votesNeeded); lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; lfgBootTargetName_.clear(); lfgBootReason_.clear(); lfgState_ = LfgState::InDungeon; - if (myAnswer) { + if (bootPassed) { addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); } else { addSystemChatMessage("Dungeon Finder: Vote kick failed."); @@ -14982,12 +15477,6 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 1) return; uint8_t event = packet.readUInt8(); - static const char* events[] = { - "joined", "left", "removed", "leader changed", - "disbanded", "created" - }; - std::string eventName = (event < 6) ? events[event] : "unknown event"; - // Read string params (up to 3) uint8_t strCount = 0; if (packet.getSize() - packet.getReadPos() >= 1) { @@ -14998,11 +15487,45 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { if (strCount >= 1 && packet.getSize() > packet.getReadPos()) param1 = packet.readString(); if (strCount >= 2 && packet.getSize() > packet.getReadPos()) param2 = packet.readString(); - std::string msg = "Arena team " + eventName; - if (!param1.empty()) msg += ": " + param1; - if (!param2.empty()) msg += " (" + param2 + ")"; + // Build natural-language message based on event type + // Event params: 0=joined(name), 1=left(name), 2=removed(name,kicker), + // 3=leader_changed(new,old), 4=disbanded, 5=created(name) + std::string msg; + switch (event) { + case 0: // joined + msg = param1.empty() ? "A player has joined your arena team." + : param1 + " has joined your arena team."; + break; + case 1: // left + msg = param1.empty() ? "A player has left the arena team." + : param1 + " has left the arena team."; + break; + case 2: // removed + if (!param1.empty() && !param2.empty()) + msg = param1 + " has been removed from the arena team by " + param2 + "."; + else if (!param1.empty()) + msg = param1 + " has been removed from the arena team."; + else + msg = "A player has been removed from the arena team."; + break; + case 3: // leader changed + msg = param1.empty() ? "The arena team captain has changed." + : param1 + " is now the arena team captain."; + break; + case 4: // disbanded + msg = "Your arena team has been disbanded."; + break; + case 5: // created + msg = param1.empty() ? "Your arena team has been created." + : "Arena team \"" + param1 + "\" has been created."; + break; + default: + msg = "Arena team event " + std::to_string(event); + if (!param1.empty()) msg += ": " + param1; + break; + } addSystemChatMessage(msg); - LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2); + LOG_INFO("Arena team event: ", (int)event, " ", param1, " ", param2); } void GameHandler::handleArenaTeamStats(network::Packet& packet) { @@ -15719,28 +16242,28 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { } if (data.isMiss()) { - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 1) { - addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 2) { - addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 4) { // VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount if (data.totalDamage > 0) - addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker); - addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 5) { // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 6) { // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. - addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 7) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; - addCombatText(type, data.totalDamage, 0, isPlayerAttacker); + addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); // Show partial absorb/resist from sub-damage entries uint32_t totalAbsorbed = 0, totalResisted = 0; for (const auto& sub : data.subDamages) { @@ -15748,9 +16271,9 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { totalResisted += sub.resisted; } if (totalAbsorbed > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker); + addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); if (totalResisted > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker); + addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } (void)isPlayerTarget; @@ -15771,11 +16294,11 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE; if (data.damage > 0) - addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); if (data.absorbed > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); if (data.resisted > 0) - addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource); + addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); } void GameHandler::handleSpellHealLog(network::Packet& packet) { @@ -15787,9 +16310,9 @@ void GameHandler::handleSpellHealLog(network::Packet& packet) { if (!isPlayerSource && !isPlayerTarget) return; // Not our combat auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; - addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource); + addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); if (data.absorbed > 0) - addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); } // ============================================================ @@ -15912,6 +16435,7 @@ void GameHandler::cancelCast() { socket->send(packet); } pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; casting = false; castIsChannel = false; currentCastSpellId = 0; @@ -16101,6 +16625,16 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t queryItemInfo(id, 0); } saveCharacterConfig(); + // Notify the server so the action bar persists across relogs. + if (state == WorldState::IN_WORLD && socket) { + const bool classic = isClassicLikeExpansion(); + auto pkt = SetActionButtonPacket::build( + static_cast(slot), + static_cast(type), + id, + classic); + socket->send(pkt); + } } float GameHandler::getSpellCooldown(uint32_t spellId) const { @@ -16135,6 +16669,19 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { actionBar[11].id = 8690; // Hearthstone loadCharacterConfig(); + // Sync login-time cooldowns into action bar slot overlays. Without this, spells + // that are still on cooldown when the player logs in show no cooldown timer on the + // action bar even though spellCooldowns has the right remaining time. + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { + auto it = spellCooldowns.find(slot.id); + if (it != spellCooldowns.end() && it->second > 0.0f) { + slot.cooldownTotal = it->second; + slot.cooldownRemaining = it->second; + } + } + } + LOG_INFO("Learned ", knownSpells.size(), " spells"); } @@ -16148,6 +16695,7 @@ void GameHandler::handleCastFailed(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + lastInteractedGoGuid_ = 0; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -16203,6 +16751,14 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { + // CMSG_GAMEOBJ_USE was accepted — cancel pending USE retries so we don't + // re-send GAMEOBJ_USE mid-gather-cast and get SPELL_FAILED_BAD_TARGETS. + // Keep entries that only have sendLoot (no-cast chests that still need looting). + pendingGameObjectLootRetries_.erase( + std::remove_if(pendingGameObjectLootRetries_.begin(), pendingGameObjectLootRetries_.end(), + [](const PendingLootRetry&) { return true; /* cancel all retries once a gather cast starts */ }), + pendingGameObjectLootRetries_.end()); + casting = true; castIsChannel = false; currentCastSpellId = data.spellId; @@ -16274,11 +16830,26 @@ void GameHandler::handleSpellGo(network::Packet& packet) { meleeSwingCallback_(); } + // Capture cast state before clearing. Guard with spellId match so that + // proc/triggered spells (which fire SMSG_SPELL_GO while a gather cast is + // still active and casting == true) do NOT trigger premature CMSG_LOOT. + // Only the spell that originally started the cast bar (currentCastSpellId) + // should count as "gather cast completed". + const bool wasInTimedCast = casting && (data.spellId == currentCastSpellId); + casting = false; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + // If we were gathering a node (mining/herbalism), send CMSG_LOOT now that + // the gather cast completed and the server has made the node lootable. + // Guard with wasInTimedCast to avoid firing on instant casts / procs. + if (wasInTimedCast && lastInteractedGoGuid_ != 0) { + lootTarget(lastInteractedGoGuid_); + lastInteractedGoGuid_ = 0; + } + // End cast animation on player character if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); @@ -16442,8 +17013,14 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { const size_t minSz = classicSpellId ? 2u : 4u; if (packet.getSize() - packet.getReadPos() < minSz) return; uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); + + // Track whether we already knew this spell before inserting. + // SMSG_TRAINER_BUY_SUCCEEDED pre-inserts the spell and shows its own "You have + // learned X" message, so when the accompanying SMSG_LEARNED_SPELL arrives we + // must not duplicate it. + const bool alreadyKnown = knownSpells.count(spellId) > 0; knownSpells.insert(spellId); - LOG_INFO("Learned spell: ", spellId); + LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : ""); // Check if this spell corresponds to a talent rank for (const auto& [talentId, talent] : talentCache_) { @@ -16459,12 +17036,15 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { } } - // Show chat message for non-talent spells - const std::string& name = getSpellName(spellId); - if (!name.empty()) { - addSystemChatMessage("You have learned a new spell: " + name + "."); - } else { - addSystemChatMessage("You have learned a new spell."); + // Show chat message for non-talent spells, but only if not already announced by + // SMSG_TRAINER_BUY_SUCCEEDED (which pre-inserts into knownSpells). + if (!alreadyKnown) { + const std::string& name = getSpellName(spellId); + if (!name.empty()) { + addSystemChatMessage("You have learned a new spell: " + name + "."); + } else { + addSystemChatMessage("You have learned a new spell."); + } } } @@ -16476,6 +17056,16 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO("Removed spell: ", spellId); + + // Clear any action bar slots referencing this spell + bool barChanged = false; + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot = ActionBarSlot{}; + barChanged = true; + } + } + if (barChanged) saveCharacterConfig(); } void GameHandler::handleSupercededSpell(network::Packet& packet) { @@ -16491,14 +17081,39 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { // Remove old spell knownSpells.erase(oldSpellId); + // Track whether the new spell was already announced via SMSG_TRAINER_BUY_SUCCEEDED. + // If it was pre-inserted there, that handler already showed "You have learned X" so + // we should skip the "Upgraded to X" message to avoid a duplicate. + const bool newSpellAlreadyAnnounced = knownSpells.count(newSpellId) > 0; + // Add new spell knownSpells.insert(newSpellId); LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId); - const std::string& newName = getSpellName(newSpellId); - if (!newName.empty()) { - addSystemChatMessage("Upgraded to " + newName); + // Update all action bar slots that reference the old spell rank to the new rank. + // This matches the WoW client behaviour: the action bar automatically upgrades + // to the new rank when you train it. + bool barChanged = false; + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) { + slot.id = newSpellId; + slot.cooldownRemaining = 0.0f; + slot.cooldownTotal = 0.0f; + barChanged = true; + LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId); + } + } + if (barChanged) saveCharacterConfig(); + + // Show "Upgraded to X" only when the new spell wasn't already announced by the + // trainer-buy handler. For non-trainer supersedes (e.g. quest rewards), the new + // spell won't be pre-inserted so we still show the message. + if (!newSpellAlreadyAnnounced) { + const std::string& newName = getSpellName(newSpellId); + if (!newName.empty()) { + addSystemChatMessage("Upgraded to " + newName); + } } } @@ -16508,11 +17123,19 @@ void GameHandler::handleUnlearnSpells(network::Packet& packet) { uint32_t spellCount = packet.readUInt32(); LOG_INFO("Unlearning ", spellCount, " spells"); + bool barChanged = false; for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) { uint32_t spellId = packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO(" Unlearned spell: ", spellId); + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot = ActionBarSlot{}; + barChanged = true; + } + } } + if (barChanged) saveCharacterConfig(); if (spellCount > 0) { addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells"); @@ -16717,17 +17340,23 @@ void GameHandler::handleGroupList(network::Packet& packet) { // WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder. // Classic 1.12 and TBC 2.4.3 do not send the roles byte. const bool hasRoles = isActiveExpansion("wotlk"); + // Snapshot state before reset so we can detect transitions. + const uint32_t prevCount = partyData.memberCount; + const bool wasInGroup = !partyData.isEmpty(); // Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta. // Without this, repeated GROUP_LIST packets push duplicate members. partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles)) return; - if (partyData.isEmpty()) { + const bool nowInGroup = !partyData.isEmpty(); + if (!nowInGroup && wasInGroup) { LOG_INFO("No longer in a group"); addSystemChatMessage("You are no longer in a group."); - } else { - LOG_INFO("In group with ", partyData.memberCount, " members"); - addSystemChatMessage("You are now in a group with " + std::to_string(partyData.memberCount) + " members."); + } else if (nowInGroup && !wasInGroup) { + LOG_INFO("Joined group with ", partyData.memberCount, " members"); + addSystemChatMessage("You are now in a group."); + } else if (nowInGroup && partyData.memberCount != prevCount) { + LOG_INFO("Group updated: ", partyData.memberCount, " members"); } } @@ -16748,11 +17377,36 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { if (!PartyCommandResultParser::parse(packet, data)) return; if (data.result != PartyResult::OK) { + const char* errText = nullptr; + switch (data.result) { + case PartyResult::BAD_PLAYER_NAME: errText = "No player named \"%s\" is currently online."; break; + case PartyResult::TARGET_NOT_IN_GROUP: errText = "%s is not in your group."; break; + case PartyResult::TARGET_NOT_IN_INSTANCE:errText = "%s is not in your instance."; break; + case PartyResult::GROUP_FULL: errText = "Your party is full."; break; + case PartyResult::ALREADY_IN_GROUP: errText = "%s is already in a group."; break; + case PartyResult::NOT_IN_GROUP: errText = "You are not in a group."; break; + case PartyResult::NOT_LEADER: errText = "You are not the group leader."; break; + case PartyResult::PLAYER_WRONG_FACTION: errText = "%s is the wrong faction for this group."; break; + case PartyResult::IGNORING_YOU: errText = "%s is ignoring you."; break; + case PartyResult::LFG_PENDING: errText = "You cannot do that while in a LFG queue."; break; + case PartyResult::INVITE_RESTRICTED: errText = "Target is not accepting group invites."; break; + default: errText = "Party command failed."; break; + } + + char buf[256]; + if (!data.name.empty() && errText && std::strstr(errText, "%s")) { + std::snprintf(buf, sizeof(buf), errText, data.name.c_str()); + } else if (errText) { + std::snprintf(buf, sizeof(buf), "%s", errText); + } else { + std::snprintf(buf, sizeof(buf), "Party command failed (error %u).", + static_cast(data.result)); + } + MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - msg.message = "Party command failed (error " + std::to_string(static_cast(data.result)) + ")"; - if (!data.name.empty()) msg.message += " for " + data.name; + msg.message = buf; addLocalChatMessage(msg); } } @@ -17128,6 +17782,7 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; + const bool wasUnknown = guildName_.empty(); guildName_ = data.guildName; guildQueryData_ = data; guildRankNames_.clear(); @@ -17135,7 +17790,10 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { guildRankNames_.push_back(data.rankNames[i]); } LOG_INFO("Guild name set to: ", guildName_); - addSystemChatMessage("Guild: <" + guildName_ + ">"); + // Only announce once — when we first learn our own guild name at login. + // Subsequent queries (e.g. querying other players' guilds) are silent. + if (wasUnknown && !guildName_.empty()) + addSystemChatMessage("Guild: <" + guildName_ + ">"); } void GameHandler::handleGuildEvent(network::Packet& packet) { @@ -17234,12 +17892,73 @@ void GameHandler::handleGuildCommandResult(network::Packet& packet) { GuildCommandResultData data; if (!GuildCommandResultParser::parse(packet, data)) return; - if (data.errorCode != 0) { - std::string msg = "Guild command failed"; + // command: 0=CREATE, 1=INVITE, 2=QUIT, 3=FOUNDER + if (data.errorCode == 0) { + switch (data.command) { + case 0: // CREATE + addSystemChatMessage("Guild created."); + break; + case 1: // INVITE — invited another player + if (!data.name.empty()) + addSystemChatMessage("You have invited " + data.name + " to the guild."); + break; + case 2: // QUIT — player successfully left + addSystemChatMessage("You have left the guild."); + guildName_.clear(); + guildRankNames_.clear(); + guildRoster_ = GuildRosterData{}; + hasGuildRoster_ = false; + break; + default: + break; + } + return; + } + + // Error codes from AzerothCore SharedDefines.h GuildCommandError + const char* errStr = nullptr; + switch (data.errorCode) { + case 2: errStr = "You are not in a guild."; break; + case 3: errStr = "That player is not in a guild."; break; + case 4: errStr = "No player named \"%s\" is online."; break; + case 7: errStr = "You are the guild leader."; break; + case 8: errStr = "You must transfer leadership before leaving."; break; + case 11: errStr = "\"%s\" is already in a guild."; break; + case 13: errStr = "You are already in a guild."; break; + case 14: errStr = "\"%s\" has already been invited to a guild."; break; + case 15: errStr = "You cannot invite yourself."; break; + case 16: + case 17: errStr = "You are not the guild leader."; break; + case 18: errStr = "That player's rank is too high to remove."; break; + case 19: errStr = "You cannot remove someone with a higher rank."; break; + case 20: errStr = "Guild ranks are locked."; break; + case 21: errStr = "That rank is in use."; break; + case 22: errStr = "That player is ignoring you."; break; + case 25: errStr = "Insufficient guild bank withdrawal quota."; break; + case 26: errStr = "Guild doesn't have enough money."; break; + case 28: errStr = "Guild bank is full."; break; + case 31: errStr = "Too many guild ranks."; break; + case 37: errStr = "That player is the guild leader."; break; + case 49: errStr = "Guild reputation is too low."; break; + default: break; + } + + std::string msg; + if (errStr) { + // Substitute %s with player name where applicable + std::string fmt = errStr; + auto pos = fmt.find("%s"); + if (pos != std::string::npos && !data.name.empty()) + fmt.replace(pos, 2, data.name); + else if (pos != std::string::npos) + fmt.replace(pos, 2, "that player"); + msg = fmt; + } else { + msg = "Guild command failed"; if (!data.name.empty()) msg += " for " + data.name; msg += " (error " + std::to_string(data.errorCode) + ")"; - addSystemChatMessage(msg); } + addSystemChatMessage(msg); } // ============================================================ @@ -17359,6 +18078,7 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { auto packet = GameObjectUsePacket::build(guid); socket->send(packet); + lastInteractedGoGuid_ = guid; // For mailbox GameObjects (type 19), open mail UI and request mail list. // In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends @@ -17406,6 +18126,11 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { } if (shouldSendLoot) { lootTarget(guid); + } else { + // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be + // sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot + // guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT. + lastInteractedGoGuid_ = 0; } // Retry use briefly to survive packet loss/order races. const bool retryLoot = shouldSendLoot; @@ -18572,6 +19297,7 @@ void GameHandler::handleLootResponse(network::Packet& packet) { const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; lootWindowOpen = true; + lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false}; // Query item info so loot window can show names instead of IDs @@ -19463,6 +20189,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; // Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready @@ -20077,7 +20804,16 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { dismount(); } - addSystemChatMessage("Taxi: requesting flight..."); + { + auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) { + taxiDestName_ = destIt->second.name; + addSystemChatMessage("Requesting flight to " + destIt->second.name + "..."); + } else { + taxiDestName_.clear(); + addSystemChatMessage("Taxi: requesting flight..."); + } + } // BFS to find path from startNode to destNodeId std::unordered_map> adj; @@ -20195,10 +20931,13 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { taxiActivateTimer_ = 0.0f; } - addSystemChatMessage("Flight started."); - // Save recovery target in case of disconnect during taxi. auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) + addSystemChatMessage("Flight to " + destIt->second.name + " started."); + else + addSystemChatMessage("Flight started."); + if (destIt != taxiNodes_.end()) { taxiRecoverMapId_ = destIt->second.mapId; taxiRecoverPos_ = core::coords::serverToCanonical( @@ -20438,13 +21177,17 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { return; } - // Look up player name from GUID + // Look up player name: contacts_ (populated by SMSG_FRIEND_LIST) > playerNameCache std::string playerName; - auto it = playerNameCache.find(data.guid); - if (it != playerNameCache.end()) { - playerName = it->second; - } else { - playerName = "Unknown"; + { + auto cit2 = std::find_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }); + if (cit2 != contacts_.end() && !cit2->name.empty()) { + playerName = cit2->name; + } else { + auto it = playerNameCache.find(data.guid); + if (it != playerNameCache.end()) playerName = it->second; + } } // Update friends cache @@ -20555,14 +21298,17 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { // Success - logout initiated if (data.instant) { addSystemChatMessage("Logging out..."); + logoutCountdown_ = 0.0f; } else { addSystemChatMessage("Logging out in 20 seconds..."); + logoutCountdown_ = 20.0f; } LOG_INFO("Logout response: success, instant=", (int)data.instant); } else { // Failure addSystemChatMessage("Cannot logout right now."); loggingOut_ = false; + logoutCountdown_ = 0.0f; LOG_WARNING("Logout failed, result=", data.result); } } @@ -20570,6 +21316,7 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) { addSystemChatMessage("Logout complete."); loggingOut_ = false; + logoutCountdown_ = 0.0f; LOG_INFO("Logout complete"); // Server will disconnect us } @@ -20666,10 +21413,20 @@ void GameHandler::extractSkillFields(const std::map& fields) uint16_t value = raw1 & 0xFFFF; uint16_t maxValue = (raw1 >> 16) & 0xFFFF; + uint16_t bonusTemp = 0; + uint16_t bonusPerm = 0; + auto bonusIt = fields.find(static_cast(baseField + 2)); + if (bonusIt != fields.end()) { + bonusTemp = bonusIt->second & 0xFFFF; + bonusPerm = (bonusIt->second >> 16) & 0xFFFF; + } + PlayerSkill skill; skill.skillId = skillId; skill.value = value; skill.maxValue = maxValue; + skill.bonusTemp = bonusTemp; + skill.bonusPerm = bonusPerm; newSkills[skillId] = skill; } @@ -21660,6 +22417,11 @@ void GameHandler::handleQuestConfirmAccept(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { sharedQuestSharerName_ = unit->getName(); } + if (sharedQuestSharerName_.empty()) { + auto nit = playerNameCache.find(sharedQuestSharerGuid_); + if (nit != playerNameCache.end()) + sharedQuestSharerName_ = nit->second; + } if (sharedQuestSharerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -21697,7 +22459,7 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 16) return; summonerGuid_ = packet.readUInt64(); - /*uint32_t zoneId =*/ packet.readUInt32(); + uint32_t zoneId = packet.readUInt32(); uint32_t timeoutMs = packet.readUInt32(); summonTimeoutSec_ = timeoutMs / 1000.0f; pendingSummonRequest_= true; @@ -21707,6 +22469,11 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { summonerName_ = unit->getName(); } + if (summonerName_.empty()) { + auto nit = playerNameCache.find(summonerGuid_); + if (nit != playerNameCache.end()) + summonerName_ = nit->second; + } if (summonerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -21714,9 +22481,14 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { summonerName_ = tmp; } - addSystemChatMessage(summonerName_ + " is summoning you."); + std::string msg = summonerName_ + " is summoning you"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + msg += " to " + zoneName; + msg += '.'; + addSystemChatMessage(msg); LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_, - " timeout=", summonTimeoutSec_, "s"); + " zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s"); } void GameHandler::acceptSummon() { @@ -21762,6 +22534,11 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { tradePeerName_ = unit->getName(); } + if (tradePeerName_.empty()) { + auto nit = playerNameCache.find(tradePeerGuid_); + if (nit != playerNameCache.end()) + tradePeerName_ = nit->second; + } if (tradePeerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -21780,12 +22557,15 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); break; - case 3: // CANCELLED - case 9: // REJECTED + case 3: // CANCELLED case 12: // CLOSE_WINDOW resetTradeState(); addSystemChatMessage("Trade cancelled."); break; + case 9: // REJECTED — other player clicked Decline + resetTradeState(); + addSystemChatMessage("Trade declined."); + break; case 4: // ACCEPTED (partner accepted) tradeStatus_ = TradeStatus::Accepted; addSystemChatMessage("Trade accepted. Awaiting other player..."); @@ -22218,6 +22998,11 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { senderName = unit->getName(); } + if (senderName.empty()) { + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) + senderName = nit->second; + } if (senderName.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -22451,6 +23236,71 @@ std::string GameHandler::getAreaName(uint32_t areaId) const { return (it != areaNameCache_.end()) ? it->second : std::string{}; } +void GameHandler::loadMapNameCache() { + if (mapNameCacheLoaded_) return; + mapNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Map.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + // Field 2 = MapName_enUS (first localized); field 1 = InternalName fallback + std::string name = dbc->getString(i, 2); + if (name.empty()) name = dbc->getString(i, 1); + if (!name.empty() && !mapNameCache_.count(id)) { + mapNameCache_[id] = std::move(name); + } + } + LOG_INFO("Map.dbc: loaded ", mapNameCache_.size(), " map names"); +} + +std::string GameHandler::getMapName(uint32_t mapId) const { + if (mapId == 0) return {}; + const_cast(this)->loadMapNameCache(); + auto it = mapNameCache_.find(mapId); + return (it != mapNameCache_.end()) ? it->second : std::string{}; +} + +// --------------------------------------------------------------------------- +// LFG dungeon name cache (WotLK: LFGDungeons.dbc) +// --------------------------------------------------------------------------- + +void GameHandler::loadLfgDungeonDbc() { + if (lfgDungeonNameCacheLoaded_) return; + lfgDungeonNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("LFGDungeons.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("LFGDungeons") : nullptr; + const uint32_t idField = layout ? (*layout)["ID"] : 0; + const uint32_t nameField = layout ? (*layout)["Name"] : 1; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string name = dbc->getString(i, nameField); + if (!name.empty()) + lfgDungeonNameCache_[id] = std::move(name); + } + LOG_INFO("LFGDungeons.dbc: loaded ", lfgDungeonNameCache_.size(), " dungeon names"); +} + +std::string GameHandler::getLfgDungeonName(uint32_t dungeonId) const { + if (dungeonId == 0) return {}; + const_cast(this)->loadLfgDungeonDbc(); + auto it = lfgDungeonNameCache_.find(dungeonId); + return (it != lfgDungeonNameCache_.end()) ? it->second : std::string{}; +} + // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 1750253a..259fb872 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -313,6 +313,8 @@ const char* getQualityName(ItemQuality quality) { case ItemQuality::RARE: return "Rare"; case ItemQuality::EPIC: return "Epic"; case ItemQuality::LEGENDARY: return "Legendary"; + case ItemQuality::ARTIFACT: return "Artifact"; + case ItemQuality::HEIRLOOM: return "Heirloom"; default: return "Unknown"; } } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index c62567ef..041af211 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1381,7 +1381,7 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ return false; } - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags // Vanilla: NO Flags2 packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); // SellPrice @@ -1394,18 +1394,18 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ return false; } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace data.itemLevel = packet.readUInt32(); data.requiredLevel = packet.readUInt32(); - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); @@ -1468,12 +1468,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ // Remaining tail can vary by core. Read resistances + delay when present. if (packet.getSize() - packet.getReadPos() >= 28) { - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ffc462ad..935b34ae 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -998,7 +998,7 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery return false; } - packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2) + data.itemFlags = packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2) // TBC: NO Flags2, NO BuyCount packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); @@ -1011,19 +1011,19 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery return false; } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace data.itemLevel = packet.readUInt32(); data.requiredLevel = packet.readUInt32(); - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount - data.maxStack = static_cast(packet.readUInt32()); // Stackable + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) + data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); // TBC: statsCount prefix + exactly statsCount pairs (WotLK always sends 10) @@ -1087,12 +1087,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery data.armor = static_cast(packet.readUInt32()); if (packet.getSize() - packet.getReadPos() >= 28) { - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); } diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 41473539..ae45deb7 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -43,6 +43,8 @@ static const UFNameEntry kUFNames[] = { {"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3}, {"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4}, {"UNIT_END", UF::UNIT_END}, + {"UNIT_FIELD_ATTACK_POWER", UF::UNIT_FIELD_ATTACK_POWER}, + {"UNIT_FIELD_RANGED_ATTACK_POWER", UF::UNIT_FIELD_RANGED_ATTACK_POWER}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_BYTES", UF::PLAYER_BYTES}, {"PLAYER_BYTES_2", UF::PLAYER_BYTES_2}, @@ -61,6 +63,16 @@ static const UFNameEntry kUFNames[] = { {"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY}, {"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY}, {"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE}, + {"PLAYER_CHOSEN_TITLE", UF::PLAYER_CHOSEN_TITLE}, + {"PLAYER_FIELD_MOD_DAMAGE_DONE_POS", UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS}, + {"PLAYER_FIELD_MOD_HEALING_DONE_POS", UF::PLAYER_FIELD_MOD_HEALING_DONE_POS}, + {"PLAYER_BLOCK_PERCENTAGE", UF::PLAYER_BLOCK_PERCENTAGE}, + {"PLAYER_DODGE_PERCENTAGE", UF::PLAYER_DODGE_PERCENTAGE}, + {"PLAYER_PARRY_PERCENTAGE", UF::PLAYER_PARRY_PERCENTAGE}, + {"PLAYER_CRIT_PERCENTAGE", UF::PLAYER_CRIT_PERCENTAGE}, + {"PLAYER_RANGED_CRIT_PERCENTAGE", UF::PLAYER_RANGED_CRIT_PERCENTAGE}, + {"PLAYER_SPELL_CRIT_PERCENTAGE1", UF::PLAYER_SPELL_CRIT_PERCENTAGE1}, + {"PLAYER_FIELD_COMBAT_RATING_1", UF::PLAYER_FIELD_COMBAT_RATING_1}, {"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS}, {"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1}, }; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 14dc7a20..dbcbf4c9 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1905,6 +1905,42 @@ network::Packet StandStateChangePacket::build(uint8_t state) { return packet; } +// ============================================================ +// Action Bar +// ============================================================ + +network::Packet SetActionButtonPacket::build(uint8_t button, uint8_t type, uint32_t id, bool isClassic) { + // Classic/Turtle (1.12): uint8 button + uint16 id + uint8 type + uint8 misc(0) + // type encoding: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint8 button + uint32 packed (type<<24 | id) + // type encoding: 0x00=spell, 0x80=item, 0x40=macro + // packed=0 means clear the slot + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTION_BUTTON)); + packet.writeUInt8(button); + if (isClassic) { + // Classic: 16-bit id, 8-bit type code, 8-bit misc + // Map ActionBarSlot::Type (0=EMPTY,1=SPELL,2=ITEM,3=MACRO) → classic type byte + uint8_t classicType = 0; // 0 = spell + if (type == 2 /* ITEM */) classicType = 1; + if (type == 3 /* MACRO */) classicType = 64; + packet.writeUInt16(static_cast(id)); + packet.writeUInt8(classicType); + packet.writeUInt8(0); // misc + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (Classic): button=", (int)button, + " id=", id, " type=", (int)classicType); + } else { + // TBC/WotLK: type in bits 24–31, id in bits 0–23; packed=0 clears slot + uint8_t packedType = 0x00; // spell + if (type == 2 /* ITEM */) packedType = 0x80; + if (type == 3 /* MACRO */) packedType = 0x40; + uint32_t packed = (id == 0) ? 0 : (static_cast(packedType) << 24) | (id & 0x00FFFFFF); + packet.writeUInt32(packed); + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (TBC/WotLK): button=", (int)button, + " packed=0x", std::hex, packed, std::dec); + } + return packet; +} + // ============================================================ // Display Toggles // ============================================================ @@ -2810,7 +2846,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before flags (entry=", data.entry, ")"); return false; } - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyCount packet.readUInt32(); // BuyPrice @@ -2820,7 +2856,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa if (data.inventoryType > 28) { // inventoryType out of range — BuyCount probably not present; rewind and try 4 fields packet.setReadPos(postQualityPos); - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); // SellPrice @@ -2832,18 +2868,18 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")"); return false; } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace data.itemLevel = packet.readUInt32(); data.requiredLevel = packet.readUInt32(); - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); @@ -2909,12 +2945,12 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa } data.armor = static_cast(packet.readUInt32()); - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); packet.readUInt32(); // AmmoType packet.readFloat(); // RangedModRange diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 1351e715..a5a76ab2 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -10,6 +10,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" +#include "rendering/lightning.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" @@ -369,6 +370,11 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text("Intensity: %.0f%%", weather->getIntensity() * 100.0f); } + auto* lightning = renderer->getLightning(); + if (lightning && lightning->isEnabled()) { + ImGui::Text("Lightning: active (%.0f%%)", lightning->getIntensity() * 100.0f); + } + ImGui::Spacing(); } } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 9f85c3d5..f5ab086e 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -12,6 +12,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" +#include "rendering/lightning.hpp" #include "rendering/lighting_manager.hpp" #include "rendering/sky_system.hpp" #include "rendering/swim_effects.hpp" @@ -699,6 +700,9 @@ bool Renderer::initialize(core::Window* win) { weather = std::make_unique(); weather->initialize(vkCtx, perFrameSetLayout); + lightning = std::make_unique(); + lightning->initialize(vkCtx, perFrameSetLayout); + swimEffects = std::make_unique(); swimEffects->initialize(vkCtx, perFrameSetLayout); @@ -802,6 +806,11 @@ void Renderer::shutdown() { weather.reset(); } + if (lightning) { + lightning->shutdown(); + lightning.reset(); + } + if (swimEffects) { swimEffects->shutdown(); swimEffects.reset(); @@ -942,6 +951,7 @@ void Renderer::applyMsaaChange() { if (characterRenderer) characterRenderer->recreatePipelines(); if (questMarkerRenderer) questMarkerRenderer->recreatePipelines(); if (weather) weather->recreatePipelines(); + if (lightning) lightning->recreatePipelines(); if (swimEffects) swimEffects->recreatePipelines(); if (mountDust) mountDust->recreatePipelines(); if (chargeEffect) chargeEffect->recreatePipelines(); @@ -2856,6 +2866,7 @@ void Renderer::update(float deltaTime) { // Server-driven weather (SMSG_WEATHER) — authoritative if (wType == 1) weather->setWeatherType(Weather::Type::RAIN); else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW); + else if (wType == 3) weather->setWeatherType(Weather::Type::RAIN); // thunderstorm — use rain particles else weather->setWeatherType(Weather::Type::NONE); weather->setIntensity(wInt); } else { @@ -2863,6 +2874,20 @@ void Renderer::update(float deltaTime) { weather->updateZoneWeather(currentZoneId, deltaTime); } weather->setEnabled(true); + + // Enable lightning during storms (wType==3) and heavy rain + if (lightning) { + uint32_t wType2 = gh->getWeatherType(); + float wInt2 = gh->getWeatherIntensity(); + bool stormActive = (wType2 == 3 && wInt2 > 0.1f) + || (wType2 == 1 && wInt2 > 0.7f); + lightning->setEnabled(stormActive); + if (stormActive) { + // Scale intensity: storm at full, heavy rain proportionally + float lIntensity = (wType2 == 3) ? wInt2 : (wInt2 - 0.7f) / 0.3f; + lightning->setIntensity(lIntensity); + } + } } else if (weather) { // No game handler (single-player without network) — zone weather only weather->updateZoneWeather(currentZoneId, deltaTime); @@ -2932,6 +2957,11 @@ void Renderer::update(float deltaTime) { weather->update(*camera, deltaTime); } + // Update lightning (storm / heavy rain) + if (lightning && camera && lightning->isEnabled()) { + lightning->update(deltaTime, *camera); + } + // Update swim effects if (swimEffects && camera && cameraController && waterRenderer) { swimEffects->update(*camera, *cameraController, *waterRenderer, deltaTime); @@ -5217,6 +5247,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (waterRenderer && camera) waterRenderer->render(cmd, perFrameSet, *camera, globalTime, false, frameIdx); if (weather && camera) weather->render(cmd, perFrameSet); + if (lightning && camera && lightning->isEnabled()) lightning->render(cmd, perFrameSet); if (swimEffects && camera) swimEffects->render(cmd, perFrameSet); if (mountDust && camera) mountDust->render(cmd, perFrameSet); if (chargeEffect && camera) chargeEffect->render(cmd, perFrameSet); @@ -5353,6 +5384,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (waterRenderer && camera) waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, frameIdx); if (weather && camera) weather->render(currentCmd, perFrameSet); + if (lightning && camera && lightning->isEnabled()) lightning->render(currentCmd, perFrameSet); if (swimEffects && camera) swimEffects->render(currentCmd, perFrameSet); if (mountDust && camera) mountDust->render(currentCmd, perFrameSet); if (chargeEffect && camera) chargeEffect->render(currentCmd, perFrameSet); diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 406164ac..96b53dd0 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -37,6 +37,22 @@ static uint64_t hashEquipment(const std::vector& eq) { return h; } +static ImVec4 classColor(uint8_t classId) { + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A + default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + } +} + void CharacterScreen::render(game::GameHandler& gameHandler) { ImGuiViewport* vp = ImGui::GetMainViewport(); const ImVec2 pad(24.0f, 24.0f); @@ -184,7 +200,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 45.0f); ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 1.2f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 1.5f); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); @@ -224,10 +240,16 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("%s", game::getRaceName(character.race)); ImGui::TableSetColumnIndex(3); - ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::TextColored(classColor(static_cast(character.characterClass)), "%s", game::getClassName(character.characterClass)); ImGui::TableSetColumnIndex(4); - ImGui::Text("%d", character.zoneId); + { + std::string zoneName = gameHandler.getWhoAreaName(character.zoneId); + if (!zoneName.empty()) + ImGui::TextUnformatted(zoneName.c_str()); + else + ImGui::Text("%u", character.zoneId); + } } ImGui::EndTable(); @@ -325,10 +347,21 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("Level %d", character.level); ImGui::Text("%s", game::getRaceName(character.race)); - ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::TextColored(classColor(static_cast(character.characterClass)), "%s", game::getClassName(character.characterClass)); ImGui::Text("%s", game::getGenderName(character.gender)); ImGui::Spacing(); - ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId); + { + std::string mapName = gameHandler.getMapName(character.mapId); + std::string zoneName = gameHandler.getWhoAreaName(character.zoneId); + if (!mapName.empty() && !zoneName.empty()) + ImGui::Text("%s — %s", mapName.c_str(), zoneName.c_str()); + else if (!mapName.empty()) + ImGui::Text("%s (Zone %u)", mapName.c_str(), character.zoneId); + else if (!zoneName.empty()) + ImGui::Text("Map %u — %s", character.mapId, zoneName.c_str()); + else + ImGui::Text("Map %u, Zone %u", character.mapId, character.zoneId); + } if (character.hasGuild()) { ImGui::Text("Guild ID: %d", character.guildId); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 92dccefe..2f30ee64 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -50,8 +50,8 @@ namespace { // Build a WoW-format item link string for chat insertion. // Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) { - static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000"}; - uint8_t qi = quality < 6 ? quality : 1; + static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint8_t qi = quality < 8 ? quality : 1; char buf[512]; snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", kQualHex[qi], itemId, name.c_str()); @@ -395,6 +395,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { gameHandler.setUIErrorCallback([this](const std::string& msg) { uiErrors_.push_back({msg, 0.0f}); if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin()); + // Play error sound for each new error (rate-limited by deque cap of 5) + if (auto* r = core::Application::getInstance().getRenderer()) { + if (auto* sfx = r->getUiSoundManager()) sfx->playError(); + } }); uiErrorCallbackSet_ = true; } @@ -722,6 +726,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (showMinimap_) { renderMinimapMarkers(gameHandler); } + renderLogoutCountdown(gameHandler); renderDeathScreen(gameHandler); renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); @@ -2756,6 +2761,18 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); } + // Active title — shown in gold below the name/level line + { + int32_t titleBit = gameHandler.getChosenTitleBit(); + if (titleBit >= 0) { + const std::string titleText = gameHandler.getFormattedTitle( + static_cast(titleBit)); + if (!titleText.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str()); + } + } + } + // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { @@ -3758,6 +3775,44 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Target-of-Target (ToT): show who the current target is targeting + { + uint64_t totGuid = 0; + const auto& tFields = target->getFields(); + auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (itLo != tFields.end()) { + totGuid = itLo->second; + auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (itHi != tFields.end()) + totGuid |= (static_cast(itHi->second) << 32); + } + if (totGuid != 0) { + auto totEnt = gameHandler.getEntityManager().getEntity(totGuid); + std::string totName; + ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f); + if (totGuid == gameHandler.getPlayerGuid()) { + auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid); + totName = playerEnt ? getEntityName(playerEnt) : "You"; + totColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (totEnt) { + totName = getEntityName(totEnt); + uint8_t cid = entityClassId(totEnt.get()); + if (cid != 0) totColor = classColorVec4(cid); + } + if (!totName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(totColor, "%s", totName.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str()); + } + if (ImGui::IsItemClicked()) { + gameHandler.setTarget(totGuid); + } + } + } + } + // Distance const auto& movement = gameHandler.getMovementInfo(); float dx = target->getX() - movement.x; @@ -4619,6 +4674,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "clear") { + gameHandler.clearChatHistory(); + chatInputBuffer[0] = '\0'; + return; + } + // /invite command if (cmdLower == "invite" && spacePos != std::string::npos) { std::string targetName = command.substr(spacePos + 1); @@ -6754,14 +6815,24 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (slot.id == 8690) { uint32_t mapId = 0; glm::vec3 pos; if (gameHandler.getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; + std::string homeLocation; + // Zone name (from zoneId stored in bind point) + uint32_t zoneId = gameHandler.getHomeBindZoneId(); + if (zoneId != 0) { + homeLocation = gameHandler.getWhoAreaName(zoneId); } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + default: homeLocation = "Unknown"; break; + } + } + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), + "Home: %s", homeLocation.c_str()); } } if (outOfRange) { @@ -8287,7 +8358,13 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { break; case game::CombatTextEntry::ENERGIZE: snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 0.6f, 1.0f, alpha); // Blue for mana/energy + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; // Rage: red + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; // Focus: orange + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; // Energy: yellow + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; // Runic Power: teal + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue + } break; case game::CombatTextEntry::XP_GAIN: snprintf(text, sizeof(text), "+%d XP", entry.amount); @@ -10674,9 +10751,11 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImVec4(0.0f, 0.44f, 0.87f, 1.0f),// 3=rare (blue) ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4=epic (purple) ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5=legendary (orange) + ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 6=artifact (light gold) + ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 7=heirloom (light gold) }; uint8_t q = roll.itemQuality; - ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; + ImVec4 col = (q < 8) ? kQualityColors[q] : kQualityColors[1]; // Countdown bar { @@ -10716,7 +10795,14 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); ImGui::SameLine(); } - ImGui::TextColored(col, "[%s]", roll.itemName.c_str()); + // Prefer live item info (arrives via SMSG_ITEM_QUERY_SINGLE_RESPONSE after the + // roll popup opens); fall back to the name cached at SMSG_LOOT_START_ROLL time. + const char* displayName = (rollInfo && rollInfo->valid && !rollInfo->name.empty()) + ? rollInfo->name.c_str() + : roll.itemName.c_str(); + if (rollInfo && rollInfo->valid) + col = (rollInfo->quality < 8) ? kQualityColors[rollInfo->quality] : kQualityColors[1]; + ImGui::TextColored(col, "[%s]", displayName); if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { inventoryScreen.renderItemTooltip(*rollInfo); } @@ -12376,8 +12462,9 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // 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()); + } else if (hovered && info && !info->name.empty()) { + // Item info received but not yet fully valid — show name at minimum + ImGui::SetTooltip("%s", info->name.c_str()); } ImDrawList* drawList = ImGui::GetWindowDrawList(); @@ -13130,7 +13217,16 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { gameHandler.repairAll(vendor.vendorGuid, false); } if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Repair all equipped items"); + ImGui::SetTooltip("Repair all equipped items using your gold"); + } + if (gameHandler.isInGuild()) { + ImGui::SameLine(); + if (ImGui::SmallButton("Repair (Guild)")) { + gameHandler.repairAll(vendor.vendorGuid, true); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Repair all equipped items using guild bank funds"); + } } } ImGui::Separator(); @@ -14016,6 +14112,68 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// Logout Countdown +// ============================================================ + +void GameScreen::renderLogoutCountdown(game::GameHandler& gameHandler) { + if (!gameHandler.isLoggingOut()) 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; + + constexpr float W = 280.0f; + constexpr float H = 80.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - W) * 0.5f, screenH * 0.5f - H * 0.5f - 60.0f), + ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(W, H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.88f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.8f, 1.0f)); + + if (ImGui::Begin("##LogoutCountdown", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) { + + float cd = gameHandler.getLogoutCountdown(); + if (cd > 0.0f) { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 6.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out in 20s...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), + "Logging out in %ds...", static_cast(std::ceil(cd))); + + // Progress bar (20 second countdown) + float frac = 1.0f - std::min(cd / 20.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.5f, 0.5f, 0.9f, 1.0f)); + ImGui::ProgressBar(frac, ImVec2(-1.0f, 8.0f), ""); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } else { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 14.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "Logging out..."); + ImGui::Spacing(); + } + + // Cancel button — only while countdown is still running + if (cd > 0.0f) { + float btnW = 100.0f; + ImGui::SetCursorPosX((W - btnW) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.cancelLogout(); + } + ImGui::PopStyleColor(2); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Death Screen // ============================================================ @@ -16557,8 +16715,15 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), - "In Queue: %s", bgName.c_str()); + if (slot.avgWaitTimeSec > 0) { + int avgMin = static_cast(slot.avgWaitTimeSec) / 60; + int avgSec = static_cast(slot.avgWaitTimeSec) % 60; + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec); + } else { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "In Queue: %s", bgName.c_str()); + } } ImGui::End(); nextIndicatorY += kIndicatorH; @@ -16590,6 +16755,47 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Calendar pending invites indicator (WotLK only) + { + auto* expReg = core::Application::getInstance().getExpansionRegistry(); + bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk"; + if (isWotLK) { + uint32_t calPending = gameHandler.getCalendarPendingInvites(); + if (calPending > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.0f); + char calBuf[48]; + snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s", + calPending, calPending == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + } + + // Taxi flight indicator — shown while on a flight path + if (gameHandler.isOnTaxiFlight()) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) { + const std::string& dest = gameHandler.getTaxiDestName(); + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 1.0f); + if (dest.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight"); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str()); + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf); + } + } + 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) { @@ -18843,6 +19049,8 @@ void GameScreen::renderItemLootToasts() { 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) + IM_COL32(230, 204, 128, 255), // 6 light gold (artifact) + IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom) }; // Stack at bottom-left above action bars; each item is 24 px tall @@ -18881,7 +19089,7 @@ void GameScreen::renderItemLootToasts() { 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 qualCol = kQualityColors[std::min(static_cast(7u), 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); @@ -19315,7 +19523,12 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); int qMin = static_cast(qMs / 60000); int qSec = static_cast((qMs % 60000) / 1000); - ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), + "Status: In queue for %s (%d:%02d)", dName.c_str(), qMin, qSec); + else + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); if (avgSec >= 0) { int aMin = avgSec / 60; int aSec = avgSec % 60; @@ -19324,18 +19537,33 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { } break; } - case LfgState::Proposal: - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); + case LfgState::Proposal: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); break; + } case LfgState::Boot: ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Status: Vote kick in progress"); break; - case LfgState::InDungeon: - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); + case LfgState::InDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon (%s)", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); break; - case LfgState::FinishedDungeon: - ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); + } + case LfgState::FinishedDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: %s complete", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); break; + } case LfgState::RaidBrowser: ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser"); break; @@ -19345,8 +19573,13 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // ---- Proposal accept/decline ---- if (state == LfgState::Proposal) { - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), - "A group has been found for your dungeon!"); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for your dungeon!"); ImGui::Spacing(); if (ImGui::Button("Accept", ImVec2(120, 0))) { gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); @@ -19517,24 +19750,6 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { if (lockouts.empty()) { ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active instance lockouts."); } else { - // Build map name lookup from Map.dbc (cached after first call) - static std::unordered_map sMapNames; - static bool sMapNamesLoaded = false; - if (!sMapNamesLoaded) { - sMapNamesLoaded = true; - if (auto* am = core::Application::getInstance().getAssetManager()) { - if (auto dbc = am->loadDBC("Map.dbc"); dbc && dbc->isLoaded()) { - for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { - uint32_t id = dbc->getUInt32(i, 0); - // Field 2 = MapName_enUS (first localized), field 1 = InternalName - std::string name = dbc->getString(i, 2); - if (name.empty()) name = dbc->getString(i, 1); - if (!name.empty()) sMapNames[id] = std::move(name); - } - } - } - } - auto difficultyLabel = [](uint32_t diff) -> const char* { switch (diff) { case 0: return "Normal"; @@ -19560,11 +19775,11 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { for (const auto& lo : lockouts) { ImGui::TableNextRow(); - // Instance name + // Instance name — use GameHandler's Map.dbc cache (avoids duplicate DBC load) ImGui::TableSetColumnIndex(0); - auto it = sMapNames.find(lo.mapId); - if (it != sMapNames.end()) { - ImGui::TextUnformatted(it->second.c_str()); + std::string mapName = gameHandler.getMapName(lo.mapId); + if (!mapName.empty()) { + ImGui::TextUnformatted(mapName.c_str()); } else { ImGui::Text("Map %u", lo.mapId); } @@ -19972,31 +20187,60 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); break; case T::MISS: - snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); + if (spell) + snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt); + else + snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::DODGE: - snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); + if (spell) + snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::PARRY: - snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); + if (spell) + snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::BLOCK: - snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); + if (spell) + snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount); + else + snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); break; case T::IMMUNE: - snprintf(desc, sizeof(desc), "%s is immune", tgt); + if (spell) + snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); + else + snprintf(desc, sizeof(desc), "%s is immune", tgt); color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); break; case T::ABSORB: - snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + else + snprintf(desc, sizeof(desc), "Absorbed"); color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); break; case T::RESIST: - snprintf(desc, sizeof(desc), "%d resisted", e.amount); + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d resisted", e.amount); + else + snprintf(desc, sizeof(desc), "Resisted"); color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); break; case T::ENVIRONMENTAL: @@ -20021,6 +20265,28 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "Proc triggered"); color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); break; + case T::DISPEL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); + color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); + break; + case T::INTERRUPT: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s", tgt); + else + snprintf(desc, sizeof(desc), "%s interrupted", tgt); + color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); + break; default: snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index cfea2be4..fb0a0df7 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace wowee { @@ -102,6 +103,8 @@ ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) { case game::ItemQuality::RARE: return ImVec4(0.0f, 0.44f, 0.87f, 1.0f); // Blue case game::ItemQuality::EPIC: return ImVec4(0.64f, 0.21f, 0.93f, 1.0f); // Purple case game::ItemQuality::LEGENDARY: return ImVec4(1.0f, 0.50f, 0.0f, 1.0f); // Orange + case game::ItemQuality::ARTIFACT: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold + case game::ItemQuality::HEIRLOOM: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold default: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); } } @@ -1161,7 +1164,7 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; 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); + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists, &gameHandler); // Played time (shown if available, fetched on character screen open) uint32_t totalSec = gameHandler.getTotalTimePlayed(); @@ -1242,18 +1245,35 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { snprintf(label, sizeof(label), "%s", name.c_str()); } - // Show progress bar with value/max overlay + // Effective value includes temporary and permanent bonuses + uint16_t effective = skill->effectiveValue(); + uint16_t bonus = skill->bonusTemp + skill->bonusPerm; + + // Progress bar reflects effective / max; cap visual fill at 1.0 float ratio = (skill->maxValue > 0) - ? static_cast(skill->value) / static_cast(skill->maxValue) + ? std::min(1.0f, static_cast(effective) / static_cast(skill->maxValue)) : 0.0f; char overlay[64]; - snprintf(overlay, sizeof(overlay), "%u / %u", skill->value, skill->maxValue); + if (bonus > 0) + snprintf(overlay, sizeof(overlay), "%u / %u (+%u)", effective, skill->maxValue, bonus); + else + snprintf(overlay, sizeof(overlay), "%u / %u", effective, skill->maxValue); - ImGui::Text("%s", label); + // Gold name when maxed out, cyan when buffed above base, default otherwise + bool isMaxed = (effective >= skill->maxValue && skill->maxValue > 0); + bool isBuffed = (bonus > 0); + ImVec4 nameColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) + : isBuffed ? ImVec4(0.4f, 0.9f, 1.0f, 1.0f) + : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + ImGui::TextColored(nameColor, "%s", label); ImGui::SameLine(180.0f); ImGui::SetNextItemWidth(-1.0f); + // Bar color: gold when maxed, green otherwise + ImVec4 barColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) : ImVec4(0.2f, 0.7f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); ImGui::ProgressBar(ratio, ImVec2(0, 14.0f), overlay); + ImGui::PopStyleColor(); } } } @@ -1443,6 +1463,8 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId); bool isWatched = (factionId == watchedFactionId); + ImGui::PushID(static_cast(factionId)); + // Faction name + tier label on same line; mark at-war and watched factions ImGui::TextColored(tier.color, "[%s]", tier.name); ImGui::SameLine(90.0f); @@ -1478,7 +1500,23 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(-1.0f); ImGui::ProgressBar(ratio, ImVec2(0, 12.0f), overlay); ImGui::PopStyleColor(); + + // Right-click context menu on the progress bar + if (ImGui::BeginPopupContextItem("##RepCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (isWatched) { + if (ImGui::MenuItem("Untrack")) + gameHandler.setWatchedFactionId(0); + } else { + if (ImGui::MenuItem("Track on Rep Bar")) + gameHandler.setWatchedFactionId(factionId); + } + ImGui::EndPopup(); + } + ImGui::Spacing(); + ImGui::PopID(); } ImGui::EndChild(); @@ -1606,7 +1644,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor, const int32_t* serverStats, - const int32_t* serverResists) { + const int32_t* serverResists, + const game::GameHandler* gh) { // 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 @@ -1776,6 +1815,174 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } } } + + // Server-authoritative combat stats (WotLK update fields — only shown when received) + if (gh) { + int32_t meleeAP = gh->getMeleeAttackPower(); + int32_t rangedAP = gh->getRangedAttackPower(); + int32_t spellPow = gh->getSpellPower(); + int32_t healPow = gh->getHealingPower(); + float dodgePct = gh->getDodgePct(); + float parryPct = gh->getParryPct(); + float blockPct = gh->getBlockPct(); + float critPct = gh->getCritPct(); + float rCritPct = gh->getRangedCritPct(); + float sCritPct = gh->getSpellCritPct(1); // Holy school (avg proxy for spell crit) + // Hit rating: CR_HIT_MELEE=5, CR_HIT_RANGED=6, CR_HIT_SPELL=7 + // Haste rating: CR_HASTE_MELEE=17, CR_HASTE_RANGED=18, CR_HASTE_SPELL=19 + // Other: CR_EXPERTISE=23, CR_ARMOR_PENETRATION=24, CR_CRIT_TAKEN_MELEE=14 + int32_t hitRating = gh->getCombatRating(5); + int32_t hitRangedR = gh->getCombatRating(6); + int32_t hitSpellR = gh->getCombatRating(7); + int32_t expertiseR = gh->getCombatRating(23); + int32_t hasteR = gh->getCombatRating(17); + int32_t hasteRangedR = gh->getCombatRating(18); + int32_t hasteSpellR = gh->getCombatRating(19); + int32_t armorPenR = gh->getCombatRating(24); + int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience + + bool hasAny = (meleeAP >= 0 || spellPow >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f || + blockPct >= 0.0f || critPct >= 0.0f || hitRating >= 0); + if (hasAny) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Combat"); + ImVec4 cyan(0.5f, 0.9f, 1.0f, 1.0f); + if (meleeAP >= 0) ImGui::TextColored(cyan, "Attack Power: %d", meleeAP); + if (rangedAP >= 0 && rangedAP != meleeAP) + ImGui::TextColored(cyan, "Ranged Attack Power: %d", rangedAP); + if (spellPow >= 0) ImGui::TextColored(cyan, "Spell Power: %d", spellPow); + if (healPow >= 0 && healPow != spellPow) + ImGui::TextColored(cyan, "Healing Power: %d", healPow); + if (dodgePct >= 0.0f) ImGui::TextColored(cyan, "Dodge: %.2f%%", dodgePct); + if (parryPct >= 0.0f) ImGui::TextColored(cyan, "Parry: %.2f%%", parryPct); + if (blockPct >= 0.0f) ImGui::TextColored(cyan, "Block: %.2f%%", blockPct); + if (critPct >= 0.0f) ImGui::TextColored(cyan, "Melee Crit: %.2f%%", critPct); + if (rCritPct >= 0.0f) ImGui::TextColored(cyan, "Ranged Crit: %.2f%%", rCritPct); + if (sCritPct >= 0.0f) ImGui::TextColored(cyan, "Spell Crit: %.2f%%", sCritPct); + + // Combat ratings with percentage conversion (WotLK level-80 divisors scaled by level). + // Formula: pct = rating / (divisorAt80 * pow(level/80.0, 0.93)) + // Level-80 divisors derived from gtCombatRatings.dbc (well-known WotLK constants): + // Hit: 26.23, Expertise: 8.19/expertise (0.25% each), + // Haste: 32.79, ArmorPen: 13.99, Resilience: 94.27 + uint32_t level = playerLevel > 0 ? playerLevel : gh->getPlayerLevel(); + if (level == 0) level = 80; + double lvlScale = level <= 80 + ? std::pow(static_cast(level) / 80.0, 0.93) + : 1.0; + + auto ratingPct = [&](int32_t rating, double divisorAt80) -> float { + if (rating < 0 || divisorAt80 <= 0.0) return -1.0f; + double d = divisorAt80 * lvlScale; + return static_cast(rating / d); + }; + + if (hitRating >= 0) { + float pct = ratingPct(hitRating, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Hit Rating: %d (%.2f%%)", hitRating, pct); + else + ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); + } + // Show ranged/spell hit only when they differ from melee hit + if (hitRangedR >= 0 && hitRangedR != hitRating) { + float pct = ratingPct(hitRangedR, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Ranged Hit Rating: %d (%.2f%%)", hitRangedR, pct); + else + ImGui::TextColored(cyan, "Ranged Hit Rating: %d", hitRangedR); + } + if (hitSpellR >= 0 && hitSpellR != hitRating) { + // Spell hit cap at 17% (446 rating at 80); divisor same as melee hit + float pct = ratingPct(hitSpellR, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Spell Hit Rating: %d (%.2f%%)", hitSpellR, pct); + else + ImGui::TextColored(cyan, "Spell Hit Rating: %d", hitSpellR); + } + if (expertiseR >= 0) { + // Each expertise point reduces dodge and parry chance by 0.25% + // expertise_points = rating / 8.19 + float exp_pts = ratingPct(expertiseR, 8.19); + if (exp_pts >= 0.0f) { + float exp_pct = exp_pts * 0.25f; // % dodge/parry reduction + ImGui::TextColored(cyan, "Expertise: %d (%.1f / %.2f%%)", + expertiseR, exp_pts, exp_pct); + } else { + ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR); + } + } + if (hasteR >= 0) { + float pct = ratingPct(hasteR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Haste Rating: %d (%.2f%%)", hasteR, pct); + else + ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); + } + if (hasteRangedR >= 0 && hasteRangedR != hasteR) { + float pct = ratingPct(hasteRangedR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Ranged Haste Rating: %d (%.2f%%)", hasteRangedR, pct); + else + ImGui::TextColored(cyan, "Ranged Haste Rating: %d", hasteRangedR); + } + if (hasteSpellR >= 0 && hasteSpellR != hasteR) { + float pct = ratingPct(hasteSpellR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Spell Haste Rating: %d (%.2f%%)", hasteSpellR, pct); + else + ImGui::TextColored(cyan, "Spell Haste Rating: %d", hasteSpellR); + } + if (armorPenR >= 0) { + float pct = ratingPct(armorPenR, 13.99); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Armor Pen: %d (%.2f%%)", armorPenR, pct); + else + ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR); + } + if (resilR >= 0) { + // Resilience: reduces crit chance against you by pct%, and crit damage by 2*pct% + float pct = ratingPct(resilR, 94.27); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Resilience: %d (%.2f%%)", resilR, pct); + else + ImGui::TextColored(cyan, "Resilience: %d", resilR); + } + } + + // Movement speeds (always show when non-default) + { + constexpr float kBaseRun = 7.0f; + constexpr float kBaseFlight = 7.0f; + float runSpeed = gh->getServerRunSpeed(); + float flightSpeed = gh->getServerFlightSpeed(); + float swimSpeed = gh->getServerSwimSpeed(); + + bool showRun = runSpeed > 0.0f && std::fabs(runSpeed - kBaseRun) > 0.05f; + bool showFlight = flightSpeed > 0.0f && std::fabs(flightSpeed - kBaseFlight) > 0.05f; + bool showSwim = swimSpeed > 0.0f && std::fabs(swimSpeed - 4.722f) > 0.05f; + + if (showRun || showFlight || showSwim) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Movement"); + ImVec4 speedColor(0.6f, 1.0f, 0.8f, 1.0f); + if (showRun) { + float pct = (runSpeed / kBaseRun) * 100.0f; + ImGui::TextColored(speedColor, "Run Speed: %.1f%%", pct); + } + if (showFlight) { + float pct = (flightSpeed / kBaseFlight) * 100.0f; + ImGui::TextColored(speedColor, "Flight Speed: %.1f%%", pct); + } + if (showSwim) { + float pct = (swimSpeed / 4.722f) * 100.0f; + ImGui::TextColored(speedColor, "Swim Speed: %.1f%%", pct); + } + } + } + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { @@ -2080,6 +2287,8 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite case game::ItemQuality::RARE: qualHex = "0070dd"; break; case game::ItemQuality::EPIC: qualHex = "a335ee"; break; case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break; + case game::ItemQuality::ARTIFACT: qualHex = "e6cc80"; break; + case game::ItemQuality::HEIRLOOM: qualHex = "e6cc80"; break; default: break; } char linkBuf[512]; @@ -2106,6 +2315,23 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel); } + // Heroic / Unique / Unique-Equipped indicators + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (qi->itemFlags & kFlagHeroic) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + } + if (qi->maxCount == 1) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + } else if (qi->itemFlags & kFlagUniqueEquipped) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + } + } + } + // Binding type switch (item.bindType) { case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; @@ -2119,16 +2345,24 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I uint32_t mapId = 0; glm::vec3 pos; if (gameHandler_->getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; - case 13: mapName = "Test"; break; - case 169: mapName = "Emerald Dream"; break; + std::string homeLocation; + // Prefer the specific zone name from the bind-point zone ID + uint32_t zoneId = gameHandler_->getHomeBindZoneId(); + if (zoneId != 0) + homeLocation = gameHandler_->getWhoAreaName(zoneId); + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + case 13: homeLocation = "Test"; break; + case 169: homeLocation = "Emerald Dream"; break; + default: homeLocation = "Unknown"; break; + } } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", homeLocation.c_str()); } else { ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Home: not set"); } @@ -2206,6 +2440,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::Text("%d Armor", item.armor); } + // Elemental resistances from item query cache (fire resist gear, nature resist gear, etc.) + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + const int32_t resValsI[6] = { qi->holyRes, qi->fireRes, qi->natureRes, + qi->frostRes, qi->shadowRes, qi->arcaneRes }; + static const char* resLabelsI[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int i = 0; i < 6; ++i) + if (resValsI[i] > 0) ImGui::Text("+%d %s", resValsI[i], resLabelsI[i]); + } + } + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { if (val <= 0) return; if (!out.empty()) out += " "; @@ -2290,10 +2539,12 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I 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; - case 6: trigger = "Soulstone"; break; + case 0: trigger = "Use"; break; // on use + case 1: trigger = "Equip"; break; // on equip + case 2: trigger = "Chance on Hit"; break; // proc on melee hit + case 4: trigger = "Use"; break; // soulstone (still shows as Use) + case 5: trigger = "Use"; break; // on use, no delay + case 6: trigger = "Use"; break; // learn spell (recipe/pattern) default: break; } if (!trigger) continue; @@ -2312,6 +2563,258 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Skill / reputation requirements from item query cache + if (gameHandler_) { + const auto* qInfo = gameHandler_->getItemInfo(item.itemId); + if (qInfo && qInfo->valid) { + if (qInfo->requiredSkill != 0 && qInfo->requiredSkillRank > 0) { + static std::unordered_map s_skillNamesB; + static bool s_skillNamesLoadedB = false; + if (!s_skillNamesLoadedB && assetManager_) { + s_skillNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 2; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNamesB[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler_->getPlayerSkills(); + auto skPit = skills.find(qInfo->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= qInfo->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNamesB.find(qInfo->requiredSkill); + if (skIt != s_skillNamesB.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), qInfo->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", qInfo->requiredSkill, qInfo->requiredSkillRank); + } + if (qInfo->requiredReputationFaction != 0 && qInfo->requiredReputationRank > 0) { + static std::unordered_map s_factionNamesB; + static bool s_factionNamesLoadedB = false; + if (!s_factionNamesLoadedB && assetManager_) { + s_factionNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 20; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNamesB[fid] = std::move(fname); + } + } + } + static const char* kRepRankNamesB[] = { + "Hated","Hostile","Unfriendly","Neutral","Friendly","Honored","Revered","Exalted" + }; + const char* rankName = (qInfo->requiredReputationRank < 8) + ? kRepRankNamesB[qInfo->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNamesB.find(qInfo->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNamesB.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction + if (qInfo->allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClassesB[] = { + { 1,"Warrior" },{ 2,"Paladin" },{ 4,"Hunter" },{ 8,"Rogue" }, + { 16,"Priest" },{ 32,"Death Knight" },{ 64,"Shaman" }, + { 128,"Mage" },{ 256,"Warlock" },{ 1024,"Druid" }, + }; + int mc = 0; + for (const auto& kc : kClassesB) if (qInfo->allowableClass & kc.mask) ++mc; + if (mc > 0 && mc < 10) { + char buf[128] = "Classes: "; bool first = true; + for (const auto& kc : kClassesB) { + if (!(qInfo->allowableClass & kc.mask)) continue; + if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1); + strncat(buf, kc.name, sizeof(buf)-strlen(buf)-1); + first = false; + } + uint8_t pc = gameHandler_->getPlayerClass(); + uint32_t pm = (pc > 0 && pc <= 10) ? (1u << (pc-1)) : 0; + bool ok = (pm == 0 || (qInfo->allowableClass & pm)); + ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf); + } + } + // Race restriction + if (qInfo->allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRacesB[] = { + { 1,"Human" },{ 2,"Orc" },{ 4,"Dwarf" },{ 8,"Night Elf" }, + { 16,"Undead" },{ 32,"Tauren" },{ 64,"Gnome" },{ 128,"Troll" }, + { 512,"Blood Elf" },{ 1024,"Draenei" }, + }; + constexpr uint32_t kAll = 1|2|4|8|16|32|64|128|512|1024; + if ((qInfo->allowableRace & kAll) != kAll) { + int mc = 0; + for (const auto& kr : kRacesB) if (qInfo->allowableRace & kr.mask) ++mc; + if (mc > 0) { + char buf[160] = "Races: "; bool first = true; + for (const auto& kr : kRacesB) { + if (!(qInfo->allowableRace & kr.mask)) continue; + if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1); + strncat(buf, kr.name, sizeof(buf)-strlen(buf)-1); + first = false; + } + uint8_t pr = gameHandler_->getPlayerRace(); + uint32_t pm = (pr > 0 && pr <= 11) ? (1u << (pr-1)) : 0; + bool ok = (pm == 0 || (qInfo->allowableRace & pm)); + ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf); + } + } + } + } + } + + // Gem socket slots and item set — look up from query cache + if (gameHandler_) { + const auto* qi2 = gameHandler_->getItemInfo(item.itemId); + if (qi2 && qi2->valid) { + // Gem sockets + { + 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 (qi2->socketColor[i] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (qi2->socketColor[i] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && qi2->socketBonus != 0) { + static std::unordered_map s_enchantNamesD; + static bool s_enchantNamesLoadedD = false; + if (!s_enchantNamesLoadedD && assetManager_) { + s_enchantNamesLoadedD = 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_enchantNamesD[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNamesD.find(qi2->socketBonus); + if (enchIt != s_enchantNamesD.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)", qi2->socketBonus); + } + } + // Item set membership + if (qi2->itemSetId != 0) { + struct SetEntryD { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setDataD; + static bool s_setDataLoadedD = false; + if (!s_setDataLoadedD && assetManager_) { + s_setDataLoadedD = true; + auto dbc = assetManager_->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = { + "Item0","Item1","Item2","Item3","Item4", + "Item5","Item6","Item7","Item8","Item9" }; + static const char* spellKeys[10] = { + "Spell0","Spell1","Spell2","Spell3","Spell4", + "Spell5","Spell6","Spell7","Spell8","Spell9" }; + static const char* thrKeys[10] = { + "Threshold0","Threshold1","Threshold2","Threshold3","Threshold4", + "Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" }; + uint32_t itemFB[10], spellFB[10], thrFB[10]; + for (int i = 0; i < 10; ++i) { + itemFB[i] = 18+i; spellFB[i] = 28+i; thrFB[i] = 38+i; + } + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntryD e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFB[i]); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFB[i]); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFB[i]); + } + s_setDataD[id] = std::move(e); + } + } + } + auto setIt = s_setDataD.find(qi2->itemSetId); + ImGui::Spacing(); + if (setIt != s_setDataD.end()) { + const SetEntryD& se = setIt->second; + 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()); + } + 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)", qi2->itemSetId); + } + } + } + } + // "Begins a Quest" line (shown in yellow-green like the game) if (item.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); @@ -2460,6 +2963,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel); } + // Unique / Heroic indicators + constexpr uint32_t kFlagHeroic = 0x8; // ITEM_FLAG_HEROIC_TOOLTIP + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; // ITEM_FLAG_UNIQUE_EQUIPPABLE + if (info.itemFlags & kFlagHeroic) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + } + if (info.maxCount == 1) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + } else if (info.itemFlags & kFlagUniqueEquipped) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + } + // Binding type switch (info.bindType) { case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; @@ -2524,6 +3039,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, if (info.armor > 0) ImGui::Text("%d Armor", info.armor); + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { info.holyRes, info.fireRes, info.natureRes, + info.frostRes, info.shadowRes, info.arcaneRes }; + static const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int i = 0; i < 6; ++i) + if (resVals[i] > 0) ImGui::Text("+%d %s", resVals[i], resLabels[i]); + } + auto appendBonus = [](std::string& out, int32_t val, const char* name) { if (val <= 0) return; if (!out.empty()) out += " "; @@ -2576,14 +3103,166 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel); } + // Required skill (e.g. "Requires Engineering (300)") + if (info.requiredSkill != 0 && info.requiredSkillRank > 0) { + // Lazy-load SkillLine.dbc names + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetManager_) { + s_skillNamesLoaded = true; + auto dbc = assetManager_->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 2; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + if (gameHandler_) { + const auto& skills = gameHandler_->getPlayerSkills(); + auto skPit = skills.find(info.requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + } + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info.requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNames.find(info.requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info.requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info.requiredSkill, info.requiredSkillRank); + } + + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info.requiredReputationFaction != 0 && info.requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetManager_) { + s_factionNamesLoaded = true; + auto dbc = assetManager_->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 20; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info.requiredReputationRank < 8) + ? kRepRankNames[info.requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info.requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info.allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClasses[] = { + { 1, "Warrior" }, + { 2, "Paladin" }, + { 4, "Hunter" }, + { 8, "Rogue" }, + { 16, "Priest" }, + { 32, "Death Knight" }, + { 64, "Shaman" }, + { 128, "Mage" }, + { 256, "Warlock" }, + { 1024, "Druid" }, + }; + // Count matching classes + int matchCount = 0; + for (const auto& kc : kClasses) + if (info.allowableClass & kc.mask) ++matchCount; + // Only show if restricted to a subset (not all classes) + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info.allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + // Check if player's class is allowed + bool playerAllowed = true; + if (gameHandler_) { + uint8_t pc = gameHandler_->getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0; + playerAllowed = (pmask == 0 || (info.allowableClass & pmask)); + } + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(clColor, "%s", classBuf); + } + } + + // Race restriction (e.g. "Races: Night Elf, Human") + if (info.allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRaces[] = { + { 1, "Human" }, + { 2, "Orc" }, + { 4, "Dwarf" }, + { 8, "Night Elf" }, + { 16, "Undead" }, + { 32, "Tauren" }, + { 64, "Gnome" }, + { 128, "Troll" }, + { 512, "Blood Elf" }, + { 1024, "Draenei" }, + }; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + // Only show if not all playable races are allowed + if ((info.allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info.allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info.allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + bool playerAllowed = true; + if (gameHandler_) { + uint8_t pr = gameHandler_->getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0; + playerAllowed = (pmask == 0 || (info.allowableRace & pmask)); + } + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // 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; + case 0: trigger = "Use"; break; // on use + case 1: trigger = "Equip"; break; // on equip + case 2: trigger = "Chance on Hit"; break; // proc on melee hit + case 4: trigger = "Use"; break; // soulstone (still shows as Use) + case 5: trigger = "Use"; break; // on use, no delay + case 6: trigger = "Use"; break; // learn spell (recipe/pattern) default: break; } if (!trigger) continue;