Fix armor stat in character stats panel via UNIT_FIELD_RESISTANCES

The character stats panel was showing Armor: 0 because summing armor
from item query responses is fragile (depends on correct BuyCount/damage
block parsing). Instead, read the server-authoritative total armor
directly from UNIT_FIELD_RESISTANCES (physical resistance, index 0)
in the player entity update fields.

Added UNIT_FIELD_RESISTANCES to the UF enum and all four expansion
JSON files with correct wire indices:
  WotLK 3.3.5a: 99   (NPC_FLAGS=82 + emotestate + stat×5 + posstat×5 + negstat×5)
  TBC 2.4.3:   185   (NPC_FLAGS=168 + same relative layout)
  Classic 1.12: 154  (NPC_FLAGS=147 + emotestate + stat×5, no posstat/negstat)
  Turtle WoW:  154   (same as Classic)

Stats panel uses server armor when > 0, falls back to summed item-query
armor otherwise. Armor rating resets to 0 on world entry and is updated
from both CREATE_OBJECT and VALUES update blocks.
This commit is contained in:
Kelsi 2026-02-19 17:45:09 -08:00
parent 05e2b37894
commit 20cdff0790
10 changed files with 33 additions and 5 deletions

View file

@ -15,6 +15,7 @@
"UNIT_FIELD_AURAS": 50, "UNIT_FIELD_AURAS": 50,
"UNIT_NPC_FLAGS": 147, "UNIT_NPC_FLAGS": 147,
"UNIT_DYNAMIC_FLAGS": 143, "UNIT_DYNAMIC_FLAGS": 143,
"UNIT_FIELD_RESISTANCES": 154,
"UNIT_END": 188, "UNIT_END": 188,
"PLAYER_FLAGS": 190, "PLAYER_FLAGS": 190,
"PLAYER_BYTES": 191, "PLAYER_BYTES": 191,

View file

@ -15,6 +15,7 @@
"UNIT_FIELD_MOUNTDISPLAYID": 154, "UNIT_FIELD_MOUNTDISPLAYID": 154,
"UNIT_NPC_FLAGS": 168, "UNIT_NPC_FLAGS": 168,
"UNIT_DYNAMIC_FLAGS": 164, "UNIT_DYNAMIC_FLAGS": 164,
"UNIT_FIELD_RESISTANCES": 185,
"UNIT_END": 234, "UNIT_END": 234,
"PLAYER_FLAGS": 236, "PLAYER_FLAGS": 236,
"PLAYER_BYTES": 237, "PLAYER_BYTES": 237,

View file

@ -15,6 +15,7 @@
"UNIT_FIELD_AURAS": 50, "UNIT_FIELD_AURAS": 50,
"UNIT_NPC_FLAGS": 147, "UNIT_NPC_FLAGS": 147,
"UNIT_DYNAMIC_FLAGS": 143, "UNIT_DYNAMIC_FLAGS": 143,
"UNIT_FIELD_RESISTANCES": 154,
"UNIT_END": 188, "UNIT_END": 188,
"PLAYER_FLAGS": 190, "PLAYER_FLAGS": 190,
"PLAYER_BYTES": 191, "PLAYER_BYTES": 191,

View file

@ -15,6 +15,7 @@
"UNIT_FIELD_MOUNTDISPLAYID": 69, "UNIT_FIELD_MOUNTDISPLAYID": 69,
"UNIT_NPC_FLAGS": 82, "UNIT_NPC_FLAGS": 82,
"UNIT_DYNAMIC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 147,
"UNIT_FIELD_RESISTANCES": 99,
"UNIT_END": 148, "UNIT_END": 148,
"PLAYER_FLAGS": 150, "PLAYER_FLAGS": 150,
"PLAYER_BYTES": 151, "PLAYER_BYTES": 151,

View file

@ -273,6 +273,9 @@ public:
// Money (copper) // Money (copper)
uint64_t getMoneyCopper() const { return playerMoneyCopper_; } uint64_t getMoneyCopper() const { return playerMoneyCopper_; }
// Server-authoritative armor (UNIT_FIELD_RESISTANCES[0])
int32_t getArmorRating() const { return playerArmorRating_; }
// Inventory // Inventory
Inventory& getInventory() { return inventory; } Inventory& getInventory() { return inventory; }
const Inventory& getInventory() const { return inventory; } const Inventory& getInventory() const { return inventory; }
@ -1435,6 +1438,7 @@ private:
float pendingLootMoneyNotifyTimer_ = 0.0f; float pendingLootMoneyNotifyTimer_ = 0.0f;
std::unordered_map<uint64_t, float> recentLootMoneyAnnounceCooldowns_; std::unordered_map<uint64_t, float> recentLootMoneyAnnounceCooldowns_;
uint64_t playerMoneyCopper_ = 0; uint64_t playerMoneyCopper_ = 0;
int32_t playerArmorRating_ = 0;
// Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating // 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. // money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime.
uint32_t pendingMoneyDelta_ = 0; uint32_t pendingMoneyDelta_ = 0;

View file

@ -32,6 +32,7 @@ enum class UF : uint16_t {
UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only) UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only)
UNIT_NPC_FLAGS, UNIT_NPC_FLAGS,
UNIT_DYNAMIC_FLAGS, UNIT_DYNAMIC_FLAGS,
UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array)
UNIT_END, UNIT_END,
// Player fields // Player fields

