From 99de1fa3e5ac0b1cd1cca9c0916808cab2983372 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 23:08:15 -0700 Subject: [PATCH] feat: track UNIT_FIELD_STAT0-4 from server update fields for accurate character stats Add UNIT_FIELD_STAT0-4 (STR/AGI/STA/INT/SPI) to the UF enum and wire up per-expansion indices in all four expansion JSON files (WotLK: 84-88, Classic/Turtle: 138-142, TBC: 159-163). Read the values in both CREATE and VALUES player update handlers and store in playerStats_[5]. renderStatsPanel now uses the server-authoritative totals when available, falling back to the previous 20+level estimate only if the server hasn't sent UNIT_FIELD_STAT* yet. Item-query bonuses are still shown as (+N) alongside the server total for both paths. --- Data/expansions/classic/update_fields.json | 5 ++ Data/expansions/tbc/update_fields.json | 5 ++ Data/expansions/turtle/update_fields.json | 5 ++ Data/expansions/wotlk/update_fields.json | 5 ++ include/game/game_handler.hpp | 9 +++ include/game/update_field_table.hpp | 5 ++ include/ui/inventory_screen.hpp | 3 +- src/game/game_handler.cpp | 27 ++++++++ src/game/update_field_table.cpp | 5 ++ src/ui/inventory_screen.cpp | 76 +++++++++++++--------- 10 files changed, 115 insertions(+), 30 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index c393c6e6..8bba605d 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index 1df171f7..196d70ce 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 168, "UNIT_DYNAMIC_FLAGS": 164, "UNIT_FIELD_RESISTANCES": 185, + "UNIT_FIELD_STAT0": 159, + "UNIT_FIELD_STAT1": 160, + "UNIT_FIELD_STAT2": 161, + "UNIT_FIELD_STAT3": 162, + "UNIT_FIELD_STAT4": 163, "UNIT_END": 234, "PLAYER_FLAGS": 236, "PLAYER_BYTES": 237, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index b268c5f8..153fd2ed 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 0bff52fc..2c5c1a8d 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 82, "UNIT_DYNAMIC_FLAGS": 147, "UNIT_FIELD_RESISTANCES": 99, + "UNIT_FIELD_STAT0": 84, + "UNIT_FIELD_STAT1": 85, + "UNIT_FIELD_STAT2": 86, + "UNIT_FIELD_STAT3": 87, + "UNIT_FIELD_STAT4": 88, "UNIT_END": 148, "PLAYER_FLAGS": 150, "PLAYER_BYTES": 153, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8656b9db..11969c88 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -295,6 +295,13 @@ public: // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } + // Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI). + // Returns -1 if the server hasn't sent the value yet. + int32_t getPlayerStat(int idx) const { + if (idx < 0 || idx > 4) return -1; + return playerStats_[idx]; + } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -2109,6 +2116,8 @@ private: std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; int32_t playerArmorRating_ = 0; + // 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}; // 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 88ce8a30..07c735fd 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -34,6 +34,11 @@ enum class UF : uint16_t { UNIT_NPC_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array) + UNIT_FIELD_STAT0, // Strength (effective base, includes items) + UNIT_FIELD_STAT1, // Agility + UNIT_FIELD_STAT2, // Stamina + UNIT_FIELD_STAT3, // Intellect + UNIT_FIELD_STAT4, // Spirit UNIT_END, // Player fields diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 4f3e7970..bfca779f 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -148,7 +148,8 @@ private: int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper); 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); + void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, + const int32_t* serverStats = 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 9964828e..f007c947 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6098,6 +6098,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; + std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); actionBar = {}; @@ -8142,6 +8143,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } @@ -8170,6 +8176,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce // phantom "already accepted" quests that block quest acceptance. @@ -8454,6 +8468,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; @@ -8510,6 +8529,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(false); } } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } } // Do not auto-create quests from VALUES quest-log slot fields for the // same reason as CREATE_OBJECT2 above (can be misaligned per realm). diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 1026c29e..41473539 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -37,6 +37,11 @@ static const UFNameEntry kUFNames[] = { {"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS}, {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES}, + {"UNIT_FIELD_STAT0", UF::UNIT_FIELD_STAT0}, + {"UNIT_FIELD_STAT1", UF::UNIT_FIELD_STAT1}, + {"UNIT_FIELD_STAT2", UF::UNIT_FIELD_STAT2}, + {"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3}, + {"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4}, {"UNIT_END", UF::UNIT_END}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_BYTES", UF::PLAYER_BYTES}, diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 11f825ed..8ceb64a5 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1086,7 +1086,10 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (ImGui::BeginTabItem("Stats")) { ImGui::Spacing(); - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating()); + int32_t stats[5]; + for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); + const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); ImGui::EndTabItem(); } @@ -1376,18 +1379,18 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // Stats Panel // ============================================================ -void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor) { - // Sum equipment stats - int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0; - +void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, + int32_t serverArmor, const int32_t* serverStats) { + // Sum equipment stats for item-query bonus display + int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; - totalStr += slot.item.strength; - totalAgi += slot.item.agility; - totalSta += slot.item.stamina; - totalInt += slot.item.intellect; - totalSpi += slot.item.spirit; + itemStr += slot.item.strength; + itemAgi += slot.item.agility; + itemSta += slot.item.stamina; + itemInt += slot.item.intellect; + itemSpi += slot.item.spirit; } // Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available. @@ -1399,9 +1402,6 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor; - // Base stats: 20 + level - int32_t baseStat = 20 + static_cast(playerLevel); - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); @@ -1414,23 +1414,41 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImGui::TextColored(gray, "Armor: 0"); } - // Helper to render a stat line - auto renderStat = [&](const char* name, int32_t equipBonus) { - int32_t total = baseStat + equipBonus; - if (equipBonus > 0) { - ImGui::TextColored(white, "%s: %d", name, total); - ImGui::SameLine(); - ImGui::TextColored(green, "(+%d)", equipBonus); - } else { - ImGui::TextColored(gray, "%s: %d", name, total); + if (serverStats) { + // Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus. + // serverStats[i] is the server's effective base stat (items included, buffs excluded). + const char* statNames[5] = {"Strength", "Agility", "Stamina", "Intellect", "Spirit"}; + const int32_t itemBonuses[5] = {itemStr, itemAgi, itemSta, itemInt, itemSpi}; + for (int i = 0; i < 5; ++i) { + int32_t total = serverStats[i]; + int32_t bonus = itemBonuses[i]; + if (bonus > 0) { + ImGui::TextColored(white, "%s: %d", statNames[i], total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", bonus); + } else { + ImGui::TextColored(gray, "%s: %d", statNames[i], total); + } } - }; - - renderStat("Strength", totalStr); - renderStat("Agility", totalAgi); - renderStat("Stamina", totalSta); - renderStat("Intellect", totalInt); - renderStat("Spirit", totalSpi); + } else { + // Fallback: estimated base (20 + level) plus item query bonuses. + int32_t baseStat = 20 + static_cast(playerLevel); + auto renderStat = [&](const char* name, int32_t equipBonus) { + int32_t total = baseStat + equipBonus; + if (equipBonus > 0) { + ImGui::TextColored(white, "%s: %d", name, total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", equipBonus); + } else { + ImGui::TextColored(gray, "%s: %d", name, total); + } + }; + renderStat("Strength", itemStr); + renderStat("Agility", itemAgi); + renderStat("Stamina", itemSta); + renderStat("Intellect", itemInt); + renderStat("Spirit", itemSpi); + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {