From c0ffca68f24b7fd57ecffa6f9d1658ad0722ab2d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 08:35:18 -0700 Subject: [PATCH] feat: track and display WotLK server-authoritative combat stats Adds update field tracking for WotLK secondary combat statistics: - UNIT_FIELD_ATTACK_POWER / RANGED_ATTACK_POWER (fields 123, 126) - PLAYER_DODGE/PARRY/BLOCK/CRIT_PERCENTAGE (fields 1025-1029) - PLAYER_RANGED_CRIT_PERCENTAGE, PLAYER_SPELL_CRIT_PERCENTAGE1 (1030, 1032) - PLAYER_FIELD_COMBAT_RATING_1 (25 slots at 1231, hit/expertise/haste/etc.) Both CREATE_OBJECT and VALUES update paths now populate these fields. The Character screen Stats tab shows them when received from the server, with graceful fallback when not available (Classic/TBC expansions). Field indices verified against AzerothCore 3.3.5a UpdateFields.h. --- Data/expansions/wotlk/update_fields.json | 9 ++++ include/game/game_handler.hpp | 35 ++++++++++++++++ include/game/update_field_table.hpp | 13 ++++++ include/ui/inventory_screen.hpp | 3 +- src/game/game_handler.cpp | 53 ++++++++++++++++++++++++ src/game/update_field_table.cpp | 10 +++++ src/ui/inventory_screen.cpp | 46 +++++++++++++++++++- 7 files changed, 166 insertions(+), 3 deletions(-) diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index b35422a3..2f0a50a5 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,13 @@ "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, "PLAYER_CHOSEN_TITLE": 1349, + "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 292206fc..d9f5d0a8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -309,6 +309,31 @@ 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 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; } @@ -2792,6 +2817,16 @@ 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; + 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; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index bc8a53f6..20a17016 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,15 @@ enum class UF : uint16_t { PLAYER_EXPLORED_ZONES_START, PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) + // 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/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 94a7ccd7..720f2f96 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8195,6 +8195,15 @@ 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; + 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(); @@ -10374,6 +10383,15 @@ 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 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; } @@ -10409,6 +10427,19 @@ 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 (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]) { @@ -10766,6 +10797,15 @@ 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 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; @@ -10830,6 +10870,19 @@ 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 (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]) { diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 41473539..4d9d8c66 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,14 @@ 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_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/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index cfea2be4..98cf3482 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1161,7 +1161,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(); @@ -1606,7 +1606,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 +1777,47 @@ 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(); + 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), expertise (CR_EXPERTISE = 23), haste (CR_HASTE_MELEE = 17) + int32_t hitRating = gh->getCombatRating(5); + int32_t expertiseR = gh->getCombatRating(23); + int32_t hasteR = gh->getCombatRating(17); + int32_t armorPenR = gh->getCombatRating(24); + int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience + + bool hasAny = (meleeAP >= 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 (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); + if (hitRating >= 0) ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); + if (expertiseR >= 0) ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR); + if (hasteR >= 0) ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); + if (armorPenR >= 0) ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR); + if (resilR >= 0) ImGui::TextColored(cyan, "Resilience: %d", resilR); + } + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {