feat: add BG scoreboard (MSG_PVP_LOG_DATA) and fix TBC aura cache for party frames

- Parse MSG_PVP_LOG_DATA to populate BgScoreboardData (players, KB, deaths,
  HKs, honor, BG-specific stats, winner)
- Add /score command to request the scorecard while in a battleground
- Render sortable per-player table with team color-coding and self-highlight
- Refresh button re-requests live data from server
- Fix TBC SMSG_INIT/SET_EXTRA_AURA_INFO_OBSOLETE to populate unitAurasCache_
  for all GUIDs (not just player/target), mirroring WotLK aura update behavior
  so party frame debuff dots work on TBC servers
This commit is contained in:
Kelsi 2026-03-12 12:02:59 -07:00
parent a4c23b7fa2
commit 79c0887db2
4 changed files with 249 additions and 2 deletions

View file

@ -393,6 +393,28 @@ public:
void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
const std::array<BgQueueSlot, 3>& getBgQueues() const { return bgQueues_; } const std::array<BgQueueSlot, 3>& getBgQueues() const { return bgQueues_; }
// BG scoreboard (MSG_PVP_LOG_DATA)
struct BgPlayerScore {
uint64_t guid = 0;
std::string name;
uint8_t team = 0; // 0=Horde, 1=Alliance
uint32_t killingBlows = 0;
uint32_t deaths = 0;
uint32_t honorableKills = 0;
uint32_t bonusHonor = 0;
std::vector<std::pair<std::string, uint32_t>> bgStats; // BG-specific fields
};
struct BgScoreboardData {
std::vector<BgPlayerScore> players;
bool hasWinner = false;
uint8_t winner = 0; // 0=Horde, 1=Alliance
bool isArena = false;
};
void requestPvpLog();
const BgScoreboardData* getBgScoreboard() const {
return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_;
}
// Network latency (milliseconds, updated each PONG response) // Network latency (milliseconds, updated each PONG response)
uint32_t getLatencyMs() const { return lastLatency; } uint32_t getLatencyMs() const { return lastLatency; }
@ -1921,6 +1943,7 @@ private:
void handleArenaTeamEvent(network::Packet& packet); void handleArenaTeamEvent(network::Packet& packet);
void handleArenaTeamStats(network::Packet& packet); void handleArenaTeamStats(network::Packet& packet);
void handleArenaError(network::Packet& packet); void handleArenaError(network::Packet& packet);
void handlePvpLogData(network::Packet& packet);
// ---- Bank handlers ---- // ---- Bank handlers ----
void handleShowBank(network::Packet& packet); void handleShowBank(network::Packet& packet);
@ -2283,6 +2306,9 @@ private:
// Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS)
std::vector<ArenaTeamStats> arenaTeamStats_; std::vector<ArenaTeamStats> arenaTeamStats_;
// BG scoreboard (MSG_PVP_LOG_DATA)
BgScoreboardData bgScoreboard_;
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot

View file

@ -434,6 +434,10 @@ private:
// Threat window // Threat window
bool showThreatWindow_ = false; bool showThreatWindow_ = false;
void renderThreatWindow(game::GameHandler& gameHandler); void renderThreatWindow(game::GameHandler& gameHandler);
// BG scoreboard window
bool showBgScoreboard_ = false;
void renderBgScoreboard(game::GameHandler& gameHandler);
uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps)
uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK)

View file

