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

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