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.
This commit is contained in:
Kelsi 2026-03-13 08:35:18 -07:00
parent ea7b276125
commit c0ffca68f2
7 changed files with 166 additions and 3 deletions

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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<int32_t>(val);
LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_);
}
else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast<int32_t>(val); }
else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast<int32_t>(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<int32_t>(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<int32_t>(val); }
else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast<int32_t>(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<int32_t>(val);
}
else {
for (int si = 0; si < 5; ++si) {
if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) {

View file

@ -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},
};

View file

@ -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) {