@ -4988,7 +4988,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleArenaError(packet); handleArenaError(packet);
break; break;
case Opcode::MSG_PVP_LOG_DATA: case Opcode::MSG_PVP_LOG_DATA:
LOG_INFO("Received MSG_PVP_LOG_DATA"); handlePvpLogData(packet);
break; break;
case Opcode::MSG_INSPECT_ARENA_TEAMS: case Opcode::MSG_INSPECT_ARENA_TEAMS:
LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS");
@ -5207,6 +5207,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::vector<AuraSlot>* auraList = nullptr; std::vector<AuraSlot>* auraList = nullptr;
if (auraTargetGuid == playerGuid) auraList = &playerAuras; if (auraTargetGuid == playerGuid) auraList = &playerAuras;
else if (auraTargetGuid == targetGuid) auraList = &targetAuras; else if (auraTargetGuid == targetGuid) auraList = &targetAuras;
else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid];
if (auraList && isInit) auraList->clear(); if (auraList && isInit) auraList->clear();
@ -13467,6 +13468,80 @@ void GameHandler::handleArenaError(network::Packet& packet) {
LOG_INFO("Arena error: ", error, " - ", msg); LOG_INFO("Arena error: ", error, " - ", msg);
} }
void GameHandler::requestPvpLog() {
if (state != WorldState::IN_WORLD || !socket) return;
// MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request
network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA));
socket->send(pkt);
LOG_INFO("Requested PvP log data");
}
void GameHandler::handlePvpLogData(network::Packet& packet) {
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
if (remaining() < 1) return;
bgScoreboard_ = BgScoreboardData{};
bgScoreboard_.isArena = (packet.readUInt8() != 0);
if (bgScoreboard_.isArena) {
// Skip arena-specific header (two teams × (rating change uint32 + name string + 5×uint32))
// Rather than hardcoding arena parse we skip gracefully up to playerCount
// Each arena team block: uint32 + string + uint32*5 — variable length due to string.
// Skip by scanning for the uint32 playerCount heuristically; simply consume rest.
packet.setReadPos(packet.getSize());
return;
}
if (remaining() < 4) return;
uint32_t playerCount = packet.readUInt32();
bgScoreboard_.players.reserve(playerCount);
for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) {
BgPlayerScore ps;
ps.guid = packet.readUInt64();
ps.team = packet.readUInt8();
ps.killingBlows = packet.readUInt32();
ps.honorableKills = packet.readUInt32();
ps.deaths = packet.readUInt32();
ps.bonusHonor = packet.readUInt32();
// Resolve player name from entity manager
{
auto ent = entityManager.getEntity(ps.guid);
if (ent && (ent->getType() == game::ObjectType::PLAYER ||
ent->getType() == game::ObjectType::UNIT)) {
auto u = std::static_pointer_cast<game::Unit>(ent);
if (!u->getName().empty()) ps.name = u->getName();
}
}
// BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value)
if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; }
uint32_t statCount = packet.readUInt32();
for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) {
std::string fieldName;
while (remaining() > 0) {
char c = static_cast<char>(packet.readUInt8());
if (c == '\0') break;
fieldName += c;
}
uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0;
ps.bgStats.emplace_back(std::move(fieldName), val);
}
bgScoreboard_.players.push_back(std::move(ps));
}
if (remaining() >= 1) {
bgScoreboard_.hasWinner = (packet.readUInt8() != 0);
if (bgScoreboard_.hasWinner && remaining() >= 1)
bgScoreboard_.winner = packet.readUInt8();
}
LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=",
bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner);
}
void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
// Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic)
const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc");

View file