View file

@ -131,7 +131,7 @@ private:
int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper); int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper);
void renderEquipmentPanel(game::Inventory& inventory); void renderEquipmentPanel(game::Inventory& inventory);
void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false);
void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel); void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0);
// Slot rendering with interaction support // Slot rendering with interaction support
enum class SlotKind { BACKPACK, EQUIPMENT }; enum class SlotKind { BACKPACK, EQUIPMENT };

View file

@ -2772,6 +2772,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
lastPlayerFields_.clear(); lastPlayerFields_.clear();
onlineEquipDirty_ = false; onlineEquipDirty_ = false;
playerMoneyCopper_ = 0; playerMoneyCopper_ = 0;
playerArmorRating_ = 0;
knownSpells.clear(); knownSpells.clear();
spellCooldowns.clear(); spellCooldowns.clear();
actionBar = {}; actionBar = {};
@ -4427,6 +4428,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
for (const auto& [key, val] : block.fields) { for (const auto& [key, val] : block.fields) {
if (key == ufPlayerXp) { playerXp_ = val; } if (key == ufPlayerXp) { playerXp_ = val; }
else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; }
@ -4440,6 +4442,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerMoneyCopper_ = val; playerMoneyCopper_ = val;
LOG_INFO("Money set from update fields: ", val, " copper"); LOG_INFO("Money set from update fields: ", val, " copper");
} }
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
LOG_INFO("Armor rating from update fields: ", playerArmorRating_);
}
// Do not synthesize quest-log entries from raw update-field slots. // Do not synthesize quest-log entries from raw update-field slots.
// Slot layouts differ on some classic-family realms and can produce // Slot layouts differ on some classic-family realms and can produce
// phantom "already accepted" quests that block quest acceptance. // phantom "already accepted" quests that block quest acceptance.
@ -4684,6 +4690,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
for (const auto& [key, val] : block.fields) { for (const auto& [key, val] : block.fields) {
if (key == ufPlayerXp) { if (key == ufPlayerXp) {
playerXp_ = val; playerXp_ = val;
@ -4711,6 +4718,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerMoneyCopper_ = val; playerMoneyCopper_ = val;
LOG_INFO("Money updated via VALUES: ", val, " copper"); LOG_INFO("Money updated via VALUES: ", val, " copper");
} }
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
}
else if (key == ufPlayerFlags) { else if (key == ufPlayerFlags) {
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
bool wasGhost = releasedSpirit_; bool wasGhost = releasedSpirit_;

View file

@ -35,6 +35,7 @@ static const UFNameEntry kUFNames[] = {
{"UNIT_FIELD_AURAS", UF::UNIT_FIELD_AURAS}, {"UNIT_FIELD_AURAS", UF::UNIT_FIELD_AURAS},
{"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS}, {"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS},
{"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS},
{"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES},
{"UNIT_END", UF::UNIT_END}, {"UNIT_END", UF::UNIT_END},
{"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS},
{"PLAYER_BYTES", UF::PLAYER_BYTES}, {"PLAYER_BYTES", UF::PLAYER_BYTES},
@ -76,6 +77,7 @@ void UpdateFieldTable::loadWotlkDefaults() {
{UF::UNIT_FIELD_MOUNTDISPLAYID, 69}, {UF::UNIT_FIELD_MOUNTDISPLAYID, 69},
{UF::UNIT_NPC_FLAGS, 82}, {UF::UNIT_NPC_FLAGS, 82},
{UF::UNIT_DYNAMIC_FLAGS, 147}, {UF::UNIT_DYNAMIC_FLAGS, 147},
{UF::UNIT_FIELD_RESISTANCES, 99},
{UF::UNIT_END, 148}, {UF::UNIT_END, 148},
{UF::PLAYER_FLAGS, 150}, {UF::PLAYER_FLAGS, 150},
{UF::PLAYER_BYTES, 151}, {UF::PLAYER_BYTES, 151},

View file

@ -1066,7 +1066,7 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
if (ImGui::BeginTabItem("Stats")) { if (ImGui::BeginTabItem("Stats")) {
ImGui::Spacing(); ImGui::Spacing();
renderStatsPanel(inventory, gameHandler.getPlayerLevel()); renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating());
ImGui::EndTabItem(); ImGui::EndTabItem();
} }
@ -1269,15 +1269,13 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
// Stats Panel // Stats Panel
// ============================================================ // ============================================================
void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel) { void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor) {
// Sum equipment stats // Sum equipment stats
int32_t totalArmor = 0;
int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0; int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s)); const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (slot.empty()) continue; if (slot.empty()) continue;
totalArmor += slot.item.armor;
totalStr += slot.item.strength; totalStr += slot.item.strength;
totalAgi += slot.item.agility; totalAgi += slot.item.agility;
totalSta += slot.item.stamina; totalSta += slot.item.stamina;
@ -1285,6 +1283,15 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
totalSpi += slot.item.spirit; totalSpi += slot.item.spirit;
} }
// Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available.
// Falls back to summing item query armors if server armor wasn't received yet.
int32_t itemQueryArmor = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty()) itemQueryArmor += slot.item.armor;
}
int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor;
// Base stats: 20 + level // Base stats: 20 + level
int32_t baseStat = 20 + static_cast<int32_t>(playerLevel); int32_t baseStat = 20 + static_cast<int32_t>(playerLevel);