@ -611,6 +611,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderGmTicketWindow(gameHandler); renderGmTicketWindow(gameHandler);
renderInspectWindow(gameHandler); renderInspectWindow(gameHandler);
renderThreatWindow(gameHandler); renderThreatWindow(gameHandler);
renderBgScoreboard(gameHandler);
renderObjectiveTracker(gameHandler); renderObjectiveTracker(gameHandler);
// renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now
if (showMinimap_) { if (showMinimap_) {
@ -3979,6 +3980,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return; return;
} }
// /score command — BG scoreboard
if (cmdLower == "score") {
gameHandler.requestPvpLog();
showBgScoreboard_ = true;
chatInputBuffer[0] = '\0';
return;
}
// /time command // /time command
if (cmdLower == "time") { if (cmdLower == "time") {
gameHandler.queryServerTime(); gameHandler.queryServerTime();
@ -4065,7 +4074,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
"Movement: /sit /stand /kneel /dismount", "Movement: /sit /stand /kneel /dismount",
"Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect",
" /helm /cloak /trade /join <channel> /leave <channel>", " /helm /cloak /trade /join <channel> /leave <channel>",
" /unstuck /logout /ticket /help", " /score /unstuck /logout /ticket /help",
}; };
for (const char* line : kHelpLines) { for (const char* line : kHelpLines) {
game::MessageChatData helpMsg; game::MessageChatData helpMsg;
@ -17738,6 +17747,139 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) {
ImGui::End(); ImGui::End();
} }
// ─── BG Scoreboard ────────────────────────────────────────────────────────────
void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) {
if (!showBgScoreboard_) return;
const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard();
ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver);
const char* title = "Battleground Score###BgScore";
if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
return;
}
if (!data) {
ImGui::TextDisabled("No score data yet.");
ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground.");
ImGui::End();
return;
}
// Winner banner
if (data->hasWinner) {
const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde";
ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f)
: ImVec4(1.0f, 0.35f, 0.35f, 1.0f);
float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x;
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f);
ImGui::TextColored(winnerColor, "%s", winnerStr);
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Victory!");
ImGui::Separator();
}
// Refresh button
if (ImGui::SmallButton("Refresh")) {
gameHandler.requestPvpLog();
}
ImGui::SameLine();
ImGui::TextDisabled("%zu players", data->players.size());
// Score table
constexpr ImGuiTableFlags kTableFlags =
ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg |
ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV |
ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable;
// Build dynamic column count based on what BG-specific stats are present
int numBgCols = 0;
std::vector<std::string> bgColNames;
for (const auto& ps : data->players) {
for (const auto& [fieldName, val] : ps.bgStats) {
// Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps")
std::string shortName = fieldName;
auto dotPos = fieldName.rfind('.');
if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1);
bool found = false;
for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } }
if (!found) bgColNames.push_back(shortName);
}
}
numBgCols = static_cast<int>(bgColNames.size());
// Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific
int totalCols = 6 + numBgCols;
float tableH = ImGui::GetContentRegionAvail().y;
if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) {
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f);
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f);
ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f);
ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f);
ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f);
for (const auto& col : bgColNames)
ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f);
ImGui::TableHeadersRow();
// Sort: Alliance first, then Horde; within each team by KB desc
std::vector<const game::GameHandler::BgPlayerScore*> sorted;
sorted.reserve(data->players.size());
for (const auto& ps : data->players) sorted.push_back(&ps);
std::stable_sort(sorted.begin(), sorted.end(),
[](const game::GameHandler::BgPlayerScore* a,
const game::GameHandler::BgPlayerScore* b) {
if (a->team != b->team) return a->team > b->team; // Alliance(1) first
return a->killingBlows > b->killingBlows;
});
uint64_t playerGuid = gameHandler.getPlayerGuid();
for (const auto* ps : sorted) {
ImGui::TableNextRow();
// Team
ImGui::TableNextColumn();
if (ps->team == 1)
ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "Alliance");
else
ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Horde");
// Name (highlight player's own row)
ImGui::TableNextColumn();
bool isSelf = (ps->guid == playerGuid);
if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f));
const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str();
ImGui::TextUnformatted(nameStr);
if (isSelf) ImGui::PopStyleColor();
ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows);
ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths);
ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills);
ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor);
for (const auto& col : bgColNames) {
ImGui::TableNextColumn();
uint32_t val = 0;
for (const auto& [fieldName, fval] : ps->bgStats) {
std::string shortName = fieldName;
auto dotPos = fieldName.rfind('.');
if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1);
if (shortName == col) { val = fval; break; }
}
if (val > 0) ImGui::Text("%u", val);
else ImGui::TextDisabled("-");
}
}
ImGui::EndTable();
}
ImGui::End();
}
// ─── Quest Objective Tracker ────────────────────────────────────────────────── // ─── Quest Objective Tracker ──────────────────────────────────────────────────
void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) {
if (gameHandler.getState() != game::WorldState::IN_WORLD) return; if (gameHandler.getState() != game::WorldState::IN_WORLD) return;