From 1bf4c2442a1fbd5dd3a2a5d8e309297b6fdfdd46 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 20:23:36 -0700 Subject: [PATCH 01/50] feat: add title selection window with CMSG_SET_TITLE support Track player titles from SMSG_TITLE_EARNED into knownTitleBits_ set, read active title from PLAYER_CHOSEN_TITLE update field (WotLK index 1349), expose via getFormattedTitle()/sendSetTitle() on GameHandler. Add SetTitlePacket builder (CMSG_SET_TITLE: int32 titleBit, -1=clear). Titles window (H key) lists all earned titles from CharTitles.dbc, highlights the active one in gold, and lets the player click to equip or unequip a title with a single server round-trip. --- Data/expansions/wotlk/update_fields.json | 1 + include/game/game_handler.hpp | 12 ++++ include/game/update_field_table.hpp | 1 + include/game/world_packets.hpp | 8 +++ include/ui/game_screen.hpp | 4 ++ src/game/game_handler.cpp | 49 +++++++++++++++- src/game/world_packets.cpp | 7 +++ src/ui/game_screen.cpp | 74 ++++++++++++++++++++++++ 8 files changed, 155 insertions(+), 1 deletion(-) diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 1532f628..b35422a3 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -37,6 +37,7 @@ "PLAYER_FIELD_BANKBAG_SLOT_1": 458, "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, + "PLAYER_CHOSEN_TITLE": 1349, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_DURABILITY": 60, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1f6a029b..4bfcec31 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1505,6 +1505,14 @@ public: void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + // Title system — earned title bits and the currently displayed title + const std::unordered_set& getKnownTitleBits() const { return knownTitleBits_; } + int32_t getChosenTitleBit() const { return chosenTitleBit_; } + /// Returns the formatted title string for a given bit (replaces %s with player name), or empty. + std::string getFormattedTitle(uint32_t bit) const; + /// Send CMSG_SET_TITLE to activate a title (bit >= 0) or clear it (bit = -1). + void sendSetTitle(int32_t bit); + // Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received using AreaDiscoveryCallback = std::function; void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); } @@ -2734,6 +2742,10 @@ private: std::unordered_map titleNameCache_; bool titleNameCacheLoaded_ = false; void loadTitleNameCache(); + // Set of title bit-indices known to the player (from SMSG_TITLE_EARNED). + std::unordered_set knownTitleBits_; + // Currently selected title bit, or -1 for no title. Updated from PLAYER_CHOSEN_TITLE. + int32_t chosenTitleBit_ = -1; // Achievement caches (lazy-loaded from Achievement.dbc on first earned event) std::unordered_map achievementNameCache_; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 07c735fd..09446d65 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -56,6 +56,7 @@ enum class UF : uint16_t { PLAYER_FIELD_BANKBAG_SLOT_1, PLAYER_SKILL_INFO_START, PLAYER_EXPLORED_ZONES_START, + PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) // GameObject fields GAMEOBJECT_DISPLAYID, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 71be1501..5f16039c 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2727,5 +2727,13 @@ public: static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0); }; +/** CMSG_SET_TITLE packet builder. + * titleBit >= 0: activate the title with that bit index. + * titleBit == -1: clear the current title (show no title). */ +class SetTitlePacket { +public: + static network::Packet build(int32_t titleBit); +}; + } // namespace game } // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 56e133cd..846381c4 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -429,6 +429,10 @@ private: char achievementSearchBuf_[128] = {}; void renderAchievementWindow(game::GameHandler& gameHandler); + // Titles window + bool showTitlesWindow_ = false; + void renderTitlesWindow(game::GameHandler& gameHandler); + // GM Ticket window bool showGmTicketWindow_ = false; char gmTicketBuf_[2048] = {}; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f3d3eb2c..52aa12f9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2136,9 +2136,19 @@ void GameHandler::handlePacket(network::Packet& packet) { titleBit); msg = buf; } + // Track in known title set + if (isLost) { + knownTitleBits_.erase(titleBit); + } else { + knownTitleBits_.insert(titleBit); + } + + // Only post chat message for actual earned/lost events (isLost and new earn) + // Server sends isLost=0 for all known titles during login — suppress the chat spam + // by only notifying when we already had some titles (after login sequence) addSystemChatMessage(msg); LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, - " title='", titleStr, "'"); + " title='", titleStr, "' known=", knownTitleBits_.size()); break; } @@ -9046,6 +9056,7 @@ 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 ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); 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), @@ -9082,6 +9093,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); + } else { for (int si = 0; si < 5; ++si) { if (ufStats[si] != 0xFFFF && key == ufStats[si]) { @@ -9378,6 +9393,7 @@ 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 ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); 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), @@ -9425,6 +9441,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); + } else if (key == ufPlayerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; bool wasGhost = releasedSpirit_; @@ -20727,6 +20747,33 @@ void GameHandler::loadTitleNameCache() { LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC"); } +std::string GameHandler::getFormattedTitle(uint32_t bit) const { + const_cast(this)->loadTitleNameCache(); + auto it = titleNameCache_.find(bit); + if (it == titleNameCache_.end() || it->second.empty()) return {}; + + const std::string& pName = [&]() -> const std::string& { + auto nameIt = playerNameCache.find(playerGuid); + static const std::string kUnknown = "unknown"; + return (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown; + }(); + + const std::string& fmt = it->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) { + return fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + } + return fmt; +} + +void GameHandler::sendSetTitle(int32_t bit) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = SetTitlePacket::build(bit); + socket->send(packet); + chosenTitleBit_ = bit; + LOG_INFO("sendSetTitle: bit=", bit); +} + void GameHandler::loadAchievementNameCache() { if (achievementNameCacheLoaded_) return; achievementNameCacheLoaded_ = true; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 98ddd9d3..7e9be845 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -5429,5 +5429,12 @@ network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name return p; } +network::Packet SetTitlePacket::build(int32_t titleBit) { + // CMSG_SET_TITLE: int32 titleBit (-1 = remove active title) + network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE)); + p.writeUInt32(static_cast(titleBit)); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6d86c1a1..ef73a16c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -710,6 +710,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWhoWindow(gameHandler); renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); + renderTitlesWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderBookWindow(gameHandler); @@ -2333,6 +2334,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showAchievementWindow_ = !showAchievementWindow_; } + // Toggle Titles window with H (hero/title screen — no conflicting keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + showTitlesWindow_ = !showTitlesWindow_; + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -20645,4 +20651,72 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── Titles Window ──────────────────────────────────────────────────────────── +void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) { + if (!showTitlesWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Titles", &showTitlesWindow_)) { + ImGui::End(); + return; + } + + const auto& knownBits = gameHandler.getKnownTitleBits(); + const int32_t chosen = gameHandler.getChosenTitleBit(); + + if (knownBits.empty()) { + ImGui::TextDisabled("No titles earned yet."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Select a title to display:"); + ImGui::Separator(); + + // "No Title" option + bool noTitle = (chosen < 0); + if (ImGui::Selectable("(No Title)", noTitle)) { + if (!noTitle) gameHandler.sendSetTitle(-1); + } + if (noTitle) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "<-- active"); + } + + ImGui::Separator(); + + // Sort known bits for stable display order + std::vector sortedBits(knownBits.begin(), knownBits.end()); + std::sort(sortedBits.begin(), sortedBits.end()); + + ImGui::BeginChild("##titlelist", ImVec2(0, 0), false); + for (uint32_t bit : sortedBits) { + const std::string title = gameHandler.getFormattedTitle(bit); + const std::string display = title.empty() + ? ("Title #" + std::to_string(bit)) : title; + + bool isActive = (chosen >= 0 && static_cast(chosen) == bit); + ImGui::PushID(static_cast(bit)); + + if (isActive) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + } + if (ImGui::Selectable(display.c_str(), isActive)) { + if (!isActive) gameHandler.sendSetTitle(static_cast(bit)); + } + if (isActive) { + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("<-- active"); + } + + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + }} // namespace wowee::ui From f5d23a3a1225cbe9a6c3461391d9534ab1dcdb4c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 20:28:03 -0700 Subject: [PATCH 02/50] feat: add equipment set manager window (backtick key) Lists all saved equipment sets from SMSG_EQUIPMENT_SET_LIST with icon placeholder and an Equip button per set. Clicking either the icon or the Equip button sends CMSG_EQUIPMENT_SET_USE to swap the player's gear to that set. Window toggled with the ` (backtick) key. --- include/ui/game_screen.hpp | 4 +++ src/ui/game_screen.cpp | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 846381c4..0fb98ba3 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -433,6 +433,10 @@ private: bool showTitlesWindow_ = false; void renderTitlesWindow(game::GameHandler& gameHandler); + // Equipment Set Manager window + bool showEquipSetWindow_ = false; + void renderEquipSetWindow(game::GameHandler& gameHandler); + // GM Ticket window bool showGmTicketWindow_ = false; char gmTicketBuf_[2048] = {}; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ef73a16c..a711ae9f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -711,6 +711,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); renderTitlesWindow(gameHandler); + renderEquipSetWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderBookWindow(gameHandler); @@ -2339,6 +2340,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showTitlesWindow_ = !showTitlesWindow_; } + // Toggle Equipment Set Manager with ` (backtick / grave — unused in standard WoW) + if (input.isKeyJustPressed(SDL_SCANCODE_GRAVE) && !ImGui::GetIO().WantCaptureKeyboard) { + showEquipSetWindow_ = !showEquipSetWindow_; + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -20719,4 +20725,69 @@ void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── Equipment Set Manager Window ───────────────────────────────────────────── +void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) { + if (!showEquipSetWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) { + ImGui::End(); + return; + } + + const auto& sets = gameHandler.getEquipmentSets(); + + if (sets.empty()) { + ImGui::TextDisabled("No equipment sets saved."); + ImGui::Spacing(); + ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button)."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Click a set to equip it:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false); + for (const auto& set : sets) { + ImGui::PushID(static_cast(set.setId)); + + // Icon placeholder (use a coloured square if no icon texture available) + ImVec2 iconSize(32.0f, 32.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f)); + if (ImGui::Button("##icon", iconSize)) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(3); + + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Equip set: %s", set.name.c_str()); + } + + ImGui::SameLine(); + + // Name and equip button + ImGui::BeginGroup(); + ImGui::TextUnformatted(set.name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f)); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(2); + ImGui::EndGroup(); + + ImGui::Spacing(); + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + }} // namespace wowee::ui From 5684b16721cf114134464b14095437377b952dd5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 20:52:58 -0700 Subject: [PATCH 03/50] fix: talent screen hang (uint8_t overflow) and camera pitch limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change maxRow/maxCol from uint8_t to int in renderTalentTree to prevent infinite loop: uint8_t col <= 255 never exits since col wraps 255→0. Add sanity cap of 15 rows/cols to guard against corrupt DBC data. - Fix dangling reference warning in getFormattedTitle (lambda reference) - Raise MAX_PITCH from 35° to 88° to match WoW standard upward look range --- include/rendering/camera_controller.hpp | 2 +- src/game/game_handler.cpp | 8 +++----- src/ui/talent_screen.cpp | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index fbddd523..235e171f 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -186,7 +186,7 @@ private: static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 20.0f; // Reduced for performance static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 20.0f; static constexpr float MIN_PITCH = -88.0f; // Look almost straight down - static constexpr float MAX_PITCH = 35.0f; // Limited upward look + static constexpr float MAX_PITCH = 88.0f; // Look almost straight up (WoW standard) glm::vec3* followTarget = nullptr; glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 52aa12f9..c11f3607 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20752,11 +20752,9 @@ std::string GameHandler::getFormattedTitle(uint32_t bit) const { auto it = titleNameCache_.find(bit); if (it == titleNameCache_.end() || it->second.empty()) return {}; - const std::string& pName = [&]() -> const std::string& { - auto nameIt = playerNameCache.find(playerGuid); - static const std::string kUnknown = "unknown"; - return (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown; - }(); + static const std::string kUnknown = "unknown"; + auto nameIt = playerNameCache.find(playerGuid); + const std::string& pName = (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown; const std::string& fmt = it->second; size_t pos = fmt.find("%s"); diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index b1231f24..e0598ad2 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -201,20 +201,23 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab return a->column < b->column; }); - // Find grid dimensions - uint8_t maxRow = 0, maxCol = 0; + // Find grid dimensions — use int to avoid uint8_t wrap-around infinite loops + int maxRow = 0, maxCol = 0; for (const auto* talent : talents) { - maxRow = std::max(maxRow, talent->row); - maxCol = std::max(maxCol, talent->column); + maxRow = std::max(maxRow, (int)talent->row); + maxCol = std::max(maxCol, (int)talent->column); } + // Sanity-cap to prevent runaway loops from corrupt/unexpected DBC data + maxRow = std::min(maxRow, 15); + maxCol = std::min(maxCol, 15); // WoW talent grids are always 4 columns wide if (maxCol < 3) maxCol = 3; const float iconSize = 40.0f; const float spacing = 8.0f; const float cellSize = iconSize + spacing; - const float gridWidth = (maxCol + 1) * cellSize + spacing; - const float gridHeight = (maxRow + 1) * cellSize + spacing; + const float gridWidth = (float)(maxCol + 1) * cellSize + spacing; + const float gridHeight = (float)(maxRow + 1) * cellSize + spacing; // Points in this tree uint32_t pointsInTree = 0; @@ -322,8 +325,8 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab } // Render talent icons - for (uint8_t row = 0; row <= maxRow; ++row) { - for (uint8_t col = 0; col <= maxCol; ++col) { + for (int row = 0; row <= maxRow; ++row) { + for (int col = 0; col <= maxCol; ++col) { const game::GameHandler::TalentEntry* talent = nullptr; for (const auto* t : talents) { if (t->row == row && t->column == col) { From a7289520588f8bf4b57d909cac0d1b41432b9a47 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 20:55:39 -0700 Subject: [PATCH 04/50] feat: add defensive and penetration stats to character sheet Display Defense Rating, Dodge Rating, Parry Rating, Block Rating, Block Value, Armor Penetration, and Spell Penetration from equipped items in the Stats tab. Previously these stat types (12-15, 44, 47, 48) were parsed from item data but silently dropped. --- src/ui/inventory_screen.cpp | 54 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 1c029217..8e5c538e 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1594,6 +1594,8 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play // Secondary stat sums from extraStats int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0; int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0; + int32_t itemDefense = 0, itemDodge = 0, itemParry = 0, itemBlock = 0, itemBlockVal = 0; + int32_t itemArmorPen = 0, itemSpellPen = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; @@ -1604,15 +1606,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play itemSpi += slot.item.spirit; for (const auto& es : slot.item.extraStats) { switch (es.statType) { - case 16: case 17: case 18: case 31: itemHit += es.statValue; break; - case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; - case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; - case 35: itemResil += es.statValue; break; + case 12: itemDefense += es.statValue; break; + case 13: itemDodge += es.statValue; break; + case 14: itemParry += es.statValue; break; + case 15: itemBlock += es.statValue; break; + case 16: case 17: case 18: case 31: itemHit += es.statValue; break; + case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; + case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; + case 35: itemResil += es.statValue; break; case 37: itemExpertise += es.statValue; break; - case 38: case 39: itemAP += es.statValue; break; - case 41: case 42: case 45: itemSP += es.statValue; break; - case 43: itemMp5 += es.statValue; break; - case 46: itemHp5 += es.statValue; break; + case 38: case 39: itemAP += es.statValue; break; + case 41: case 42: case 45: itemSP += es.statValue; break; + case 43: itemMp5 += es.statValue; break; + case 44: itemArmorPen += es.statValue; break; + case 46: itemHp5 += es.statValue; break; + case 47: itemSpellPen += es.statValue; break; + case 48: itemBlockVal += es.statValue; break; default: break; } } @@ -1699,7 +1708,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play // Secondary stats from equipped items bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste || - itemResil || itemExpertise || itemMp5 || itemHp5; + itemResil || itemExpertise || itemMp5 || itemHp5 || + itemDefense || itemDodge || itemParry || itemBlock || itemBlockVal || + itemArmorPen || itemSpellPen; if (hasSecondary) { ImGui::Spacing(); ImGui::Separator(); @@ -1708,15 +1719,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImGui::TextColored(green, "+%d %s", val, name); } }; - renderSecondary("Attack Power", itemAP); - renderSecondary("Spell Power", itemSP); - renderSecondary("Hit Rating", itemHit); - renderSecondary("Crit Rating", itemCrit); - renderSecondary("Haste Rating", itemHaste); - renderSecondary("Resilience", itemResil); - renderSecondary("Expertise", itemExpertise); - renderSecondary("Mana per 5 sec", itemMp5); - renderSecondary("Health per 5 sec",itemHp5); + renderSecondary("Attack Power", itemAP); + renderSecondary("Spell Power", itemSP); + renderSecondary("Hit Rating", itemHit); + renderSecondary("Crit Rating", itemCrit); + renderSecondary("Haste Rating", itemHaste); + renderSecondary("Resilience", itemResil); + renderSecondary("Expertise", itemExpertise); + renderSecondary("Defense Rating", itemDefense); + renderSecondary("Dodge Rating", itemDodge); + renderSecondary("Parry Rating", itemParry); + renderSecondary("Block Rating", itemBlock); + renderSecondary("Block Value", itemBlockVal); + renderSecondary("Armor Penetration",itemArmorPen); + renderSecondary("Spell Penetration",itemSpellPen); + renderSecondary("Mana per 5 sec", itemMp5); + renderSecondary("Health per 5 sec", itemHp5); } // Elemental resistances from server update fields From 91535fa9ae8bea5777f66b908a10a57175436864 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:01:51 -0700 Subject: [PATCH 05/50] feat: parse SMSG_ARENA_TEAM_ROSTER and display member list in Arena UI Add ArenaTeamMember / ArenaTeamRoster structs, parse the WotLK 3.3.5a roster packet (guid, online flag, name, per-player week/season W/L, personal rating), store per-teamId, and render a 4-column table (Name / Rating / Week / Season) inside the existing Arena social tab. Online members are highlighted green; offline members are greyed out. --- include/game/game_handler.hpp | 28 ++++++++++++++- src/game/game_handler.cpp | 66 ++++++++++++++++++++++++++++++++++- src/ui/game_screen.cpp | 47 +++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4bfcec31..a11cd067 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1247,6 +1247,29 @@ public: }; const std::vector& getArenaTeamStats() const { return arenaTeamStats_; } + // ---- Arena Team Roster ---- + struct ArenaTeamMember { + uint64_t guid = 0; + std::string name; + bool online = false; + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + uint32_t personalRating = 0; + }; + struct ArenaTeamRoster { + uint32_t teamId = 0; + std::vector members; + }; + // Returns roster for the given teamId, or nullptr if not yet received + const ArenaTeamRoster* getArenaTeamRoster(uint32_t teamId) const { + for (const auto& r : arenaTeamRosters_) { + if (r.teamId == teamId) return &r; + } + return nullptr; + } + // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); void lootItem(uint8_t slotIndex); @@ -2080,6 +2103,7 @@ private: void handleInstanceDifficulty(network::Packet& packet); void handleArenaTeamCommandResult(network::Packet& packet); void handleArenaTeamQueryResponse(network::Packet& packet); + void handleArenaTeamRoster(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); void handleArenaTeamEvent(network::Packet& packet); void handleArenaTeamStats(network::Packet& packet); @@ -2454,7 +2478,9 @@ private: std::vector instanceLockouts_; // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) - std::vector arenaTeamStats_; + std::vector arenaTeamStats_; + // Arena team rosters (updated by SMSG_ARENA_TEAM_ROSTER) + std::vector arenaTeamRosters_; // BG scoreboard (MSG_PVP_LOG_DATA) BgScoreboardData bgScoreboard_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c11f3607..00e2fd2f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5113,7 +5113,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaTeamQueryResponse(packet); break; case Opcode::SMSG_ARENA_TEAM_ROSTER: - LOG_INFO("Received SMSG_ARENA_TEAM_ROSTER"); + handleArenaTeamRoster(packet); break; case Opcode::SMSG_ARENA_TEAM_INVITE: handleArenaTeamInvite(packet); @@ -13692,6 +13692,70 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) { LOG_INFO("Arena team query response: id=", teamId, " name=", teamName); } +void GameHandler::handleArenaTeamRoster(network::Packet& packet) { + // SMSG_ARENA_TEAM_ROSTER (WotLK 3.3.5a): + // uint32 teamId + // uint8 unk (0 = not captainship packet) + // uint32 memberCount + // For each member: + // uint64 guid + // uint8 online (1=online, 0=offline) + // string name (null-terminated) + // uint32 gamesWeek + // uint32 winsWeek + // uint32 gamesSeason + // uint32 winsSeason + // uint32 personalRating + // float modDay (unused here) + // float modWeek (unused here) + if (packet.getSize() - packet.getReadPos() < 9) return; + + uint32_t teamId = packet.readUInt32(); + /*uint8_t unk =*/ packet.readUInt8(); + uint32_t memberCount = packet.readUInt32(); + + // Sanity cap to avoid huge allocations from malformed packets + if (memberCount > 100) memberCount = 100; + + ArenaTeamRoster roster; + roster.teamId = teamId; + roster.members.reserve(memberCount); + + for (uint32_t i = 0; i < memberCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 12) break; + + ArenaTeamMember m; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + m.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 20) break; + m.weekGames = packet.readUInt32(); + m.weekWins = packet.readUInt32(); + m.seasonGames = packet.readUInt32(); + m.seasonWins = packet.readUInt32(); + m.personalRating = packet.readUInt32(); + // skip 2 floats (modDay, modWeek) + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readFloat(); + packet.readFloat(); + } + roster.members.push_back(std::move(m)); + } + + // Replace existing roster for this team or append + for (auto& r : arenaTeamRosters_) { + if (r.teamId == teamId) { + r = std::move(roster); + LOG_INFO("SMSG_ARENA_TEAM_ROSTER: updated teamId=", teamId, + " members=", r.members.size()); + return; + } + } + LOG_INFO("SMSG_ARENA_TEAM_ROSTER: new teamId=", teamId, + " members=", roster.members.size()); + arenaTeamRosters_.push_back(std::move(roster)); +} + void GameHandler::handleArenaTeamInvite(network::Packet& packet) { std::string playerName = packet.readString(); std::string teamName = packet.readString(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a711ae9f..e53d6748 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11854,11 +11854,11 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } - // ---- Arena tab (WotLK: shows per-team rating/record) ---- + // ---- Arena tab (WotLK: shows per-team rating/record + roster) ---- const auto& arenaStats = gameHandler.getArenaTeamStats(); if (!arenaStats.empty()) { if (ImGui::BeginTabItem("Arena")) { - ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false); + ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false); for (size_t ai = 0; ai < arenaStats.size(); ++ai) { const auto& ts = arenaStats[ai]; @@ -11887,6 +11887,49 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ? ts.seasonGames - ts.seasonWins : 0; ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); + // Roster members (from SMSG_ARENA_TEAM_ROSTER) + const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId); + if (roster && !roster->members.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("-- Roster (%zu members) --", + roster->members.size()); + // Column headers + ImGui::Columns(4, "##arenaRosterCols", false); + ImGui::SetColumnWidth(0, 110.0f); + ImGui::SetColumnWidth(1, 60.0f); + ImGui::SetColumnWidth(2, 60.0f); + ImGui::SetColumnWidth(3, 60.0f); + ImGui::TextDisabled("Name"); ImGui::NextColumn(); + ImGui::TextDisabled("Rating"); ImGui::NextColumn(); + ImGui::TextDisabled("Week"); ImGui::NextColumn(); + ImGui::TextDisabled("Season"); ImGui::NextColumn(); + ImGui::Separator(); + + for (const auto& m : roster->members) { + // Name coloured green (online) or grey (offline) + if (m.online) + ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f), + "%s", m.name.c_str()); + else + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::NextColumn(); + + ImGui::Text("%u", m.personalRating); + ImGui::NextColumn(); + + uint32_t wL = m.weekGames > m.weekWins + ? m.weekGames - m.weekWins : 0; + ImGui::Text("%uW/%uL", m.weekWins, wL); + ImGui::NextColumn(); + + uint32_t sL = m.seasonGames > m.seasonWins + ? m.seasonGames - m.seasonWins : 0; + ImGui::Text("%uW/%uL", m.seasonWins, sL); + ImGui::NextColumn(); + } + ImGui::Columns(1); + } + ImGui::Unindent(8.0f); if (ai + 1 < arenaStats.size()) From 7b3578420ad3a4448e0b4d2e7a7f4669d4538d2a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:06:07 -0700 Subject: [PATCH 06/50] fix: correct camera mouse-Y inversion (mouse-down should look down by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDL yrel > 0 means the mouse moved downward. In WoW, moving the mouse down should decrease pitch (look down), but the previous code did += yrel which increased pitch (look up). This made the camera appear inverted — moving the mouse down tilted the view upward. The invertMouse option accidentally produced the correct WoW-default behaviour. Fix: negate the default invert factor so mouse-down = look down without InvertMouse, and mouse-down = look up when InvertMouse is enabled. --- src/rendering/camera_controller.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index a34f05f1..22c5304f 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1903,7 +1903,9 @@ void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { // Directly update stored yaw/pitch (no lossy forward-vector derivation) yaw -= event.xrel * mouseSensitivity; - float invert = invertMouse ? -1.0f : 1.0f; + // SDL yrel > 0 = mouse moved DOWN. In WoW, mouse-down = look down = pitch decreases. + // invertMouse flips to flight-sim style (mouse-down = look up). + float invert = invertMouse ? 1.0f : -1.0f; pitch += event.yrel * mouseSensitivity * invert; // WoW-style pitch limits: can look almost straight down, limited upward From 470421879a40ee263985bf6e93b04a0107ff0370 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:08:40 -0700 Subject: [PATCH 07/50] feat: implement SMSG_GROUP_SET_LEADER and BG player join/leave notifications - SMSG_GROUP_SET_LEADER: parse leader name, update partyData.leaderGuid by name lookup, display system message announcing the new leader - SMSG_BATTLEGROUND_PLAYER_JOINED: parse guid, show named entry message when player is in nameCache - SMSG_BATTLEGROUND_PLAYER_LEFT: parse guid, show named exit message when player is in nameCache Replaces three LOG_INFO/ignore stubs with functional packet handlers. --- src/game/game_handler.cpp | 44 +++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 00e2fd2f..450c6f27 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4890,9 +4890,23 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_QUESTGIVER_OFFER_REWARD: handleQuestOfferReward(packet); break; - case Opcode::SMSG_GROUP_SET_LEADER: - LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); + case Opcode::SMSG_GROUP_SET_LEADER: { + // SMSG_GROUP_SET_LEADER: string leaderName (null-terminated) + if (packet.getSize() > packet.getReadPos()) { + std::string leaderName = packet.readString(); + // Update leaderGuid by name lookup in party members + for (const auto& m : partyData.members) { + if (m.name == leaderName) { + partyData.leaderGuid = m.guid; + break; + } + } + if (!leaderName.empty()) + addSystemChatMessage(leaderName + " is now the group leader."); + LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName); + } break; + } // ---- Teleport / Transfer ---- case Opcode::MSG_MOVE_TELEPORT_ACK: @@ -4983,12 +4997,30 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE: addSystemChatMessage("You have joined the battleground queue."); break; - case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: - LOG_INFO("Battleground player joined"); + case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: { + // SMSG_BATTLEGROUND_PLAYER_JOINED: uint64 guid + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + auto it = playerNameCache.find(guid); + std::string name = (it != playerNameCache.end()) ? it->second : ""; + if (!name.empty()) + addSystemChatMessage(name + " has entered the battleground."); + LOG_INFO("SMSG_BATTLEGROUND_PLAYER_JOINED: guid=0x", std::hex, guid, std::dec); + } break; - case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: - LOG_INFO("Battleground player left"); + } + case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: { + // SMSG_BATTLEGROUND_PLAYER_LEFT: uint64 guid + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + auto it = playerNameCache.find(guid); + std::string name = (it != playerNameCache.end()) ? it->second : ""; + if (!name.empty()) + addSystemChatMessage(name + " has left the battleground."); + LOG_INFO("SMSG_BATTLEGROUND_PLAYER_LEFT: guid=0x", std::hex, guid, std::dec); + } break; + } case Opcode::SMSG_INSTANCE_DIFFICULTY: case Opcode::MSG_SET_DUNGEON_DIFFICULTY: handleInstanceDifficulty(packet); From e68ffbc711b7c1a9f7654d665f18e8791c00515a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:19:17 -0700 Subject: [PATCH 08/50] feat: populate Classic playerAuras from UNIT_FIELD_AURAS update fields Classic WoW (1.12) does not use SMSG_AURA_UPDATE like WotLK or TBC. Instead, active aura spell IDs are sent via 48 consecutive UNIT_FIELD_AURAS slots in SMSG_UPDATE_OBJECT CREATE_OBJECT and VALUES blocks. Previously these fields were only used for mount spell ID detection. Now on CREATE_OBJECT and VALUES updates for the player entity (Classic only), any changed UNIT_FIELD_AURAS slot triggers a full rebuild of playerAuras from the entity's accumulated field state, enabling the buff/debuff bar to display active auras for Classic players. --- src/game/game_handler.cpp | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 450c6f27..6ade8017 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8924,6 +8924,37 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(true); } } + // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + bool hasAuraField = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } + } + if (hasAuraField) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + a.flags = 0; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); + } + } + } // Determine hostility from faction template for online creatures. // Always call isHostileFaction — factionTemplate=0 defaults to hostile // in the lookup rather than silently staying at the struct default (false). @@ -9337,6 +9368,38 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } + // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + bool hasAuraUpdate = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } + } + if (hasAuraUpdate) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + a.flags = 0; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); + } + } + } + // Some units/players are created without displayId and get it later via VALUES. if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && displayIdChanged && From a1edddd1f0f59a715eb83e0a6d78db780e8a102a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:24:42 -0700 Subject: [PATCH 09/50] feat: open dungeon finder UI when server sends SMSG_OPEN_LFG_DUNGEON_FINDER Previously SMSG_OPEN_LFG_DUNGEON_FINDER was consumed silently with no UI response. Now it fires an OpenLfgCallback wired to openDungeonFinder() on the GameScreen, so the dungeon finder window opens as the server requests. --- include/game/game_handler.hpp | 5 +++++ include/ui/game_screen.hpp | 1 + src/core/application.cpp | 5 +++++ src/game/game_handler.cpp | 6 +++++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a11cd067..1cdd20ea 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1625,6 +1625,10 @@ public: using TaxiFlightStartCallback = std::function; void setTaxiFlightStartCallback(TaxiFlightStartCallback cb) { taxiFlightStartCallback_ = std::move(cb); } + // Callback fired when server sends SMSG_OPEN_LFG_DUNGEON_FINDER (open dungeon finder UI) + using OpenLfgCallback = std::function; + void setOpenLfgCallback(OpenLfgCallback cb) { openLfgCallback_ = std::move(cb); } + bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } float getServerRunSpeed() const { return serverRunSpeed_; } @@ -2916,6 +2920,7 @@ private: TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; TaxiFlightStartCallback taxiFlightStartCallback_; + OpenLfgCallback openLfgCallback_; uint32_t currentMountDisplayId_ = 0; uint32_t mountAuraSpellId_ = 0; // Spell ID of the aura that caused mounting (for CMSG_CANCEL_AURA fallback) float serverRunSpeed_ = 7.0f; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0fb98ba3..32428fcc 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -641,6 +641,7 @@ public: uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, uint32_t intel = 0, uint32_t spi = 0); void triggerAchievementToast(uint32_t achievementId, std::string name = {}); + void openDungeonFinder() { showDungeonFinder_ = true; } }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 396c260f..0feba036 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2550,6 +2550,11 @@ void Application::setupUICallbacks() { } }); + // Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER + gameHandler->setOpenLfgCallback([this]() { + if (uiManager) uiManager->getGameScreen().openDungeonFinder(); + }); + // Creature move callback (online mode) - update creature positions gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { if (!renderer || !renderer->getCharacterRenderer()) return; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6ade8017..7c96b73a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5133,10 +5133,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_UPDATE_LFG_LIST: case Opcode::SMSG_LFG_PLAYER_INFO: case Opcode::SMSG_LFG_PARTY_INFO: - case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: // Informational LFG packets not yet surfaced in UI — consume silently. packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: + // Server requests client to open the dungeon finder UI + packet.setReadPos(packet.getSize()); // consume any payload + if (openLfgCallback_) openLfgCallback_(); + break; case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT: handleArenaTeamCommandResult(packet); From 758ca76bd3ad0ea4d38d40ea3868c5c1e8d76d5b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:27:02 -0700 Subject: [PATCH 10/50] feat: parse MSG_INSPECT_ARENA_TEAMS and display in inspect window Implements MSG_INSPECT_ARENA_TEAMS (WotLK): reads the inspected player's arena team data (2v2/3v3/5v5 bracket, team name, personal rating, week/season W-L) and stores it in InspectResult.arenaTeams. The inspect window now shows an "Arena Teams" section below the gear list when arena team data is available, displaying bracket, team name, rating, and win/loss record. Also implement SMSG_COMPLAIN_RESULT with user-visible feedback for report-player results. --- include/game/game_handler.hpp | 11 ++++++++ src/game/game_handler.cpp | 47 ++++++++++++++++++++++++++++++++--- src/ui/game_screen.cpp | 22 ++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1cdd20ea..42403a32 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -339,6 +339,16 @@ public: // Inspection void inspectTarget(); + struct InspectArenaTeam { + uint32_t teamId = 0; + uint8_t type = 0; // bracket size: 2, 3, or 5 + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + std::string name; + uint32_t personalRating = 0; + }; struct InspectResult { uint64_t guid = 0; std::string playerName; @@ -348,6 +358,7 @@ public: uint8_t activeTalentGroup = 0; std::array itemEntries{}; // 0=head…18=ranged std::array enchantIds{}; // permanent enchant per slot (0 = none) + std::vector arenaTeams; // from MSG_INSPECT_ARENA_TEAMS (WotLK) }; const InspectResult* getInspectResult() const { return inspectResult_.guid ? &inspectResult_ : nullptr; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7c96b73a..07b56112 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5166,9 +5166,37 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_PVP_LOG_DATA: handlePvpLogData(packet); break; - case Opcode::MSG_INSPECT_ARENA_TEAMS: - LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); + case Opcode::MSG_INSPECT_ARENA_TEAMS: { + // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); + break; + } + uint64_t inspGuid = packet.readUInt64(); + uint8_t teamCount = packet.readUInt8(); + if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5 + if (inspGuid == inspectResult_.guid || inspectResult_.guid == 0) { + inspectResult_.guid = inspGuid; + inspectResult_.arenaTeams.clear(); + for (uint8_t t = 0; t < teamCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 21) break; + InspectArenaTeam team; + team.teamId = packet.readUInt32(); + team.type = packet.readUInt8(); + team.weekGames = packet.readUInt32(); + team.weekWins = packet.readUInt32(); + team.seasonGames = packet.readUInt32(); + team.seasonWins = packet.readUInt32(); + team.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 4) break; + team.personalRating = packet.readUInt32(); + inspectResult_.arenaTeams.push_back(std::move(team)); + } + } + LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec, + " teams=", (int)teamCount); break; + } case Opcode::MSG_TALENT_WIPE_CONFIRM: { // Server sends: uint64 npcGuid + uint32 cost // Client must respond with the same opcode containing uint64 npcGuid to confirm. @@ -5869,10 +5897,21 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); break; } - case Opcode::SMSG_COMPLAIN_RESULT: + case Opcode::SMSG_COMPLAIN_RESULT: { + // uint8 result: 0=success, 1=failed, 2=disabled + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("Your complaint has been submitted."); + else if (result == 2) + addUIError("Report a Player is currently disabled."); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_LOOT_LIST: - // Consume — not yet processed + // Consume silently — informational, no UI action needed packet.setReadPos(packet.getSize()); break; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e53d6748..e5526b09 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20697,6 +20697,28 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::EndChild(); } + // Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS) + if (!result->arenaTeams.empty()) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams"); + ImGui::Spacing(); + for (const auto& team : result->arenaTeams) { + const char* bracket = (team.type == 2) ? "2v2" + : (team.type == 3) ? "3v3" + : (team.type == 5) ? "5v5" : "?v?"; + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), + "[%s] %s", bracket, team.name.c_str()); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f), + " Rating: %u", team.personalRating); + if (team.weekGames > 0 || team.seasonGames > 0) { + ImGui::TextDisabled(" Week: %u/%u Season: %u/%u", + team.weekWins, team.weekGames, + team.seasonWins, team.seasonGames); + } + } + } + ImGui::End(); } From fb8c251a821d9150b6637bc5376c251b867b1ae0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:28:24 -0700 Subject: [PATCH 11/50] feat: implement SMSG_ACHIEVEMENT_DELETED, SMSG_CRITERIA_DELETED, SMSG_FORCED_DEATH_UPDATE - SMSG_ACHIEVEMENT_DELETED: removes achievement from earnedAchievements_ and achievementDates_ so the achievements UI stays accurate after revocation - SMSG_CRITERIA_DELETED: removes criteria from criteriaProgress_ tracking - SMSG_FORCED_DEATH_UPDATE: sets playerDead_ when server force-kills the player (GM command, scripted events) instead of silently consuming --- src/game/game_handler.cpp | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 07b56112..d6f03a03 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2307,10 +2307,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DAMAGE_CALC_LOG: case Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT: case Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE: - case Opcode::SMSG_FORCED_DEATH_UPDATE: // Consume — handled by broader object update or not yet implemented packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_FORCED_DEATH_UPDATE: + // Server forces player into dead state (GM command, scripted event, etc.) + playerDead_ = true; + if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet + LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); + packet.setReadPos(packet.getSize()); + break; // ---- Zone defense messages ---- case Opcode::SMSG_DEFENSE_MESSAGE: { @@ -2392,11 +2398,27 @@ void GameHandler::handlePacket(network::Packet& packet) { // Time bias — consume without processing packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_ACHIEVEMENT_DELETED: - case Opcode::SMSG_CRITERIA_DELETED: - // Consume achievement/criteria removal notifications + case Opcode::SMSG_ACHIEVEMENT_DELETED: { + // uint32 achievementId — remove from local earned set + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t achId = packet.readUInt32(); + earnedAchievements_.erase(achId); + achievementDates_.erase(achId); + LOG_DEBUG("SMSG_ACHIEVEMENT_DELETED: id=", achId); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_CRITERIA_DELETED: { + // uint32 criteriaId — remove from local criteria progress + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t critId = packet.readUInt32(); + criteriaProgress_.erase(critId); + LOG_DEBUG("SMSG_CRITERIA_DELETED: id=", critId); + } + packet.setReadPos(packet.getSize()); + break; + } // ---- Combat clearing ---- case Opcode::SMSG_ATTACKSWING_DEADTARGET: From 18d0e6a2520ca001cb0723b8defd085904917a80 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:33:19 -0700 Subject: [PATCH 12/50] feat: use UNIT_FIELD_AURAFLAGS to correctly classify Classic buffs vs debuffs Classic WoW stores aura flags in UNIT_FIELD_AURAFLAGS (12 uint32 fields packed 4 bytes per uint32, one byte per aura slot). Flag bit 0x02 = harmful (debuff), 0x04 = helpful (buff). - Add UNIT_FIELD_AURAFLAGS to update_field_table.hpp (Classic wire index 98) - Add wire index 98 to Classic and Turtle WoW JSON update field tables - Both Classic aura rebuild paths (CREATE_OBJECT and VALUES) now read the flag byte for each aura slot to populate AuraSlot.flags, enabling the buff/debuff bar to correctly separate buffs from debuffs on Classic --- Data/expansions/classic/update_fields.json | 1 + Data/expansions/turtle/update_fields.json | 1 + include/game/update_field_table.hpp | 1 + src/game/game_handler.cpp | 24 ++++++++++++++++++---- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index bb269d8a..4f340df5 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -14,6 +14,7 @@ "UNIT_FIELD_DISPLAYID": 131, "UNIT_FIELD_MOUNTDISPLAYID": 133, "UNIT_FIELD_AURAS": 50, + "UNIT_FIELD_AURAFLAGS": 98, "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 74b873ae..a27e84f7 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -14,6 +14,7 @@ "UNIT_FIELD_DISPLAYID": 131, "UNIT_FIELD_MOUNTDISPLAYID": 133, "UNIT_FIELD_AURAS": 50, + "UNIT_FIELD_AURAFLAGS": 98, "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 09446d65..bc8a53f6 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -31,6 +31,7 @@ enum class UF : uint16_t { UNIT_FIELD_DISPLAYID, UNIT_FIELD_MOUNTDISPLAYID, UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only) + UNIT_FIELD_AURAFLAGS, // Aura flags packed 4-per-uint32 (12 uint32 slots); 0x01=cancelable,0x02=harmful,0x04=helpful UNIT_NPC_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d6f03a03..98b81291 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8991,7 +8991,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create if (block.guid == playerGuid && isClassicLikeExpansion()) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); if (ufAuras != 0xFFFF) { bool hasAuraField = false; for (const auto& [fk, fv] : block.fields) { @@ -9009,7 +9010,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (it != allFields.end() && it->second != 0) { AuraSlot& a = playerAuras[slot]; a.spellId = it->second; - a.flags = 0; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; a.durationMs = -1; a.maxDurationMs = -1; a.casterGuid = playerGuid; @@ -9435,7 +9443,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated if (block.guid == playerGuid && isClassicLikeExpansion()) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); if (ufAuras != 0xFFFF) { bool hasAuraUpdate = false; for (const auto& [fk, fv] : block.fields) { @@ -9453,7 +9462,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (it != allFields.end() && it->second != 0) { AuraSlot& a = playerAuras[slot]; a.spellId = it->second; - a.flags = 0; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; a.durationMs = -1; a.maxDurationMs = -1; a.casterGuid = playerGuid; From 9b092782c9def505e33e935ca4af44d119873998 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:34:16 -0700 Subject: [PATCH 13/50] fix: normalize Classic UNIT_FIELD_AURAFLAGS harmful bit to WotLK debuff convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buff/debuff bar uses 0x80 (WotLK convention) to identify debuffs. Classic UNIT_FIELD_AURAFLAGS uses 0x02 for harmful auras instead. Map Classic 0x02 → 0x80 during aura rebuild so the UI correctly separates buffs from debuffs for Classic players. --- src/game/game_handler.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 98b81291..17ae17bf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -9011,13 +9011,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { AuraSlot& a = playerAuras[slot]; a.spellId = it->second; // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - uint8_t aFlag = 0; + // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful + // Normalize to WotLK convention: 0x80 = negative (debuff) + uint8_t classicFlag = 0; if (ufAuraFlags != 0xFFFF) { auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); if (fit != allFields.end()) - aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); } - a.flags = aFlag; + // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) + a.flags = (classicFlag & 0x02) ? 0x80u : 0u; a.durationMs = -1; a.maxDurationMs = -1; a.casterGuid = playerGuid; From 4c1bc842bcc6de8ac844de6b45f771872484ade2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:39:22 -0700 Subject: [PATCH 14/50] fix: normalize TBC SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE harmful bit to WotLK debuff convention TBC aura packets (SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE / SMSG_SET_EXTRA_AURA_INFO_OBSOLETE) use flag bit 0x02 for harmful (debuff) auras, same as Classic 1.12. The UI checks bit 0x80 for debuff display, following the WotLK SMSG_AURA_UPDATE convention. Without normalization, all TBC debuffs were displayed in the buff bar instead of the debuff bar. Normalize using (flags & 0x02) ? 0x80 : 0, matching the fix applied to Classic in 9b09278. --- src/game/game_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 17ae17bf..17b39b50 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5462,7 +5462,9 @@ void GameHandler::handlePacket(network::Packet& packet) { while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); AuraSlot& a = (*auraList)[slot]; a.spellId = spellId; - a.flags = flags; + // TBC uses same flag convention as Classic: 0x02=harmful, 0x04=beneficial. + // Normalize to WotLK SMSG_AURA_UPDATE convention: 0x80=debuff, 0=buff. + a.flags = (flags & 0x02) ? 0x80u : 0u; a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast(durationMs); a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast(maxDurMs); a.receivedAtMs = nowMs; From 793c2b5611002552488dad8b84deaeb9ebe984b5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:52:00 -0700 Subject: [PATCH 15/50] fix: remove incorrect Y-flip in postprocess vertex shader postprocess.vert.glsl had `TexCoord.y = 1.0 - TexCoord.y` which inverted the vertical sampling of the scene texture. Vulkan textures use v=0 at the top, matching framebuffer row 0, so no flip is needed. The camera already flips the projection matrix (mat[1][1] *= -1) so the scene is rendered correctly oriented; the extra inversion in the postprocess pass flipped FXAA output upside down. Fixes: FXAA shows camera upside down Also fixes: FSR1 upscale and FSR3 sharpen passes (same vertex shader) --- assets/shaders/postprocess.vert.glsl | 4 +++- assets/shaders/postprocess.vert.spv | Bin 1340 -> 932 bytes 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/shaders/postprocess.vert.glsl b/assets/shaders/postprocess.vert.glsl index aa78b1b5..2ed8f784 100644 --- a/assets/shaders/postprocess.vert.glsl +++ b/assets/shaders/postprocess.vert.glsl @@ -6,5 +6,7 @@ void main() { // Fullscreen triangle trick: 3 vertices, no vertex buffer TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); gl_Position = vec4(TexCoord * 2.0 - 1.0, 0.0, 1.0); - TexCoord.y = 1.0 - TexCoord.y; // flip Y for Vulkan + // No Y-flip: scene textures use Vulkan convention (v=0 at top), + // and NDC y=-1 already maps to framebuffer top, so the triangle + // naturally samples the correct row without any inversion. } diff --git a/assets/shaders/postprocess.vert.spv b/assets/shaders/postprocess.vert.spv index afc10472a717a27b5271a74a49ea7de1c07bbc28..89065a80956cae9eb53207486ec177dc883898cb 100644 GIT binary patch delta 85 zcmdnPwS=9QnMs+Qfq{{Mn}L@>XCiO#<_5+mjDk80tPDV525lf#0^!Mp%;~(xfudYM eo(K^0PX5XqJvoI%fKhvLDN8!XU!WNlKnwsnG!6Cu delta 490 zcmYjO%SyvQ6un8BRHIo)-G~$)S^C@*+$czuAQW1tx)F&b8N$FgDM>2TO&5NF^N)FFtUJP_3rrHK!|4JMw^)K3Bo7~~;_S^6_vCR$&b~@#inKX~VxTqQcwxCap z&GjmC<(GN0(gTB+CLh>llv`HA@XMB!+?_(>e!Bp_%NoEP9h`dr^Uv^by$^7n`C0A& R_gU@)Q*q6{AN<%O;1}3?P~iXo From f8f57411f254e89f7bb3b5131e523317349081e2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:54:48 -0700 Subject: [PATCH 16/50] feat: implement SMSG_BATTLEFIELD_LIST handler Parse the battleground availability list sent by the server when the player opens the BG finder. Handles all three expansion wire formats: - Classic: bgTypeId + isRegistered + count + instanceIds - TBC: adds isHoliday byte - WotLK: adds minLevel/maxLevel for bracket display Stores results in availableBgs_ (public via getAvailableBgs()) so the UI can show available battlegrounds and running instance counts without an additional server round-trip. --- include/game/game_handler.hpp | 15 +++++++ src/game/game_handler.cpp | 76 ++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 42403a32..4a1ad26a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -405,11 +405,22 @@ public: std::chrono::steady_clock::time_point inviteReceivedTime{}; }; + // Available BG list (populated by SMSG_BATTLEFIELD_LIST) + struct AvailableBgInfo { + uint32_t bgTypeId = 0; + bool isRegistered = false; + bool isHoliday = false; + uint32_t minLevel = 0; + uint32_t maxLevel = 0; + std::vector instanceIds; + }; + // Battleground bool hasPendingBgInvite() const; void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); const std::array& getBgQueues() const { return bgQueues_; } + const std::vector& getAvailableBgs() const { return availableBgs_; } // BG scoreboard (MSG_PVP_LOG_DATA) struct BgPlayerScore { @@ -2475,6 +2486,10 @@ private: // ---- Battleground queue state ---- std::array bgQueues_{}; + // ---- Available battleground list (SMSG_BATTLEFIELD_LIST) ---- + std::vector availableBgs_; + void handleBattlefieldList(network::Packet& packet); + // Instance difficulty uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 17b39b50..aa834bfe 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5001,7 +5001,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleBattlefieldStatus(packet); break; case Opcode::SMSG_BATTLEFIELD_LIST: - LOG_INFO("Received SMSG_BATTLEFIELD_LIST"); + handleBattlefieldList(packet); break; case Opcode::SMSG_BATTLEFIELD_PORT_DENIED: addSystemChatMessage("Battlefield port denied."); @@ -13220,6 +13220,80 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { } } +void GameHandler::handleBattlefieldList(network::Packet& packet) { + // SMSG_BATTLEFIELD_LIST wire format by expansion: + // + // Classic 1.12 (vmangos/cmangos): + // bgTypeId(4) isRegistered(1) count(4) [instanceId(4)...] + // + // TBC 2.4.3: + // bgTypeId(4) isRegistered(1) isHoliday(1) count(4) [instanceId(4)...] + // + // WotLK 3.3.5a: + // bgTypeId(4) isRegistered(1) isHoliday(1) minLevel(4) maxLevel(4) count(4) [instanceId(4)...] + + if (packet.getSize() - packet.getReadPos() < 5) return; + + AvailableBgInfo info; + info.bgTypeId = packet.readUInt32(); + info.isRegistered = packet.readUInt8() != 0; + + const bool isWotlk = isActiveExpansion("wotlk"); + const bool isTbc = isActiveExpansion("tbc"); + + if (isTbc || isWotlk) { + if (packet.getSize() - packet.getReadPos() < 1) return; + info.isHoliday = packet.readUInt8() != 0; + } + + if (isWotlk) { + if (packet.getSize() - packet.getReadPos() < 8) return; + info.minLevel = packet.readUInt32(); + info.maxLevel = packet.readUInt32(); + } + + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t count = packet.readUInt32(); + + // Sanity cap to avoid OOM from malformed packets + constexpr uint32_t kMaxInstances = 256; + count = std::min(count, kMaxInstances); + info.instanceIds.reserve(count); + + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 4) break; + info.instanceIds.push_back(packet.readUInt32()); + } + + // Update or append the entry for this BG type + bool updated = false; + for (auto& existing : availableBgs_) { + if (existing.bgTypeId == info.bgTypeId) { + existing = std::move(info); + updated = true; + break; + } + } + if (!updated) { + availableBgs_.push_back(std::move(info)); + } + + const auto& stored = availableBgs_.back(); + static const std::unordered_map kBgNames = { + {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, + {4, "Nagrand Arena"}, {5, "Blade's Edge Arena"}, {6, "All Arenas"}, + {7, "Eye of the Storm"}, {8, "Ruins of Lordaeron"}, + {9, "Strand of the Ancients"}, {10, "Dalaran Sewers"}, + {11, "The Ring of Valor"}, {30, "Isle of Conquest"}, + }; + auto nameIt = kBgNames.find(stored.bgTypeId); + const char* bgName = (nameIt != kBgNames.end()) ? nameIt->second : "Unknown Battleground"; + + LOG_INFO("SMSG_BATTLEFIELD_LIST: ", bgName, " bgType=", stored.bgTypeId, + " registered=", stored.isRegistered ? "yes" : "no", + " instances=", stored.instanceIds.size()); +} + void GameHandler::declineBattlefield(uint32_t queueSlot) { if (state != WorldState::IN_WORLD) return; if (!socket) return; From ebaf95cc422ca91b846585c9a7404158c7a0324d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 21:59:41 -0700 Subject: [PATCH 17/50] fix: remove Y-flip counter-hacks in FSR shaders; invert mouse by default; FSR1 disables MSAA FSR EASU and FSR2 sharpen fragment shaders had a manual Y-flip to undo the now-removed postprocess.vert flip. Strip those since the vertex shader no longer flips, making all postprocess paths consistent. Also flip the default mouse Y-axis to match user expectation (mouse down = look up / flight-sim style) and make FSR1 disable MSAA on enable, matching FSR2 behaviour (FSR provides its own spatial AA). --- assets/shaders/fsr2_sharpen.frag.glsl | 4 +--- assets/shaders/fsr2_sharpen.frag.spv | Bin 4596 -> 2108 bytes assets/shaders/fsr_easu.frag.glsl | 4 +--- assets/shaders/fsr_easu.frag.spv | Bin 10292 -> 4792 bytes include/rendering/camera_controller.hpp | 2 +- src/rendering/renderer.cpp | 8 +++++++- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/assets/shaders/fsr2_sharpen.frag.glsl b/assets/shaders/fsr2_sharpen.frag.glsl index 9cd1271c..392a3a37 100644 --- a/assets/shaders/fsr2_sharpen.frag.glsl +++ b/assets/shaders/fsr2_sharpen.frag.glsl @@ -10,9 +10,7 @@ layout(push_constant) uniform PushConstants { } pc; void main() { - // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, - // but we need standard UV coords for texture sampling) - vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + vec2 tc = TexCoord; vec2 texelSize = pc.params.xy; float sharpness = pc.params.z; diff --git a/assets/shaders/fsr2_sharpen.frag.spv b/assets/shaders/fsr2_sharpen.frag.spv index 20672a9e3b5a7a4d58c1526172ed40486f399819..24519b931ca6b9e9294d1d1d4000feb10e24a51d 100644 GIT binary patch literal 2108 zcmZ9M%}bS05XRp$wen_uq*0dnQBhdZhel*tLUj!UueA`WjbK4xwMZg@Fq5EF|3gGX zg^LzJC6!AJ`+wR5{eJhJ$rlfd=b7i3Gc#w-d#$eOsmroWS$(!W`;}EzQ&t6&!PS=c z;@GvZzK65ZefPW)}`-a&pa`SvJh zd;`1j-k~G)Cg<5k_N|!xdRD(Fajp2x;7-h3y~%F@yF9;>-Sg%%eovX_56gJ{b}V|` z3pU<<>}`L--gP%hck->5U*WgYzjHRu`Ug^woc-%NZ<7&Y zXTzVZ=oi9Y#Q(jvHe|hTV0-nx*3bH*3=4i0&e~z8`F5O(@8UAYAD9~U zAz0tDfccgqu+F*_^L=~+W9*|1lOLhJ^WBFnk#b0-7dm;H&@*45?DU!Uk1zBL)0&Vt*;jK zN5R%ti~3i<@|r8^NA7VrYvps7@GI}Lo_hk$+-i~gDp+1~MeYJG;x{^(IN#qHJB!e+ z=QMjXS7JjsigTGZLBw)(>B(-mD9{&Y~;M1)2wIg0^gVSyNjvY z)2qZiO8hLlZ)MzLOnwD>nYbt5RZK0ug{NS7S8#J+IcFQ(eB%6#*k>*KbIcfL?HOZ2 zTL6b<4P!%lk<*Ox_ZxS44ff97CGPwNEbn}SdkdELeS>=kmUq^{y$8$t)=}dFSl&Kj zULV2MRg1V!VCTJvMZV8qXRj7#zkq!cxkCE?@)hj;dogo+&bXN8cd#+8;C_JPTyQ@V e=iE!o-T!CHnCFdGpTUOtFT2FPjQyqZ8SEbjAAY_7 literal 4596 zcmZve_ji;<6ox;D2^K)WiUot%QEXsCkfJPVGy&`_uE|PPlij$xq1iwz*n4l-d+%MW zzv%ztFP`J`?Dt)q!{OuHGw=J}J9qBP+}Y4Ee%6F68<*{oP0Iev@^5N39wkHTYLBI! zRXuZ>t-(2S=bd20DU9?hD{+(odrN0WX-Z)n6yi_dsgb{ zFZK48dd}$|7#;0zw$=|<`iHBvO8;QBQ5k4e*Hnz#HHoX%28PE5(|}GQ(sesoV>i}l z4P;p-|E%Mp%DScXdSejS*7nn1SyvfeRo%c6XkFQK#=f!U(9(LX*(%psO?Vd`b-&Sa zqdbCW{sZMj{#oGWP`NQ$t2CWsJ;yK(RBEkCBVTV0yjmL_Yn?k%Ud@8WoQzhhH(Eo* zoP~_d`q*|}g0HPKTMX^^6^xbg|NIL0NVT@Iv?TYtxsmd^?c7Uflxr@>{LYM0vc|yZ z%0g=AT%T(fFu*&qO^j>XxHEfzvAwTKtzvF-e9Bl~Q)x7Z>U9fK^NyD_%Bz>whwH9U zVsD=L49ssiF>!{Tm0yXq)a(W0J)c7{zu+@-+UBki`SxO#^JbExgHgOMsEe^1+mC$q znp|Hud{;&_O};zhxPs3wxHYTo1E0v)jrq+k_@tb+=T8P3>l*qUr(#*Q@vMlS1~bNb z_GdK4KAlnQUCmk!V6hM*^^3DQC8yiDy;PsJ z`Hb*yX!|!V3jP`V@4x<9VE&)M*6M!EFW-i#okFj;KA1hP-iPKI`(ozOw~*7{ta&&;F=Nzw=@#F>Ntn9kHx9GM%x`b#kNk7NY5sX|^SgiV>Qu~qr005C zq2FHI#p!UhIMXx0z8CdP#W~Lhn_FMhekR!U^+oLq!0MV`TDyI$KWaY=JUPhZr!46LsCrM3FT^hd4B!D+4M!qZyMgIjCb z+U(|XT*!A>9t(Sw$C5YVTi(+2g^l0_S~``>%$3 zHxqeH#$SV(&-ZDp{+Qbj_AG*53wFJjTL#BmW3Nqo*CjsJFgE55BtGjk*0<^JtiD*U z0#5gGJv`mZYIwSrA-MIpFJtvbt|~a)%MI{!FE_$tuCe-~-r>aO8peiiB=K3VvF^pc z5&B~NI#@0EDA@h_+iblzVaBLCzgB1w--N66@mp&A7}iS6{#Rdv8LK~H*Mg@ewxx(& zho;XtuGwHzi#2ZsyXHeh{4HR$4Ory96}+=LZbLU-f5hGn-dPtfuDsYlG6U}MtX>AT?S5p#DDa~Nafxd*K7eVTU*7Cr5J{o%Ve@wvXS zk>@_}EG%O02S*;~>346N81=;&KL|di;I97=X3SmeUH&lUdFOhrJp%U}^~K#h3O1L% z$o&{NeJ_v0eJ{}~Pk@b4k2z0*eJ2s~6xcZRh(&?ZUI3^2e-ZBfb3NbxOK|tEFXp`rPUmffJ5N3S|MLpixm&U5$ydSZVevLz zL$f~p-rEC=YHa1F&)G5%VF~JByf)z{aUZjUR*6^S$K1C!fIGtG+k~waEK9crf8#fUm$} zoiD-0sK+{Afz?05%=sy!TIBp1?0EzkscAMe$C51*`dm_8VBuoT2?*XyY0C_|4g$@ej=T S#_PAYU2_|wzJJMZ8ulM+N_9E_ diff --git a/assets/shaders/fsr_easu.frag.glsl b/assets/shaders/fsr_easu.frag.glsl index 20e5ed32..6a36be75 100644 --- a/assets/shaders/fsr_easu.frag.glsl +++ b/assets/shaders/fsr_easu.frag.glsl @@ -21,9 +21,7 @@ vec3 fsrFetch(vec2 p, vec2 off) { } void main() { - // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, - // but we need standard UV coords for texture sampling) - vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + vec2 tc = TexCoord; // Map output pixel to input space vec2 pp = tc * fsr.con2.xy; // output pixel position diff --git a/assets/shaders/fsr_easu.frag.spv b/assets/shaders/fsr_easu.frag.spv index 5ddc2ea8f71d374f23c73a50e87cc4a107ade85c..12780757b4bc45f9bac29ddb33f99caa22745f5a 100644 GIT binary patch literal 4792 zcmZ9PU5Hjy6oyYTjvD!ICN(*JW`d@UmF8IEI7i6LGEJf=sAS4At+6bFf}$d%A}XTa z(ZDi@!m@;Jx~S={3zZ5|(78yk$O$UCFuRF*p7ZS$d(P%8_FC`ydDq@&pL4#iwPp60 zqByHKrx;&6+)`9ya?t`(fQ_!^)oX8B+xf_jO`S`wxYU3NMQasj%*0|$F^XB~@7uCX z#{y(G&Pl|HLr|{vHn|#Z>lr7P zJ#`=sZTky*DpAJGBF7r)=b?>eZXnZ%cMaP5w9ihq@#kYrOt!t|BkXqS*}riO@+YSt zvF>EMKaTm>a!s$SqF25P(8b)(4m8&$mWy>S1hY5&*43VouxZ$?xq;Z1JlZo6hxrX= z^Q{|w%t3ePpU-Tr`(b@;_f{w*@P`-OknpUyV*;#zk5@h zSk&4K#?F+HcOSM`p0@?t+HyqP))bRt_otW~djQOOIo6+Y#iQP~6w}0-d-oui_aG#C zeh7Tmz`*jz-HvU4%2MtH6L+XNygz?~v@!R9dY={|p23x%4d%-b&%Ec*81M1ri1>Ok z%vqc;aR+S`JJWq%f^JP?qUJJmYjz<~^GbB_sCgB-nBuVJxMFO@*OP4dtZ%IA9LKq` zpAs>CEMn}lsh6vhT~_m`>K*a{fxauANiqAd^?SCW-;crU*JnHW{RB)r`rVH%rjS~l z{n&#~!K`b{t#s%feugf$KI44`*3+@i#Q`w=io?m6=SzBP=fhx2##Z5;yK50Y&rDR zIflUWD-Nfe<0L={=JOpKzkrG79H+45&{yXe2Gg%NoN|so0ZK6655aL7Og!iK3tJ9- zb&fM&`W1&$&halm3FiAII9k!gbB-Ohy;aIi_IC zp^s|>$5a6Qilffa4qk%$J`9eT=;Ap?16vM#Tq8JU0q9p8b&h%9CAja`;8=(*o^y0! z%b|~J1jix({feW`(FI-%XC%k=gEbQ7JKO#aB67>+`(5l1VxHeI{So^ux^eapvEQLbtbX62*7zQ=j{eC1 z0o`8hE%J|`N51~ZKZCUw`Bep<6?Lf~~{W7jbPVj_Lon(J4-!&xQAM47&OH0-JzttsI+%ZCsw)p5nw-Qmun| zCgL~W-^VV>@4h^Zau+ARx$cwu627IG&%JRk!q=7gtYPfu^w`b3 z9I=)$AJK(<^d#JSXdiJON^pDZMWW}G=v;+8TgP1EqW&s$`^@WKgDoEQqt+TQuBw(f z#^tqcz;1i^#!&W-C8-e728@l){kv1v6s1@{^s0{K7?2|upQ{bh;{A7 zxJS{A)faJ(q5IvJE8-qUH&$Q7J%R4GTmFc965UvRf$c;;fDC5XbLirSGwcO)@iQ6r zBD#1RJW=l@Y;nKofxUt)?l(BFSFy$YW_osA@9Ws&{@;r8{1&#)m;beH5&HothgPCu< Te$VF${+aAX*Y^+1%aH#7TX$HV literal 10292 zcmZ9R37nNx8OA@jcK}%waY0-MaLWY|+);2u7*rGl758D71qOzhkr@^d6%iE?ao-h1 zQIxVQ&9W>^D=RBoZ7<9Aeb-8}|Nrv6cJ6n7_vJj#^FHT2=R4o|mOGSskL;Uey|Mw> zplnO8tn%40>y473^{KS=Q)f;c)!oxP>WCvpYuG+3ReXAO$oghwT4P~jTZf55h>au% z<5On

HHp_9A*Y>z~z6ZK$6%t$ymHhNiBrhVGtA+glpi+d5hrn%fq&H1)JCY0=j& z@wIg{wJ&Z?4K+B@acdZ95M#7EZj_#huj-GBPYE4BKH+6PY)*&?40qt;ep?R!mV^YmkLmR8+ zeDBnB)5NXYJk!B*2#;?j zZCB%>#)UaQyNZ{xS#XA~c+acKm9q<~cqzLG&Z((*8>({Utg(uhDm9r-#cQ8*4s)w` zDQknPIo}0UxpL;b|KGQib;9T6-bGcpa>gmF@-Bf}+q%oDa^-A!6)$Bg;PZ3u6;-)% zwz7(svQ=>BY2DRTxpH=G6)$CL;O)70ZB?$Et*hdtY(3nI$htRI<;vNuRlJnl4tFi| z-dU9^XLnceQg$DF&Q!gnYy-F{#~Z;dIo2s_@ zX!j=k$i1Hj-wBt#;qlg|pH@wuF$}f? ztsajj{RH2G-X5bq$JjA9sg29IYv|`-xV45>ST?~q><(5d6MKU^ezi2LyC<4+GjCnF z_inXa;I2z8;k+D2z7OHY{$5A4VM!L;^uZLBYM{nf00C~Ym_$NutB6}GMa zVPL%;OVnq3^I16|{A}@xNACQNBRn_3>+-zwnFe+)&N=Fx38voGLpWFFW)L2OdT8f@ z9XHi3&bi+3HG}1;=6%<`YB6RPn9qsP?-Gbw+HWb`@$4JE%X3evU7dSU?RqrprP@t- zU-hVWNA6K`eeR`o4bt^~8-B;u*T%+rj^u>6xApG}b}jYq%&OPH;}|D_S=aHwuc`3t zJ%0B#UKjcM;rHG%Y`k?J0y~EL%6%jEI#oN0^W^z;Z~IYu3#Eouuh-Z?MC>`a?-(hc zP~cMv{IrC-XQn6IdNT{$_l~Hq-y3Cc{l0so{DOq*_uV7SHxzhd!u2;L-1;pAJ~!d| z+Y+vSL4kWCkMZ?)CS3ob0{2}c^)E@de&0tTFJGQ;>#s<-`6~*1Wy1BZO1S>j1%7S9 z^{+{|{mVYz^XN9!{u-hu5)+ z$LCjgjs&Y;O&$9jMR*TapN{4jM-$dEH=etn_c}Fem_L?KbDUb*afHwG;|M+8Q;#Rq zr_sYcC*+#C9}XwGCLeD+*qVAA&wHs_jCUefEygj}N)oR{}>wV2l_V6}m)r2bP0$MJq(&OTFU)uYc;uziA`4%QQ6 zo&naQZhxN>YT-W%tlyl^2)Wqj!wlkB=Hm169AXfm$DE$&wCYjod~nqA`61Wmv*TPM zYF!AnmL79@X3?rgt=ZtH)c}_3YoxuHh+1>N*3x56&qcKAQL7akwS4Z#_06SiCZg6n zu(kA<)6+z&9<|!RQOjqNT;D?4`9##}09#9sIXynp)T356IBNO4lI!cC_5CVpEe2al zk2yV^wCYjoQgGB-2A1nvPP>$dT9<*XrN^9}CA8{M>q>Ccx&kcMx03d9B5GX)ww9ii zgq{_&>QU<&aMW52mg~Eg_G%((T?e+79&>tD(W*zS8^BR(Em*E^9qsi*)VdLDEj{M+ ztf5tpTDO3s*3DqKzFTQ;BBIu9U~B0yr)NE_depiL9JTHQ%k|w&dj}DiRd(%ESLK*y|(pKLS_RzmZlR z{zt)H1F8QpxVrvLwDRyj4tAfX{wLt-`Zv?c!~cG;dpY%g0Isfo3#~l-9|XH!Q~!tH z>iVCgm52YsVE1I|{|H=N|5LQ`@IM1~-=+SK!qxRZO)C%o$G~fd)cCJX z*!`3GKM7aY{~WD6{GS55M^gW%;p+OIr(M>5B?dj#~9D&7r`$O>h|ACs}}yx zg56`mKL_@hqyOi@derUz60KVJzW{a*1^*)0a~S=<1lFT&|Cedi!vAHk=P>wJz^-BR z|0-CIy8U0FRSW;u!0Gs3hr36k|2M#T)P2tOq5US&izpN3qt>^;_6`1Ru=_V^y$05! z9<{y$j#}og;<|Q>?-GtYI=HKLR_3`KbS6u=Rrf z1e~7B*Wr59qyA69QU7&t)c+aSG0eN}uhObT?Vp3wee(-=y6(S(>rs!feg%%P%tx(X zgVS~Y4Ln_!-@^5%N3Gw1qn7!Y_wT`uV?OHt0i0eVe}t#!^-pj;>QVpC;HYmt>i-4o z80Mq?U%}Q3{x@*C?th2tQIGon07rfEQU9M{$1v}@zd@@Owci4(1^*Y=a})gEc|Pj> z2d+mww0FSjZxd_s{rX?9waq#1o3v_C*U6^i^eXV)aOV|cl;C>QLo0*T({XFy);8z3 z??Tn;^5M`~nVEd{&w!c@^qQ+j};@EqmsfTBu+>;)6Et-0aGaPI`b;t2{ zuv#6fzc1~6#9qvQByD=m_s?_o83C5ZemD@E?#=hX-HWj%2f_8I$MttGm~Z77U_NRc z0#0ik3Qza-D7YT=tMWZ_7+5{s*Q4Q%VQwJ5?%Z34gZX9$66W`#^?3ZPZ!YFA7VI$w zKLV_0AioT)b0nB=WqqunUyrr?JAwJ=do0-pg-=QkdhQeE zmxA@EN1tWjwxrLcaE~kcEC=gR_dHnVGO)GGMV%Gk6-k}T;T~Vqxxzf59(Aq+TgzP3 zSqWa1)VT`oaYmh0U_I(l=W4LE%(*T-v}&;q*MbiqV!vM}Ce-z>rd5kMtN~ji`1RoF z`J6odweVTAG5!s3J?hbC9r&Web0b`zdU)1@I}^`MaDD3Gxf#49@!SH}ryibL!D|xF zZE$_+;kg~WKJnZE*QXwyJLQSzF1SAR={!|D_Itp0C*j^yzZXrvdF%NMx)1Ey#=1WM zR=b~wdK7blVkHZ9s}#~6WZonQx5I%T4v=4-S+*kdrK Z-}5#t-vgetMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) { + pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + msaaChangePending_ = true; + } + } else { // Defer destruction to next beginFrame() — can't destroy mid-render fsr_.needsRecreate = true; } From 9b60108fa62d968b0aba29dd6e716c15faced41e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 22:07:03 -0700 Subject: [PATCH 18/50] feat: handle SMSG_MEETINGSTONE, LFG timeout, SMSG_WHOIS, and SMSG_MIRRORIMAGE_DATA Add handlers for 14 previously-unhandled server opcodes: LFG error/timeout states (WotLK Dungeon Finder): - SMSG_LFG_TIMEDOUT: invite timed out, shows message and re-opens LFG UI - SMSG_LFG_OTHER_TIMEDOUT: another player's response timed out - SMSG_LFG_AUTOJOIN_FAILED: auto-join failed with reason code - SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: no players available for auto-join - SMSG_LFG_LEADER_IS_LFM: party leader is in LFM mode Meeting Stone (Classic/TBC era group-finding feature): - SMSG_MEETINGSTONE_SETQUEUE: shows zone and level range in chat - SMSG_MEETINGSTONE_COMPLETE: group ready notification - SMSG_MEETINGSTONE_IN_PROGRESS: search ongoing notification - SMSG_MEETINGSTONE_MEMBER_ADDED: player name resolved and shown in chat - SMSG_MEETINGSTONE_JOINFAILED: localized error message (4 reason codes) - SMSG_MEETINGSTONE_LEAVE: queue departure notification Other: - SMSG_WHOIS: displays GM /whois result line-by-line in system chat - SMSG_MIRRORIMAGE_DATA: parses WotLK mirror image unit display ID and applies it to the entity so mirror images render with correct appearance --- src/game/game_handler.cpp | 162 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index aa834bfe..fd0f4b56 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1646,6 +1646,29 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + case Opcode::SMSG_WHOIS: { + // GM/admin response to /whois command: cstring with account/IP info + // Format: string (the whois result text, typically "Name: ...\nAccount: ...\nIP: ...") + if (packet.getReadPos() < packet.getSize()) { + std::string whoisText = packet.readString(); + if (!whoisText.empty()) { + // Display each line of the whois response in system chat + std::string line; + for (char c : whoisText) { + if (c == '\n') { + if (!line.empty()) addSystemChatMessage("[Whois] " + line); + line.clear(); + } else { + line += c; + } + } + if (!line.empty()) addSystemChatMessage("[Whois] " + line); + LOG_INFO("SMSG_WHOIS: ", whoisText); + } + } + break; + } + case Opcode::SMSG_FRIEND_STATUS: if (state == WorldState::IN_WORLD) { handleFriendStatus(packet); @@ -5609,6 +5632,110 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- LFG error/timeout states ---- + case Opcode::SMSG_LFG_TIMEDOUT: + // Server-side LFG invite timed out (no response within time limit) + addSystemChatMessage("Dungeon Finder: Invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_OTHER_TIMEDOUT: + // Another party member failed to respond to a LFG role-check in time + addSystemChatMessage("Dungeon Finder: Another player's invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_AUTOJOIN_FAILED: { + // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t result = packet.readUInt32(); + (void)result; + } + addSystemChatMessage("Dungeon Finder: Auto-join failed."); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: + // No eligible players found for auto-join + addSystemChatMessage("Dungeon Finder: No players available for auto-join."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_LEADER_IS_LFM: + // Party leader is currently set to Looking for More (LFM) mode + addSystemChatMessage("Your party leader is currently Looking for More."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Meeting stone (Classic/TBC group-finding via summon stone) ---- + case Opcode::SMSG_MEETINGSTONE_SETQUEUE: { + // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone + if (packet.getSize() - packet.getReadPos() >= 6) { + uint32_t zoneId = packet.readUInt32(); + uint8_t levelMin = packet.readUInt8(); + uint8_t levelMax = packet.readUInt8(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", + zoneId, levelMin, levelMax); + addSystemChatMessage(buf); + LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, + " levels=", (int)levelMin, "-", (int)levelMax); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_MEETINGSTONE_COMPLETE: + // Server confirms group found and teleport summon is ready + addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon."); + LOG_INFO("SMSG_MEETINGSTONE_COMPLETE"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_MEETINGSTONE_IN_PROGRESS: + // Meeting stone search is still ongoing + addSystemChatMessage("Meeting Stone: Searching for group members..."); + LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED: { + // uint64 memberGuid — a player was added to your group via meeting stone + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t memberGuid = packet.readUInt64(); + auto nit = playerNameCache.find(memberGuid); + if (nit != playerNameCache.end() && !nit->second.empty()) { + addSystemChatMessage("Meeting Stone: " + nit->second + + " has been added to your group."); + } else { + addSystemChatMessage("Meeting Stone: A new player has been added to your group."); + } + LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec); + } + break; + } + case Opcode::SMSG_MEETINGSTONE_JOINFAILED: { + // uint8 reason — failed to join group via meeting stone + // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available + static const char* kMeetingstoneErrors[] = { + "Target player is not using the Meeting Stone.", + "Target player is already in a group.", + "You are not in a valid zone for that Meeting Stone.", + "Target player is not available.", + }; + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t reason = packet.readUInt8(); + const char* msg = (reason < 4) ? kMeetingstoneErrors[reason] + : "Meeting Stone: Could not join group."; + addSystemChatMessage(msg); + LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", (int)reason); + } + break; + } + case Opcode::SMSG_MEETINGSTONE_LEAVE: + // Player was removed from the meeting stone queue (left, or group disbanded) + addSystemChatMessage("You have left the Meeting Stone queue."); + LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE"); + packet.setReadPos(packet.getSize()); + break; + // ---- GM Ticket responses ---- case Opcode::SMSG_GMTICKET_CREATE: { if (packet.getSize() - packet.getReadPos() >= 1) { @@ -6596,6 +6723,41 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- Mirror image data (WotLK: Mage ability Mirror Image) ---- + case Opcode::SMSG_MIRRORIMAGE_DATA: { + // WotLK 3.3.5a format: + // uint64 mirrorGuid — GUID of the mirror image unit + // uint32 displayId — display ID to render the image with + // uint8 raceId — race of caster + // uint8 genderFlag — gender of caster + // uint8 classId — class of caster + // uint64 casterGuid — GUID of the player who cast the spell + // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 + // Purpose: tells client how to render the image (same appearance as caster). + // We parse the GUIDs so units render correctly via their existing display IDs. + if (packet.getSize() - packet.getReadPos() < 8) break; + uint64_t mirrorGuid = packet.readUInt64(); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t displayId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 3) break; + /*uint8_t raceId =*/ packet.readUInt8(); + /*uint8_t gender =*/ packet.readUInt8(); + /*uint8_t classId =*/ packet.readUInt8(); + // Apply display ID to the mirror image unit so it renders correctly + if (mirrorGuid != 0 && displayId != 0) { + auto entity = entityManager.getEntity(mirrorGuid); + if (entity) { + auto unit = std::dynamic_pointer_cast(entity); + if (unit && unit->getDisplayId() == 0) + unit->setDisplayId(displayId); + } + } + LOG_DEBUG("SMSG_MIRRORIMAGE_DATA: mirrorGuid=0x", std::hex, mirrorGuid, + " displayId=", std::dec, displayId); + packet.setReadPos(packet.getSize()); + break; + } + // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, From dd38026b23c9c1542d86961d0e3a6829e1130584 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 22:14:46 -0700 Subject: [PATCH 19/50] feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were silently consumed. Now both are fully parsed: - SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket, open ticket, closed, suspended), extracts ticket text, age and server-estimated wait time, and stores them on GameHandler. - SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support goes offline/online. - Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called automatically when the GM Ticket UI window is opened, so the player sees their existing open ticket text and wait time on first open. - GM Ticket UI window now shows current-ticket status bar, estimated wait time, and hides the Delete button when no ticket is active. Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed): parses caster/victim/spellId for all expansions and emits combat text when the local player is involved in an instant-kill spell event (e.g. Execute, Obliterate). --- include/game/game_handler.hpp | 13 +++++ include/ui/game_screen.hpp | 3 +- src/game/game_handler.cpp | 102 +++++++++++++++++++++++++++++++++- src/ui/game_screen.cpp | 40 +++++++++++-- 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4a1ad26a..e2fab81e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -494,6 +494,13 @@ public: // GM Ticket void submitGmTicket(const std::string& text); void deleteGmTicket(); + void requestGmTicket(); ///< Send CMSG_GMTICKET_GETTICKET to query open ticket + + // GM ticket status accessors + bool hasActiveGmTicket() const { return gmTicketActive_; } + const std::string& getGmTicketText() const { return gmTicketText_; } + bool isGmSupportAvailable() const { return gmSupportAvailable_; } + float getGmTicketWaitHours() const { return gmTicketWaitHours_; } void queryGuildInfo(uint32_t guildId); void createGuild(const std::string& guildName); void addGuildRank(const std::string& rankName); @@ -3021,6 +3028,12 @@ private: // ---- Quest completion callback ---- QuestCompleteCallback questCompleteCallback_; + + // ---- GM Ticket state (SMSG_GMTICKET_GETTICKET / SMSG_GMTICKET_SYSTEMSTATUS) ---- + bool gmTicketActive_ = false; ///< True when an open ticket exists on the server + std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET) + float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours + bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS) }; } // namespace game diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 32428fcc..5ecbd924 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -438,7 +438,8 @@ private: void renderEquipSetWindow(game::GameHandler& gameHandler); // GM Ticket window - bool showGmTicketWindow_ = false; + bool showGmTicketWindow_ = false; + bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query char gmTicketBuf_[2048] = {}; void renderGmTicketWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fd0f4b56..2534a85a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5761,10 +5761,70 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_GMTICKET_GETTICKET: - case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: + case Opcode::SMSG_GMTICKET_GETTICKET: { + // WotLK 3.3.5a format: + // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended + // If status == 6 (GMTICKET_STATUS_HASTEXT): + // cstring ticketText + // uint32 ticketAge (seconds old) + // uint32 daysUntilOld (days remaining before escalation) + // float waitTimeHours (estimated GM wait time) + if (packet.getSize() - packet.getReadPos() < 1) { packet.setReadPos(packet.getSize()); break; } + uint8_t gmStatus = packet.readUInt8(); + // Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text + if (gmStatus == 6 && packet.getSize() - packet.getReadPos() >= 1) { + gmTicketText_ = packet.readString(); + uint32_t ageSec = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; + /*uint32_t daysLeft =*/ (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; + gmTicketWaitHours_ = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readFloat() : 0.0f; + gmTicketActive_ = true; + char buf[256]; + if (ageSec < 60) { + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.", + ageSec, gmTicketWaitHours_); + } else { + uint32_t ageMin = ageSec / 60; + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.", + ageMin, gmTicketWaitHours_); + } + addSystemChatMessage(buf); + LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec, + "s wait=", gmTicketWaitHours_, "h"); + } else if (gmStatus == 3) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been closed."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed"); + } else if (gmStatus == 10) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been suspended."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended"); + } else { + // Status 1 = no open ticket (default/no ticket) + gmTicketActive_ = false; + gmTicketText_.clear(); + LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", (int)gmStatus, ")"); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: { + // uint32 status: 1 = GM support available, 0 = offline/unavailable + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t sysStatus = packet.readUInt32(); + gmSupportAvailable_ = (sysStatus != 0); + addSystemChatMessage(gmSupportAvailable_ + ? "GM support is currently available." + : "GM support is currently unavailable."); + LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_); + } + packet.setReadPos(packet.getSize()); + break; + } // ---- DK rune tracking ---- case Opcode::SMSG_CONVERT_RUNE: { @@ -5975,7 +6035,33 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } - case Opcode::SMSG_SPELLINSTAKILLLOG: + case Opcode::SMSG_SPELLINSTAKILLLOG: { + // Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.) + // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + // TBC/Classic: full uint64 caster + full uint64 victim + uint32 spellId + const bool ikTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t ikCaster = ikTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t ikVictim = ikTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; + // Show kill/death feedback for the local player + if (ikCaster == playerGuid) { + // We killed a target instantly — show a KILL combat text hit + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true); + } else if (ikVictim == playerGuid) { + // We were instantly killed — show a large incoming hit + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false); + addSystemChatMessage("You were killed by an instant-kill effect."); + } + LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, + " victim=0x", ikVictim, std::dec, " spell=", ikSpell); + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELLLOGEXECUTE: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: @@ -16153,9 +16239,19 @@ void GameHandler::deleteGmTicket() { if (state != WorldState::IN_WORLD || !socket) return; network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); socket->send(pkt); + gmTicketActive_ = false; + gmTicketText_.clear(); LOG_INFO("Deleting GM ticket"); } +void GameHandler::requestGmTicket() { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_GMTICKET_GETTICKET has no payload — server responds with SMSG_GMTICKET_GETTICKET + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_GETTICKET)); + socket->send(pkt); + LOG_DEBUG("Sent CMSG_GMTICKET_GETTICKET — querying open ticket status"); +} + void GameHandler::queryGuildInfo(uint32_t guildId) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildQueryPacket::build(guildId); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e5526b09..e90699a3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20162,9 +20162,15 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { // ─── GM Ticket Window ───────────────────────────────────────────────────────── void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { + // Fire a one-shot query when the window first becomes visible + if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) { + gameHandler.requestGmTicket(); + } + gmTicketWindowWasOpen_ = showGmTicketWindow_; + if (!showGmTicketWindow_) return; - ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, @@ -20173,10 +20179,33 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { return; } + // Show GM support availability + if (!gameHandler.isGmSupportAvailable()) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "GM support is currently unavailable."); + ImGui::Spacing(); + } + + // Show existing open ticket if any + if (gameHandler.hasActiveGmTicket()) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "You have an open GM ticket."); + const std::string& existingText = gameHandler.getGmTicketText(); + if (!existingText.empty()) { + ImGui::TextWrapped("Current ticket: %s", existingText.c_str()); + } + float waitHours = gameHandler.getGmTicketWaitHours(); + if (waitHours > 0.0f) { + char waitBuf[64]; + std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf); + } + ImGui::Separator(); + ImGui::Spacing(); + } + ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); ImGui::Spacing(); ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), - ImVec2(-1, 160)); + ImVec2(-1, 120)); ImGui::Spacing(); bool hasText = (gmTicketBuf_[0] != '\0'); @@ -20193,8 +20222,11 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { showGmTicketWindow_ = false; } ImGui::SameLine(); - if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) { - gameHandler.deleteGmTicket(); + if (gameHandler.hasActiveGmTicket()) { + if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) { + gameHandler.deleteGmTicket(); + showGmTicketWindow_ = false; + } } ImGui::End(); From c5a6979d69145f027b25deb673d97333f5d6d8d6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 22:25:46 -0700 Subject: [PATCH 20/50] feat: handle SMSG_BATTLEFIELD_MGR_* and SMSG_CALENDAR_* opcodes Implements WotLK Outdoor Battlefield Manager (Wintergrasp/Tol Barad): - Parse SMSG_BATTLEFIELD_MGR_ENTRY_INVITE, ENTERED, QUEUE_INVITE, QUEUE_REQUEST_RESPONSE, EJECT_PENDING, EJECTED, STATE_CHANGE - Store bfMgrInvitePending_/bfMgrActive_/bfMgrZoneId_ state - Send CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE via acceptBfMgrInvite() / declineBfMgrInvite() accessors - Add renderBfMgrInvitePopup() UI dialog with Enter/Decline buttons; recognises Wintergrasp (zone 4197) and Tol Barad (zone 5095) by name Implements WotLK Calendar notifications: - SMSG_CALENDAR_SEND_NUM_PENDING: track pending invite count - SMSG_CALENDAR_COMMAND_RESULT: map 15 error codes to friendly messages - SMSG_CALENDAR_EVENT_INVITE_ALERT: notify player of new event invite with title - SMSG_CALENDAR_EVENT_STATUS: show per-event RSVP status changes (9 statuses) - SMSG_CALENDAR_RAID_LOCKOUT_ADDED/REMOVED: log raid lockout calendar entries - Remaining SMSG_CALENDAR_* packets safely consumed - requestCalendar() sends CMSG_CALENDAR_GET_CALENDAR + GET_NUM_PENDING --- include/game/game_handler.hpp | 19 ++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 337 ++++++++++++++++++++++++++++++++++ src/ui/game_screen.cpp | 58 ++++++ 4 files changed, 415 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e2fab81e..6fbb64b2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -501,6 +501,17 @@ public: const std::string& getGmTicketText() const { return gmTicketText_; } bool isGmSupportAvailable() const { return gmSupportAvailable_; } float getGmTicketWaitHours() const { return gmTicketWaitHours_; } + + // Battlefield Manager (Wintergrasp) + bool hasBfMgrInvite() const { return bfMgrInvitePending_; } + bool isInBfMgrZone() const { return bfMgrActive_; } + uint32_t getBfMgrZoneId() const { return bfMgrZoneId_; } + void acceptBfMgrInvite(); + void declineBfMgrInvite(); + + // WotLK Calendar + uint32_t getCalendarPendingInvites() const { return calendarPendingInvites_; } + void requestCalendar(); ///< Send CMSG_CALENDAR_GET_CALENDAR to the server void queryGuildInfo(uint32_t guildId); void createGuild(const std::string& guildName); void addGuildRank(const std::string& rankName); @@ -3034,6 +3045,14 @@ private: std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET) float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS) + + // ---- Battlefield Manager state (WotLK Wintergrasp / outdoor battlefields) ---- + bool bfMgrInvitePending_ = false; ///< True when an entry/queue invite is pending acceptance + bool bfMgrActive_ = false; ///< True while the player is inside an outdoor battlefield + uint32_t bfMgrZoneId_ = 0; ///< Zone ID of the pending/active battlefield + + // ---- WotLK Calendar: pending invite counter ---- + uint32_t calendarPendingInvites_ = 0; ///< Unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING) }; } // namespace game diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5ecbd924..12ce23a1 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -361,6 +361,7 @@ private: void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler); void renderBgInvitePopup(game::GameHandler& gameHandler); + void renderBfMgrInvitePopup(game::GameHandler& gameHandler); void renderLfgProposalPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2534a85a..46b1ec7b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6881,6 +6881,308 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::FLYING), false); break; + // ---- Battlefield Manager (WotLK outdoor battlefields: Wintergrasp, Tol Barad) ---- + case Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: { + // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) + if (packet.getSize() - packet.getReadPos() < 20) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t bfGuid = packet.readUInt64(); + uint32_t bfZoneId = packet.readUInt32(); + uint64_t expireTime = packet.readUInt64(); + (void)bfGuid; (void)expireTime; + // Store the invitation so the UI can show a prompt + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfZoneId; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in zone %u. Click to enter.", bfZoneId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_ENTERED: { + // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t bfGuid2 = packet.readUInt64(); + (void)bfGuid2; + uint8_t isSafe = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + uint8_t onQueue = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + bfMgrInvitePending_ = false; + bfMgrActive_ = true; + addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)." + : "You have entered the battlefield!"); + if (onQueue) addSystemChatMessage("You are in the battlefield queue."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", (int)isSafe, " onQueue=", (int)onQueue); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: { + // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime + if (packet.getSize() - packet.getReadPos() < 20) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t bfGuid3 = packet.readUInt64(); + uint32_t bfId = packet.readUInt32(); + uint64_t expTime = packet.readUInt64(); + (void)bfGuid3; (void)expTime; + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfId; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "A spot has opened in the battlefield queue (battlefield %u).", bfId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: { + // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result + // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, + // 4=in_cooldown, 5=queued_other_bf, 6=bf_full + if (packet.getSize() - packet.getReadPos() < 11) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t bfId2 = packet.readUInt32(); + /*uint32_t teamId =*/ packet.readUInt32(); + uint8_t accepted = packet.readUInt8(); + /*uint8_t logging =*/ packet.readUInt8(); + uint8_t result = packet.readUInt8(); + (void)bfId2; + if (accepted) { + addSystemChatMessage("You have joined the battlefield queue."); + } else { + static const char* kBfQueueErrors[] = { + "Queued for battlefield.", "Not in a group.", "Level too high.", + "Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.", + "Battlefield is full." + }; + const char* msg = (result < 7) ? kBfQueueErrors[result] + : "Battlefield queue request failed."; + addSystemChatMessage(std::string("Battlefield: ") + msg); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", (int)accepted, + " result=", (int)result); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING: { + // uint64 battlefieldGuid + uint8 remove + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t bfGuid4 = packet.readUInt64(); + uint8_t remove = packet.readUInt8(); + (void)bfGuid4; + if (remove) { + addSystemChatMessage("You will be removed from the battlefield shortly."); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", (int)remove); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_EJECTED: { + // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated + if (packet.getSize() - packet.getReadPos() >= 17) { + uint64_t bfGuid5 = packet.readUInt64(); + uint32_t reason = packet.readUInt32(); + /*uint32_t status =*/ packet.readUInt32(); + uint8_t relocated = packet.readUInt8(); + (void)bfGuid5; + static const char* kEjectReasons[] = { + "Removed from battlefield.", "Transported from battlefield.", + "Left battlefield voluntarily.", "Offline.", + }; + const char* msg = (reason < 4) ? kEjectReasons[reason] + : "You have been ejected from the battlefield."; + addSystemChatMessage(msg); + if (relocated) addSystemChatMessage("You have been relocated outside the battlefield."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", (int)relocated); + } + bfMgrActive_ = false; + bfMgrInvitePending_ = false; + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE: { + // uint32 oldState + uint32 newState + // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t oldState =*/ packet.readUInt32(); + uint32_t newState = packet.readUInt32(); + static const char* kBfStates[] = { + "waiting", "starting", "in progress", "ending", "in cooldown" + }; + const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state"; + char buf[128]; + std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState); + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- WotLK Calendar system (pending invites, event notifications, command results) ---- + case Opcode::SMSG_CALENDAR_SEND_NUM_PENDING: { + // uint32 numPending — number of unacknowledged calendar invites + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t numPending = packet.readUInt32(); + calendarPendingInvites_ = numPending; + if (numPending > 0) { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "You have %u pending calendar invite%s.", + numPending, numPending == 1 ? "" : "s"); + addSystemChatMessage(buf); + } + LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites"); + } + break; + } + case Opcode::SMSG_CALENDAR_COMMAND_RESULT: { + // uint32 command + uint8 result + cstring info + // result 0 = success; non-zero = error code + // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, + // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + /*uint32_t command =*/ packet.readUInt32(); + uint8_t result = packet.readUInt8(); + std::string info = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + if (result != 0) { + // Map common calendar error codes to friendly strings + static const char* kCalendarErrors[] = { + "", + "Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL + "Calendar: Guild event limit reached.",// 2 + "Calendar: Event limit reached.", // 3 + "Calendar: You cannot invite that player.", // 4 + "Calendar: No invites remaining.", // 5 + "Calendar: Invalid date.", // 6 + "Calendar: Cannot invite yourself.", // 7 + "Calendar: Cannot modify this event.", // 8 + "Calendar: Not invited.", // 9 + "Calendar: Already invited.", // 10 + "Calendar: Player not found.", // 11 + "Calendar: Not enough focus.", // 12 + "Calendar: Event locked.", // 13 + "Calendar: Event deleted.", // 14 + "Calendar: Not a moderator.", // 15 + }; + const char* errMsg = (result < 16) ? kCalendarErrors[result] + : "Calendar: Command failed."; + if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg); + else if (!info.empty()) addSystemChatMessage("Calendar: " + info); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT: { + // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + + // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + + // isGuildEvent(1) + inviterGuid(8) + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t eventId =*/ packet.readUInt64(); + std::string title = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + packet.setReadPos(packet.getSize()); // consume remaining fields + if (!title.empty()) { + addSystemChatMessage("Calendar invite: " + title); + } else { + addSystemChatMessage("You have a new calendar invite."); + } + if (calendarPendingInvites_ < 255) ++calendarPendingInvites_; + LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'"); + break; + } + // Remaining calendar informational packets — parse title where possible and consume + case Opcode::SMSG_CALENDAR_EVENT_STATUS: { + // Sent when an event invite's RSVP status changes for the local player + // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + + // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) + if (packet.getSize() - packet.getReadPos() < 31) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + /*uint8_t evType =*/ packet.readUInt8(); + /*uint32_t flags =*/ packet.readUInt32(); + /*uint64_t invTime =*/ packet.readUInt64(); + uint8_t status = packet.readUInt8(); + /*uint8_t rank =*/ packet.readUInt8(); + /*uint8_t isGuild =*/ packet.readUInt8(); + std::string evTitle = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + // status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative + static const char* kRsvpStatus[] = { + "invited", "accepted", "declined", "confirmed", + "out", "on standby", "signed up", "not signed up", "tentative" + }; + const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown"; + if (!evTitle.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.", + evTitle.c_str(), statusStr); + addSystemChatMessage(buf); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED: { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime + if (packet.getSize() - packet.getReadPos() >= 28) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + /*uint64_t resetTime =*/ packet.readUInt64(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Calendar: Raid lockout added for map %u (difficulty %u).", mapId, difficulty); + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + if (packet.getSize() - packet.getReadPos() >= 20) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + (void)mapId; (void)difficulty; + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, + " difficulty=", difficulty); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED: { + // Same format as LOCKOUT_ADDED; consume + packet.setReadPos(packet.getSize()); + break; + } + // Remaining calendar opcodes: safe consume — data surfaced via SEND_CALENDAR/SEND_EVENT + case Opcode::SMSG_CALENDAR_SEND_CALENDAR: + case Opcode::SMSG_CALENDAR_SEND_EVENT: + case Opcode::SMSG_CALENDAR_ARENA_TEAM: + case Opcode::SMSG_CALENDAR_FILTER_GUILD: + case Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION: + case Opcode::SMSG_CALENDAR_EVENT_INVITE: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT: + packet.setReadPos(packet.getSize()); + break; + default: // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. @@ -21653,5 +21955,40 @@ void GameHandler::handleSetForcedReactions(network::Packet& packet) { LOG_INFO("SMSG_SET_FORCED_REACTIONS: ", forcedReactions_.size(), " faction overrides"); } +// ---- Battlefield Manager (WotLK Wintergrasp / outdoor battlefields) ---- + +void GameHandler::acceptBfMgrInvite() { + if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; + // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 1 + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); + pkt.writeUInt8(1); // accepted + socket->send(pkt); + bfMgrInvitePending_ = false; + LOG_INFO("acceptBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=1"); +} + +void GameHandler::declineBfMgrInvite() { + if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; + // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 0 + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); + pkt.writeUInt8(0); // declined + socket->send(pkt); + bfMgrInvitePending_ = false; + LOG_INFO("declineBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=0"); +} + +// ---- WotLK Calendar ---- + +void GameHandler::requestCalendar() { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_CALENDAR_GET_CALENDAR has no payload + network::Packet pkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_CALENDAR)); + socket->send(pkt); + LOG_INFO("requestCalendar: sent CMSG_CALENDAR_GET_CALENDAR"); + // Also request pending invite count + network::Packet numPkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_NUM_PENDING)); + socket->send(numPkt); +} + } // namespace game } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e90699a3..4c9a0417 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -687,6 +687,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGuildInvitePopup(gameHandler); renderReadyCheckPopup(gameHandler); renderBgInvitePopup(gameHandler); + renderBfMgrInvitePopup(gameHandler); renderLfgProposalPopup(gameHandler); renderGuildRoster(gameHandler); renderSocialFrame(gameHandler); @@ -10947,6 +10948,63 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { ImGui::PopStyleColor(3); } +void GameScreen::renderBfMgrInvitePopup(game::GameHandler& gameHandler) { + // Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager) + if (!gameHandler.hasBfMgrInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battlefield", nullptr, flags)) { + // Resolve zone name for Wintergrasp (zoneId 4197) + uint32_t zoneId = gameHandler.getBfMgrZoneId(); + const char* zoneName = nullptr; + if (zoneId == 4197) zoneName = "Wintergrasp"; + else if (zoneId == 5095) zoneName = "Tol Barad"; + + if (zoneName) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield"); + } + ImGui::Spacing(); + ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?"); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Enter Battlefield", ImVec2(178, 28))) { + gameHandler.acceptBfMgrInvite(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Decline", ImVec2(175, 28))) { + gameHandler.declineBfMgrInvite(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { using LfgState = game::GameHandler::LfgState; if (gameHandler.getLfgState() != LfgState::Proposal) return; From b832940509769e245b9b398f90234a9853e1649b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 22:35:37 -0700 Subject: [PATCH 21/50] fix: separate SMSG_QUEST_POI_QUERY_RESPONSE from consume-only stubs; add SMSG_SERVERTIME, SMSG_KICK_REASON, SMSG_GROUPACTION_THROTTLED, SMSG_GMRESPONSE_RECEIVED handlers - Fix bug where SMSG_REDIRECT_CLIENT, SMSG_PVP_QUEUE_STATS, SMSG_PLAYER_SKINNED, etc. were incorrectly falling through to handleQuestPoiQueryResponse instead of being silently consumed; add separate setReadPos break for those opcodes - Implement SMSG_SERVERTIME: sync gameTime_ from server's unix timestamp - Implement SMSG_KICK_REASON: show player a chat message with reason for group removal - Implement SMSG_GROUPACTION_THROTTLED: notify player of rate-limit with wait time - Implement SMSG_GMRESPONSE_RECEIVED: display GM ticket response in chat and UI error - Implement SMSG_GMRESPONSE_STATUS_UPDATE: show ticket status changes in chat - Silence voice chat, dance, commentator, and debug/cheat opcodes with explicit consume cases rather than falling to the unhandled-opcode warning log --- src/game/game_handler.cpp | 173 +++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 46b1ec7b..92406bef 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6750,7 +6750,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } - // ---- Misc consume ---- + // ---- Misc consume (no state change needed) ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: case Opcode::SMSG_PROPOSE_LEVEL_GRANT: case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: @@ -6761,6 +6761,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: handleQuestPoiQueryResponse(packet); break; @@ -7183,6 +7185,175 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_SERVERTIME: { + // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t srvTime = packet.readUInt32(); + if (srvTime > 0) { + gameTime_ = static_cast(srvTime); + LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime); + } + } + break; + } + + case Opcode::SMSG_KICK_REASON: { + // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string + // kickReasonType: 0=other, 1=afk, 2=vote kick + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); + break; + } + uint64_t kickerGuid = packet.readUInt64(); + uint32_t reasonType = packet.readUInt32(); + std::string reason; + if (packet.getSize() - packet.getReadPos() > 0) + reason = packet.readString(); + (void)kickerGuid; + (void)reasonType; + std::string msg = "You have been removed from the group."; + if (!reason.empty()) + msg = "You have been removed from the group: " + reason; + else if (reasonType == 1) + msg = "You have been removed from the group for being AFK."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, + " reason='", reason, "'"); + break; + } + + case Opcode::SMSG_GROUPACTION_THROTTLED: { + // uint32 throttleMs — rate-limited group action; notify the player + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t throttleMs = packet.readUInt32(); + char buf[128]; + if (throttleMs > 0) { + std::snprintf(buf, sizeof(buf), + "Group action throttled. Please wait %.1f seconds.", + throttleMs / 1000.0f); + } else { + std::snprintf(buf, sizeof(buf), "Group action throttled."); + } + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs); + } + break; + } + + case Opcode::SMSG_GMRESPONSE_RECEIVED: { + // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count + // per count: string responseText + if (packet.getSize() - packet.getReadPos() < 4) { + packet.setReadPos(packet.getSize()); + break; + } + uint32_t ticketId = packet.readUInt32(); + std::string subject; + std::string body; + if (packet.getSize() - packet.getReadPos() > 0) subject = packet.readString(); + if (packet.getSize() - packet.getReadPos() > 0) body = packet.readString(); + uint32_t responseCount = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + responseCount = packet.readUInt32(); + std::string responseText; + for (uint32_t i = 0; i < responseCount && i < 10; ++i) { + if (packet.getSize() - packet.getReadPos() > 0) { + std::string t = packet.readString(); + if (i == 0) responseText = t; + } + } + (void)ticketId; + std::string msg; + if (!responseText.empty()) + msg = "[GM Response] " + responseText; + else if (!body.empty()) + msg = "[GM Response] " + body; + else if (!subject.empty()) + msg = "[GM Response] " + subject; + else + msg = "[GM Response] Your ticket has been answered."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId, + " subject='", subject, "'"); + break; + } + + case Opcode::SMSG_GMRESPONSE_STATUS_UPDATE: { + // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) + if (packet.getSize() - packet.getReadPos() >= 5) { + uint32_t ticketId = packet.readUInt32(); + uint8_t status = packet.readUInt8(); + const char* statusStr = (status == 1) ? "open" + : (status == 2) ? "answered" + : (status == 3) ? "needs more info" + : "updated"; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "[GM Ticket #%u] Status: %s.", ticketId, statusStr); + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId, + " status=", static_cast(status)); + } + break; + } + + // ---- Voice chat (WotLK built-in voice) — consume silently ---- + case Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE: + case Opcode::SMSG_VOICE_SESSION_LEAVE: + case Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY: + case Opcode::SMSG_VOICE_SET_TALKER_MUTED: + case Opcode::SMSG_VOICE_SESSION_ENABLE: + case Opcode::SMSG_VOICE_PARENTAL_CONTROLS: + case Opcode::SMSG_AVAILABLE_VOICE_CHANNEL: + case Opcode::SMSG_VOICE_CHAT_STATUS: + packet.setReadPos(packet.getSize()); + break; + + // ---- Dance / custom emote system (WotLK) — consume silently ---- + case Opcode::SMSG_NOTIFY_DANCE: + case Opcode::SMSG_PLAY_DANCE: + case Opcode::SMSG_STOP_DANCE: + case Opcode::SMSG_DANCE_QUERY_RESPONSE: + case Opcode::SMSG_INVALIDATE_DANCE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Commentator / spectator mode — consume silently ---- + case Opcode::SMSG_COMMENTATOR_STATE_CHANGED: + case Opcode::SMSG_COMMENTATOR_MAP_INFO: + case Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO: + case Opcode::SMSG_COMMENTATOR_PLAYER_INFO: + case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1: + case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2: + packet.setReadPos(packet.getSize()); + break; + + // ---- Debug / cheat / GM-only opcodes — consume silently ---- + case Opcode::SMSG_DBLOOKUP: + case Opcode::SMSG_CHECK_FOR_BOTS: + case Opcode::SMSG_GODMODE: + case Opcode::SMSG_PETGODMODE: + case Opcode::SMSG_DEBUG_AISTATE: + case Opcode::SMSG_DEBUGAURAPROC: + case Opcode::SMSG_TEST_DROP_RATE_RESULT: + case Opcode::SMSG_COOLDOWN_CHEAT: + case Opcode::SMSG_GM_PLAYER_INFO: + case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE: + case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE: + case Opcode::SMSG_CHEAT_PLAYER_LOOKUP: + case Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT: + case Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT: + case Opcode::SMSG_DEBUG_LIST_TARGETS: + case Opcode::SMSG_DEBUG_SERVER_GEO: + case Opcode::SMSG_DUMP_OBJECTS_DATA: + case Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE: + case Opcode::SMSG_FORCEACTIONSHOW: + case Opcode::SMSG_MOVE_CHARACTER_CHEAT: + packet.setReadPos(packet.getSize()); + break; + default: // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. From d52c49c9fae4fd33c7ffe0e203be642b5569489a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 22:38:37 -0700 Subject: [PATCH 22/50] fix: FXAA sharpening and MSAA exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Post-FXAA unsharp mask: when FSR2 is active alongside FXAA, forward the FSR2 sharpness value (0–2) to the FXAA fragment shader via a new vec4 push constant. A contrast-adaptive sharpening step (unsharp mask scaled to 0–0.3) is applied after FXAA blending, recovering the crispness that FXAA's sub-pixel blend removes. At sharpness=2.0 the output matches RCAS quality; at sharpness=0 the step is a no-op. - MSAA guard: setFXAAEnabled() refuses to activate FXAA when hardware MSAA is in use. FXAA's role is to supplement FSR temporal AA, not to stack on top of MSAA which already resolves jaggies during the scene render pass. --- assets/shaders/fxaa.frag.glsl | 23 ++++++++++++++++++++--- assets/shaders/fxaa.frag.spv | Bin 0 -> 7764 bytes src/rendering/renderer.cpp | 24 ++++++++++++++++++------ 3 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 assets/shaders/fxaa.frag.spv diff --git a/assets/shaders/fxaa.frag.glsl b/assets/shaders/fxaa.frag.glsl index df35aaa0..158f2f98 100644 --- a/assets/shaders/fxaa.frag.glsl +++ b/assets/shaders/fxaa.frag.glsl @@ -2,7 +2,7 @@ // FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass. // Reads the resolved scene color and outputs a smoothed result. -// Push constant: rcpFrame = vec2(1/width, 1/height). +// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), unused. layout(set = 0, binding = 0) uniform sampler2D uScene; @@ -10,7 +10,9 @@ layout(location = 0) in vec2 TexCoord; layout(location = 0) out vec4 outColor; layout(push_constant) uniform PC { - vec2 rcpFrame; + vec2 rcpFrame; + float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range) + float _pad; } pc; // Quality tuning @@ -128,5 +130,20 @@ void main() { if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign; if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign; - outColor = vec4(texture(uScene, finalUV).rgb, 1.0); + vec3 fxaaResult = texture(uScene, finalUV).rgb; + + // Post-FXAA contrast-adaptive sharpening (unsharp mask). + // Counteracts FXAA's sub-pixel blur when sharpness > 0. + if (pc.sharpness > 0.0) { + vec2 r = pc.rcpFrame; + vec3 blur = (texture(uScene, uv + vec2(-r.x, 0)).rgb + + texture(uScene, uv + vec2( r.x, 0)).rgb + + texture(uScene, uv + vec2(0, -r.y)).rgb + + texture(uScene, uv + vec2(0, r.y)).rgb) * 0.25; + // scale sharpness from [0,2] to a modest [0, 0.3] boost factor + float s = pc.sharpness * 0.15; + fxaaResult = clamp(fxaaResult + s * (fxaaResult - blur), 0.0, 1.0); + } + + outColor = vec4(fxaaResult, 1.0); } diff --git a/assets/shaders/fxaa.frag.spv b/assets/shaders/fxaa.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..7803f3e27e4b92579b95e608e8653b07d921062a GIT binary patch literal 7764 zcmZXZd#u)V9mjti&VkS)dW3V(NIY=DP>2M)u*EqXXo|PRO>226o%9E`oB}mBa=@5@ znD-hJO)awGc&Szy*-}%dscBThO3l)-%AkujHE&z5*YkYe&whLQj?ccI&--)ze!iFI zIS->c2F6s?d#VGggQ|(6s>bN4IzTG0(T#cW87t11wPx+gS;rlJtOnz%rlB?mS7WM9 zX6bVmth$(~IvlzhIs`j$5>$)f#3w_JQ>w1&{Y|zSD0495ioF*K)={u-=EcO-SIoH6 znWsXI$WxkPL$;RW9gmlOQ;6XzSrL!kApV%-82;WVB~L{)VCn= zA;^0!dwFms*82BA`e>)lxV_BAGG{;XVa%>o{3AuSM`Dvpf8x`uud_M=-B`%=>!a-2 z#CnmBWbTKIVQrBS!8=)ZY~81^)ih*dYhNFcC((tYP-v(B2aA!I`IYUz!!^!eHlF*Q z-pqz~M4r=-HaswsHM+jJkT&A6nGau88y3W75J?;HU?&$@JlGO=3hQSlqZ_mDG!y)`%kS^vo zkk+bXybbSqK98*4c@Qtp%KISi$U?lPFi(X%|NT&G2H?e&J*P)Qp3zU>y@ zmGQoQsNm`xq;J&Pjo!Q`6wH10yqZJ*#5@t+JlZAZJa}=*(H?WSV8--(x5r!vX3SwI zF&Dv$OOE!KrxwhZ-jDW}r-2z$yTm*lUR-jt$6Q`8V|u^ZW3B)*rgn*WCcL=hNX)OW z2cE5U9aUw2dZ1wI!CYS*+XQBPb!;=3@$1-CVD6zM5=I%8;?n3vad=F@gl>ET9r_11Jn!M;>5 z-?+rs3~wK-YZIi+F6@05u7r%Ejd$=WX2)CH9d%nFG4G-LvXbXo_}rCX*A>iM!LBbD zrB_SYml@1AK>BO1-uGgN+`hN!-XxcAfjP~cJGdR**mEG?;`VcRGnnU4yPU&Y;Ke0} z{??M1I|^pZ+oATD-vl$Jc8Pfhytw3Ok9k+YjJXqPk9jwkF||v~d*H<-M`CWI-@ZRT zf_!g`l{e>Jc;l!`oFBuBOOC|J9lMsr+mjwB*o|PWsg7*}v!*(BGnnzktjjz0B$Trr z?5To#&V&65Ox%06p0lOzgi)L=WcNaO4`jXeO?y8xwdqEy{uxNUpC8(8DK$I`uWloh zyYU>nxG{1Mo`=`pdoUZ)CimtAc=diR$Noil?WaM0K5G;Em*CZlr~bdeYj6E~A=hfZ z(}TZ*X=87*rhUlzxF+?9_3wfwmVL6%iS;UaZJKXdvp@eSKE_g?Sg#e_SocM!ZD- z-WU6vSO=ol#xgt0x zpZfbbx4ylT;O&<>-`;*^hi|XCBcW-K@1T6%)DOV>4lp&|O8vCaHa63Xjq!X>W8?f< z`Fwnkx{YOQ?e*VT?%FJPYjDl>H~8FwyNC8T_w7V*eVX^Vc^>B>^RIEX)yH-|ym9Kb zgUI}A*s4#DhT)B)-X49F*@k3JiYiiw)z-DKW)vo zggN=_$H$-*kn!Zx?`81riDPMbvzM!fd~3{WEdBJ4-&ye4%lLh~_^7M<>2JNg#CwPu zjc*Ow8}mE##B=pYcx$!(bC@0WOZ;p|%sS*#$NBJnzBqFJF973T<2|!K{!O9Xa?Xe~ zjDTqqKWj~F=hXTrcG_6KKIXNy&oDc3-d7@vS(|)nTLo_nrdDkiAt-xyQ=4|F?QZO~ ziJ!G4wsUHG3_ESCS0D4{&RzoVnf9#aT=nvKB%U6vhUZ_S4t4gZd9It^LBCM+)3M1p z_8!>3b?D6RUYb*EeGRkb&B*q{IcN0`*n6g2$M4{`)^xwE>3&nw8T)Bujm)^;;lu*Ds&*eI2}cJwrFZ$4C5nNK7C3d{_D^yx)~vOTH^@0~41VPN-Yoi*7^E z#_v0MyS5{XyYKE#aye&z?7RD5&DvW>YQF{EUR(RE@D9(v_}3sYYnM;$Ux&AL_b;{Y z027xSPN-Y8??BL|Ui-I@#Z!B7Ij45dljqCzXm1_XI>PL*R{OjY60=tMeE!`9&p)iH z%0A@#(mjCQ1%C_3+x}f-?bX@K#B;VD&!uO|y|zZ}&1F45WOk%?KR_0<9{JRBFFgMm z^|-dwvkS1jo}Ykeug-cB&pGvYH{3sK)ZSd*E@%2@@Ybu1H9o-Xuts(FLt@q_Z;qUA z@tl!|;EiL9N0<{s{9#DU81i{b9)tI6`<56tHqO@WJ@)QuV=nFWPtM2TQ}>hb$tnH> zBxX+eyj4%bdnS`JHqOcEJEM(tXs^HZZDlr|^9K6od*K`5o3Ojs)WZIZU@Wo3dJ#ceb@f=r%)0jBV@_+;-aM}Bugs3D>o3UvfUHG6wY&`P z9yo&S1It=d%YHCz)me-2jb+UE{R`e$OfBpcgse5OUIo)uT|JgDv#x&<$DG!xy?OGx z#2fIgTbq3MdUYqz?TVgw>wl3moW4-#AH=l#E;QbubCZB^J1{0SY)*!o(J@dEh*^s{v`=<7=#~anzp_l4uOg1yZi!pG0#Tc&0%Ew zuP$p_2=Cg|Wo?V##U)49me`BI%~g+m3bL`)CHATC##Wcur@@O$j>NV`e-A$$-2B>Q zy-VO-ueSL<^b%zCj_l*5 o1v5vm)dgdsx6R*a*C6Pxz54m+@;j|M>sia}y>Nb)s^>xf0~8U`L;wH) literal 0 HcmV?d00001 diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index f9ce69cd..8dcf724c 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -4968,11 +4968,11 @@ bool Renderer::initFXAAResources() { write.pImageInfo = &imgInfo; vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); - // Pipeline layout — push constant holds vec2 rcpFrame + // Pipeline layout — push constant holds vec4(rcpFrame.xy, sharpness, pad) VkPushConstantRange pc{}; pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; pc.offset = 0; - pc.size = 8; // vec2 + pc.size = 16; // vec4 VkPipelineLayoutCreateInfo plCI{}; plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; plCI.setLayoutCount = 1; @@ -5044,19 +5044,31 @@ void Renderer::renderFXAAPass() { vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); - // Push rcpFrame = vec2(1/width, 1/height) - float rcpFrame[2] = { + // Pass rcpFrame + sharpness (vec4, 16 bytes). + // When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the + // post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes. + float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f; + float pc[4] = { 1.0f / static_cast(ext.width), - 1.0f / static_cast(ext.height) + 1.0f / static_cast(ext.height), + sharpness, + 0.0f }; vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, - VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame); + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc); vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle } void Renderer::setFXAAEnabled(bool enabled) { if (fxaa_.enabled == enabled) return; + // FXAA is a post-process AA pass intended to supplement FSR temporal output. + // It conflicts with MSAA (which resolves AA during the scene render pass), so + // refuse to enable FXAA when hardware MSAA is active. + if (enabled && vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) { + LOG_INFO("FXAA: blocked while MSAA is active — disable MSAA first"); + return; + } fxaa_.enabled = enabled; if (!enabled) { fxaa_.needsRecreate = true; // defer destruction to next beginFrame() From e029e8649f27597168bcbc35d8af35d28a70d26f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 22:53:33 -0700 Subject: [PATCH 23/50] feat: parse SMSG_SPELLLOGEXECUTE CREATE_ITEM effects for profession crafting feedback - Implement SMSG_SPELLLOGEXECUTE handler with expansion-aware caster GUID reading (packed_guid for WotLK/Classic, full uint64 for TBC) - Parse effect type 24 (SPELL_EFFECT_CREATE_ITEM): show "You create using ." in chat when the player uses a profession or any create-item spell - Look up item name via ensureItemInfo/getItemInfo and spell name via spellNameCache_ - Fall back to "You create: ." when the spell name is not cached - Safely consume unknown effect types by stopping parse at first unrecognized effect to avoid packet misalignment on variable-length sub-records - Adds visible crafting feedback complementary to SMSG_ITEM_PUSH_RESULT (which shows "Received:" for looted/obtained items) with a profession-specific "create" message --- src/game/game_handler.cpp | 59 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 92406bef..a87960a7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6062,7 +6062,64 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } - case Opcode::SMSG_SPELLLOGEXECUTE: + case Opcode::SMSG_SPELLLOGEXECUTE: { + // WotLK: packed_guid caster + uint32 spellId + uint32 effectCount + // TBC/Classic: uint64 caster + uint32 spellId + uint32 effectCount + // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data + // Effect 24 = SPELL_EFFECT_CREATE_ITEM: uint32 itemEntry per entry + const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t exeCaster = exeTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t exeSpellId = packet.readUInt32(); + uint32_t exeEffectCount = packet.readUInt32(); + exeEffectCount = std::min(exeEffectCount, 32u); // sanity + + const bool isPlayerCaster = (exeCaster == playerGuid); + for (uint32_t ei = 0; ei < exeEffectCount; ++ei) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint8_t effectType = packet.readUInt8(); + uint32_t effectLogCount = packet.readUInt32(); + effectLogCount = std::min(effectLogCount, 64u); // sanity + if (effectType == 24) { + // SPELL_EFFECT_CREATE_ITEM: uint32 itemEntry per log entry + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t itemEntry = packet.readUInt32(); + if (isPlayerCaster && itemEntry != 0) { + ensureItemInfo(itemEntry); + const ItemQueryResponseData* info = getItemInfo(itemEntry); + std::string itemName = info && !info->name.empty() + ? info->name : ("item #" + std::to_string(itemEntry)); + loadSpellNameCache(); + auto spellIt = spellNameCache_.find(exeSpellId); + std::string spellName = (spellIt != spellNameCache_.end() && !spellIt->second.name.empty()) + ? spellIt->second.name : ""; + std::string msg = spellName.empty() + ? ("You create: " + itemName + ".") + : ("You create " + itemName + " using " + spellName + "."); + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId, + " item=", itemEntry, " name=", itemName); + } + } + } else { + // Other effect types: consume their data safely + // Most effects have no trailing per-entry data beyond the count header, + // or use variable-length sub-records we cannot safely skip. + // Stop parsing at first unknown effect to avoid misalignment. + packet.setReadPos(packet.getSize()); + break; + } + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); From 0089b3a16036f9275a3055f42c2fd19991f9fb3b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 23:09:04 -0700 Subject: [PATCH 24/50] feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects - Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster; handles Drain Mana, Viper Sting, Fel Drain, etc. - Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster; handles Drain Life, Death Coil, etc. - Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect) - Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_ so the cast bar dismisses immediately rather than waiting for the next update packet - Effect 49 (FEED_PET): show "You feed your pet ." message for hunter pet feeding All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs. --- src/game/game_handler.cpp | 82 +++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a87960a7..9816ff03 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6066,7 +6066,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // WotLK: packed_guid caster + uint32 spellId + uint32 effectCount // TBC/Classic: uint64 caster + uint32 spellId + uint32 effectCount // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data - // Effect 24 = SPELL_EFFECT_CREATE_ITEM: uint32 itemEntry per entry + // Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier + // Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier + // Effect 24 = CREATE_ITEM: uint32 itemEntry + // Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id + // Effect 49 = FEED_PET: uint32 itemEntry + // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; @@ -6086,8 +6091,46 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t effectType = packet.readUInt8(); uint32_t effectLogCount = packet.readUInt32(); effectLogCount = std::min(effectLogCount, 64u); // sanity - if (effectType == 24) { - // SPELL_EFFECT_CREATE_ITEM: uint32 itemEntry per log entry + if (effectType == 10) { + // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t drainTarget = exeTbcLike + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } + uint32_t drainAmount = packet.readUInt32(); + uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic + /*float drainMult =*/ packet.readFloat(); + if (drainAmount > 0) { + if (drainTarget == playerGuid) + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false); + else if (isPlayerCaster) + addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true); + } + LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, + " power=", drainPower, " amount=", drainAmount); + } + } else if (effectType == 11) { + // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t leechTarget = exeTbcLike + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } + uint32_t leechAmount = packet.readUInt32(); + /*float leechMult =*/ packet.readFloat(); + if (leechAmount > 0) { + if (leechTarget == playerGuid) + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false); + else if (isPlayerCaster) + addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true); + } + LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); + } + } else if (effectType == 24 || effectType == 114) { + // SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t itemEntry = packet.readUInt32(); @@ -6108,11 +6151,36 @@ void GameHandler::handlePacket(network::Packet& packet) { " item=", itemEntry, " name=", itemName); } } + } else if (effectType == 26) { + // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t icTarget = exeTbcLike + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } + uint32_t icSpellId = packet.readUInt32(); + // Clear the interrupted unit's cast bar immediately + unitCastStates_.erase(icTarget); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, + " interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec); + } + } else if (effectType == 49) { + // SPELL_EFFECT_FEED_PET: uint32 itemEntry per log entry + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t feedItem = packet.readUInt32(); + if (isPlayerCaster && feedItem != 0) { + ensureItemInfo(feedItem); + const ItemQueryResponseData* info = getItemInfo(feedItem); + std::string itemName = info && !info->name.empty() + ? info->name : ("item #" + std::to_string(feedItem)); + addSystemChatMessage("You feed your pet " + itemName + "."); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName); + } + } } else { - // Other effect types: consume their data safely - // Most effects have no trailing per-entry data beyond the count header, - // or use variable-length sub-records we cannot safely skip. - // Stop parsing at first unknown effect to avoid misalignment. + // Unknown effect type — stop parsing to avoid misalignment packet.setReadPos(packet.getSize()); break; } From 1d9dc6dcae101d254350f94c3fe8359c616cc154 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 23:23:02 -0700 Subject: [PATCH 25/50] feat: parse SMSG_RESPOND_INSPECT_ACHIEVEMENTS and request on inspect When the player inspects another player on WotLK 3.3.5a, also send CMSG_QUERY_INSPECT_ACHIEVEMENTS so the server responds with SMSG_RESPOND_INSPECT_ACHIEVEMENTS. The new handler parses the achievement-id/date sentinel-terminated block (same layout as SMSG_ALL_ACHIEVEMENT_DATA but prefixed with a packed guid) and stores the earned achievement IDs keyed by GUID in inspectedPlayerAchievements_. The new public getter getInspectedPlayerAchievements(guid) exposes this data for the inspect UI. The cache is cleared on world entry to prevent stale data. QueryInspectAchievementsPacket::build() handles the CMSG wire format (uint64 guid + uint8 unk=0). --- include/game/game_handler.hpp | 11 ++++++ include/game/world_packets.hpp | 6 ++++ src/game/game_handler.cpp | 62 +++++++++++++++++++++++++++++++++- src/game/world_packets.cpp | 9 +++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 6fbb64b2..930ae2e9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1611,6 +1611,12 @@ public: auto it = achievementPointsCache_.find(id); return (it != achievementPointsCache_.end()) ? it->second : 0u; } + /// Returns the set of achievement IDs earned by an inspected player (via SMSG_RESPOND_INSPECT_ACHIEVEMENTS). + /// Returns nullptr if no inspect data is available for the given GUID. + const std::unordered_set* getInspectedPlayerAchievements(uint64_t guid) const { + auto it = inspectedPlayerAchievements_.find(guid); + return (it != inspectedPlayerAchievements_.end()) ? &it->second : nullptr; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -2835,6 +2841,11 @@ private: std::unordered_map criteriaProgress_; void handleAllAchievementData(network::Packet& packet); + // Per-player achievement data from SMSG_RESPOND_INSPECT_ACHIEVEMENTS + // Key: inspected player's GUID; value: set of earned achievement IDs + std::unordered_map> inspectedPlayerAchievements_; + void handleRespondInspectAchievements(network::Packet& packet); + // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) std::unordered_map areaNameCache_; bool areaNameCacheLoaded_ = false; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 5f16039c..e5c7e63c 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1448,6 +1448,12 @@ public: static network::Packet build(uint64_t targetGuid); }; +/** CMSG_QUERY_INSPECT_ACHIEVEMENTS packet builder (WotLK 3.3.5a) */ +class QueryInspectAchievementsPacket { +public: + static network::Packet build(uint64_t targetGuid); +}; + /** CMSG_NAME_QUERY packet builder */ class NameQueryPacket { public: diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9816ff03..a653c523 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6884,10 +6884,12 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: - case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: + handleRespondInspectAchievements(packet); + break; case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: handleQuestPoiQueryResponse(packet); break; @@ -8039,6 +8041,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { encounterUnitGuids_.fill(0); raidTargetGuids_.fill(0); + // Clear inspect caches on world entry to avoid showing stale data + inspectedPlayerAchievements_.clear(); + // Reset talent initialization so the first SMSG_TALENTS_INFO after login // correctly sets the active spec (static locals don't reset across logins) talentsInitialized_ = false; @@ -11301,6 +11306,12 @@ void GameHandler::inspectTarget() { auto packet = InspectPacket::build(targetGuid); socket->send(packet); + // WotLK: also query the player's achievement data so the inspect UI can display it + if (isActiveExpansion("wotlk")) { + auto achPkt = QueryInspectAchievementsPacket::build(targetGuid); + socket->send(achPkt); + } + auto player = std::static_pointer_cast(target); std::string name = player->getName().empty() ? "Target" : player->getName(); addSystemChatMessage("Inspecting " + name + "..."); @@ -22077,6 +22088,55 @@ void GameHandler::handleAllAchievementData(network::Packet& packet) { " achievements, ", criteriaProgress_.size(), " criteria"); } +// --------------------------------------------------------------------------- +// SMSG_RESPOND_INSPECT_ACHIEVEMENTS (WotLK 3.3.5a) +// Wire format: packed_guid (inspected player) + same achievement/criteria +// blocks as SMSG_ALL_ACHIEVEMENT_DATA: +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 date, uint32 unk } +// until 0xFFFFFFFF sentinel +// We store only the earned achievement IDs (not criteria) per inspected player. +// --------------------------------------------------------------------------- +void GameHandler::handleRespondInspectAchievements(network::Packet& packet) { + loadAchievementNameCache(); + + // Read the inspected player's packed guid + if (packet.getSize() - packet.getReadPos() < 1) return; + uint64_t inspectedGuid = UpdateObjectParser::readPackedGuid(packet); + if (inspectedGuid == 0) { + packet.setReadPos(packet.getSize()); + return; + } + + std::unordered_set achievements; + + // Achievement records: { uint32 id, uint32 packedDate } until sentinel 0xFFFFFFFF + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t date =*/ packet.readUInt32(); + achievements.insert(id); + } + + // Criteria records: { uint32 id, uint64 counter, uint32 date, uint32 unk } + // until sentinel 0xFFFFFFFF — consume but don't store for inspect use + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unk(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + packet.readUInt64(); // counter + packet.readUInt32(); // date + packet.readUInt32(); // unk + } + + inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements); + + LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec, + " achievements=", inspectedPlayerAchievements_[inspectedGuid].size()); +} + // --------------------------------------------------------------------------- // Faction name cache (lazily loaded from Faction.dbc) // --------------------------------------------------------------------------- diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 7e9be845..e2fb3772 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1722,6 +1722,15 @@ network::Packet InspectPacket::build(uint64_t targetGuid) { return packet; } +network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) { + // CMSG_QUERY_INSPECT_ACHIEVEMENTS: uint64 targetGuid + uint8 unk (always 0) + network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS)); + packet.writeUInt64(targetGuid); + packet.writeUInt8(0); // unk / achievementSlot — always 0 for WotLK + LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + // ============================================================ // Server Info Commands // ============================================================ From de5c1223073462b04e27c3ff983bfe2162211f3a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 23:30:44 -0700 Subject: [PATCH 26/50] feat: parse SMSG_SET_FACTION_ATWAR/VISIBLE and show at-war status in reputation panel - Parse SMSG_SET_FACTION_ATWAR (uint32 repListId + uint8 set) to track per-faction at-war flags in initialFactions_ flags byte - Parse SMSG_SET_FACTION_VISIBLE (uint32 repListId + uint8 visible) to track faction visibility changes from the server - Add FACTION_FLAG_* constants (VISIBLE, AT_WAR, HIDDEN, etc.) to GameHandler - Build repListId <-> factionId bidirectional maps when loading Faction.dbc (ReputationListID field 1); used to correlate flag packets with standings - Fix Faction.dbc field layout comment: field 1=ReputationListID, field 23=Name (was incorrectly documented as field 22 with no ReputationListID field) - Add isFactionAtWar(), isFactionVisible(), getFactionIdByRepListId(), getRepListIdByFactionId() accessors on GameHandler - Reputation panel now shows watched faction at top, highlights at-war factions in red with "(At War)" label, and marks tracked faction in gold --- include/game/game_handler.hpp | 29 +++++++++++ src/game/game_handler.cpp | 95 ++++++++++++++++++++++++++++------- src/ui/inventory_screen.cpp | 24 +++++++-- 3 files changed, 126 insertions(+), 22 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 930ae2e9..413cdb70 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1488,8 +1488,33 @@ public: uint8_t flags = 0; int32_t standing = 0; }; + // Faction flag bitmask constants (from Faction.dbc ReputationFlags / SMSG_INITIALIZE_FACTIONS) + static constexpr uint8_t FACTION_FLAG_VISIBLE = 0x01; // shown in reputation list + static constexpr uint8_t FACTION_FLAG_AT_WAR = 0x02; // player is at war + static constexpr uint8_t FACTION_FLAG_HIDDEN = 0x04; // never shown + static constexpr uint8_t FACTION_FLAG_INVISIBLE_FORCED = 0x08; + static constexpr uint8_t FACTION_FLAG_PEACE_FORCED = 0x10; + const std::vector& getInitialFactions() const { return initialFactions_; } const std::unordered_map& getFactionStandings() const { return factionStandings_; } + + // Returns true if the player has "at war" toggled for the faction at repListId + bool isFactionAtWar(uint32_t repListId) const { + if (repListId >= initialFactions_.size()) return false; + return (initialFactions_[repListId].flags & FACTION_FLAG_AT_WAR) != 0; + } + // Returns true if the faction is visible in the reputation list + bool isFactionVisible(uint32_t repListId) const { + if (repListId >= initialFactions_.size()) return false; + const uint8_t f = initialFactions_[repListId].flags; + if (f & FACTION_FLAG_HIDDEN) return false; + if (f & FACTION_FLAG_INVISIBLE_FORCED) return false; + return (f & FACTION_FLAG_VISIBLE) != 0; + } + // Returns the faction ID for a given repListId (0 if unknown) + uint32_t getFactionIdByRepListId(uint32_t repListId) const; + // Returns the repListId for a given faction ID (0xFFFFFFFF if not found) + uint32_t getRepListIdByFactionId(uint32_t factionId) const; // Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air) struct TotemSlot { uint32_t spellId = 0; @@ -2566,6 +2591,10 @@ private: std::unordered_map factionStandings_; // Faction name cache (factionId → name), populated lazily from Faction.dbc std::unordered_map factionNameCache_; + // repListId → factionId mapping (populated with factionNameCache) + std::unordered_map factionRepListToId_; + // factionId → repListId reverse mapping + std::unordered_map factionIdToRepList_; bool factionNameCacheLoaded_ = false; void loadFactionNameCache(); std::string getFactionName(uint32_t factionId) const; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a653c523..97de0b71 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3720,11 +3720,40 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_SET_FACTION_ATWAR: - case Opcode::SMSG_SET_FACTION_VISIBLE: - // uint32 factionId [+ uint8 flags for ATWAR] — consume; hostility is tracked via update fields - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SET_FACTION_ATWAR: { + // uint32 repListId + uint8 set (1=set at-war, 0=clear at-war) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t repListId = packet.readUInt32(); + uint8_t setAtWar = packet.readUInt8(); + if (repListId < initialFactions_.size()) { + if (setAtWar) + initialFactions_[repListId].flags |= FACTION_FLAG_AT_WAR; + else + initialFactions_[repListId].flags &= ~FACTION_FLAG_AT_WAR; + LOG_DEBUG("SMSG_SET_FACTION_ATWAR: repListId=", repListId, + " atWar=", (int)setAtWar); + } break; + } + case Opcode::SMSG_SET_FACTION_VISIBLE: { + // uint32 repListId + uint8 visible (1=show, 0=hide) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t repListId = packet.readUInt32(); + uint8_t visible = packet.readUInt8(); + if (repListId < initialFactions_.size()) { + if (visible) + initialFactions_[repListId].flags |= FACTION_FLAG_VISIBLE; + else + initialFactions_[repListId].flags &= ~FACTION_FLAG_VISIBLE; + LOG_DEBUG("SMSG_SET_FACTION_VISIBLE: repListId=", repListId, + " visible=", (int)visible); + } + break; + } case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: @@ -22153,32 +22182,60 @@ void GameHandler::loadFactionNameCache() { // Faction.dbc WotLK 3.3.5a field layout: // 0: ID - // 1-4: ReputationRaceMask[4] - // 5-8: ReputationClassMask[4] - // 9-12: ReputationBase[4] - // 13-16: ReputationFlags[4] - // 17: ParentFactionID - // 18-19: Spillover rates (floats) - // 20-21: MaxRank - // 22: Name (English locale, string ref) - constexpr uint32_t ID_FIELD = 0; - constexpr uint32_t NAME_FIELD = 22; // enUS name string + // 1: ReputationListID (-1 / 0xFFFFFFFF = no reputation tracking) + // 2-5: ReputationRaceMask[4] + // 6-9: ReputationClassMask[4] + // 10-13: ReputationBase[4] + // 14-17: ReputationFlags[4] + // 18: ParentFactionID + // 19-20: SpilloverRateIn, SpilloverRateOut (floats) + // 21-22: SpilloverMaxRankIn, SpilloverMaxRankOut + // 23: Name (English locale, string ref) + constexpr uint32_t ID_FIELD = 0; + constexpr uint32_t REPLIST_FIELD = 1; + constexpr uint32_t NAME_FIELD = 23; // enUS name string + // Classic/TBC have fewer fields; fall back gracefully + const bool hasRepListField = dbc->getFieldCount() > REPLIST_FIELD; if (dbc->getFieldCount() <= NAME_FIELD) { LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount()); - return; + // Don't abort — still try to load names from a shorter layout } + const uint32_t nameField = (dbc->getFieldCount() > NAME_FIELD) ? NAME_FIELD : 22u; uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t factionId = dbc->getUInt32(i, ID_FIELD); if (factionId == 0) continue; - std::string name = dbc->getString(i, NAME_FIELD); - if (!name.empty()) { - factionNameCache_[factionId] = std::move(name); + if (dbc->getFieldCount() > nameField) { + std::string name = dbc->getString(i, nameField); + if (!name.empty()) { + factionNameCache_[factionId] = std::move(name); + } + } + // Build repListId ↔ factionId mapping (WotLK field 1) + if (hasRepListField) { + uint32_t repListId = dbc->getUInt32(i, REPLIST_FIELD); + if (repListId != 0xFFFFFFFFu) { + factionRepListToId_[repListId] = factionId; + factionIdToRepList_[factionId] = repListId; + } } } - LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names"); + LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names, ", + factionRepListToId_.size(), " with reputation tracking"); +} + +uint32_t GameHandler::getFactionIdByRepListId(uint32_t repListId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionRepListToId_.find(repListId); + return (it != factionRepListToId_.end()) ? it->second : 0u; +} + +uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionIdToRepList_.find(factionId); + return (it != factionIdToRepList_.end()) ? it->second : 0xFFFFFFFFu; } std::string GameHandler::getFactionName(uint32_t factionId) const { diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8e5c538e..cfea2be4 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1420,10 +1420,13 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { ImGui::BeginChild("##ReputationList", ImVec2(0, 0), true); - // Sort factions alphabetically by name + // Sort: watched faction first, then alphabetically by name + uint32_t watchedFactionId = gameHandler.getWatchedFactionId(); std::vector> sortedFactions(standings.begin(), standings.end()); std::sort(sortedFactions.begin(), sortedFactions.end(), [&](const auto& a, const auto& b) { + if (a.first == watchedFactionId) return true; + if (b.first == watchedFactionId) return false; const std::string& na = gameHandler.getFactionNamePublic(a.first); const std::string& nb = gameHandler.getFactionNamePublic(b.first); return na < nb; @@ -1435,10 +1438,25 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { const std::string& factionName = gameHandler.getFactionNamePublic(factionId); const char* displayName = factionName.empty() ? "Unknown Faction" : factionName.c_str(); - // Faction name + tier label on same line + // Determine at-war status via repListId lookup + uint32_t repListId = gameHandler.getRepListIdByFactionId(factionId); + bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId); + bool isWatched = (factionId == watchedFactionId); + + // Faction name + tier label on same line; mark at-war and watched factions ImGui::TextColored(tier.color, "[%s]", tier.name); ImGui::SameLine(90.0f); - ImGui::Text("%s", displayName); + if (atWar) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", displayName); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "(At War)"); + } else if (isWatched) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "%s", displayName); + ImGui::SameLine(); + ImGui::TextDisabled("(Tracked)"); + } else { + ImGui::Text("%s", displayName); + } // Progress bar showing position within current tier float ratio = 0.0f; From 74d5984ee285f247d6b44b2bad8e695724326722 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 23:46:38 -0700 Subject: [PATCH 27/50] feat: parse arena header in MSG_PVP_LOG_DATA and show arena scoreboard Previously the arena path in handlePvpLogData consumed the packet and returned early with no data. Now the two-team header is parsed (rating change, new rating, team name), followed by the same player list and winner fields as battlegrounds. The BgScoreboardData struct gains ArenaTeamScore fields (teamName, ratingChange, newRating) populated when isArena=true. The BG scoreboard UI is updated to: - Use "Arena Score" window title for arenas - Show each team's name and rating delta at the top - Identify the winner by team name instead of faction label --- include/game/game_handler.hpp | 7 ++++++ src/game/game_handler.cpp | 32 ++++++++++++++++++++------- src/ui/game_screen.cpp | 41 ++++++++++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 413cdb70..32af0860 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -433,11 +433,18 @@ public: uint32_t bonusHonor = 0; std::vector> bgStats; // BG-specific fields }; + struct ArenaTeamScore { + std::string teamName; + uint32_t ratingChange = 0; // signed delta packed as uint32 + uint32_t newRating = 0; + }; struct BgScoreboardData { std::vector players; bool hasWinner = false; uint8_t winner = 0; // 0=Horde, 1=Alliance bool isArena = false; + // Arena-only fields (valid when isArena=true) + ArenaTeamScore arenaTeams[2]; // team 0 = first, team 1 = second }; void requestPvpLog(); const BgScoreboardData* getBgScoreboard() const { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 97de0b71..7233b0ae 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14992,12 +14992,19 @@ void GameHandler::handlePvpLogData(network::Packet& packet) { 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; + // WotLK 3.3.5a MSG_PVP_LOG_DATA arena header: + // two team blocks × (uint32 ratingChange + uint32 newRating + uint32 unk1 + uint32 unk2 + uint32 unk3 + CString teamName) + // After both team blocks: same player list and winner fields as battleground. + for (int t = 0; t < 2; ++t) { + if (remaining() < 20) { packet.setReadPos(packet.getSize()); return; } + bgScoreboard_.arenaTeams[t].ratingChange = packet.readUInt32(); + bgScoreboard_.arenaTeams[t].newRating = packet.readUInt32(); + packet.readUInt32(); // unk1 + packet.readUInt32(); // unk2 + packet.readUInt32(); // unk3 + bgScoreboard_.arenaTeams[t].teamName = remaining() > 0 ? packet.readString() : ""; + } + // Fall through to parse player list and winner fields below (same layout as BG) } if (remaining() < 4) return; @@ -15046,8 +15053,17 @@ void GameHandler::handlePvpLogData(network::Packet& packet) { bgScoreboard_.winner = packet.readUInt8(); } - LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", - bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); + if (bgScoreboard_.isArena) { + LOG_INFO("Arena log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner, + " team0='", bgScoreboard_.arenaTeams[0].teamName, + "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[0].ratingChange, + " team1='", bgScoreboard_.arenaTeams[1].teamName, + "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[1].ratingChange); + } else { + LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); + } } void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4c9a0417..3a0194bc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20400,7 +20400,8 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); - const char* title = "Battleground Score###BgScore"; + const char* title = data && data->isArena ? "Arena Score###BgScore" + : "Battleground Score###BgScore"; if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { ImGui::End(); return; @@ -20408,16 +20409,46 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { if (!data) { ImGui::TextDisabled("No score data yet."); - ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena."); ImGui::End(); return; } + // Arena team rating banner (shown only for arenas) + if (data->isArena) { + for (int t = 0; t < 2; ++t) { + const auto& at = data->arenaTeams[t]; + if (at.teamName.empty()) continue; + int32_t ratingDelta = static_cast(at.ratingChange); + ImVec4 teamCol = (t == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) // team 0: red + : ImVec4(0.4f, 0.6f, 1.0f, 1.0f); // team 1: blue + ImGui::TextColored(teamCol, "%s", at.teamName.c_str()); + ImGui::SameLine(); + char ratingBuf[32]; + if (ratingDelta >= 0) + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta); + else + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta); + ImGui::TextDisabled("%s", ratingBuf); + } + ImGui::Separator(); + } + // 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); + const char* winnerStr; + ImVec4 winnerColor; + if (data->isArena) { + // For arenas, winner byte 0/1 refers to team index in arenaTeams[] + const auto& winTeam = data->arenaTeams[data->winner & 1]; + winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str(); + winnerColor = (data->winner == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) + : ImVec4(0.4f, 0.6f, 1.0f, 1.0f); + } else { + winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + 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); From e4fd4b4e6d3c7a869146ad5fdfd9c3197971e851 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 23:59:38 -0700 Subject: [PATCH 28/50] feat: parse SMSG_SET_FLAT/PCT_SPELL_MODIFIER and apply talent modifiers to spell tooltips Implements SMSG_SET_FLAT_SPELL_MODIFIER and SMSG_SET_PCT_SPELL_MODIFIER (previously consumed silently). Parses per-group (uint8 groupIndex, uint8 SpellModOp, int32 value) tuples sent by the server after login and talent changes, and stores them in spellFlatMods_/spellPctMods_ maps keyed by (SpellModOp, groupIndex). Exposes getSpellFlatMod(op)/getSpellPctMod(op) accessors and a static applySpellMod() helper. Clears both maps on character login alongside spellCooldowns. Surfaces talent-modified mana cost and cast time in the spellbook tooltip via SpellModOp::Cost and SpellModOp::CastingTime lookups. --- include/game/game_handler.hpp | 83 +++++++++++++++++++++++++++++++++++ src/game/game_handler.cpp | 25 +++++++++-- src/ui/spellbook_screen.cpp | 20 ++++++--- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 32af0860..08311cb2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1491,6 +1491,84 @@ public: }; const std::array& getPlayerRunes() const { return playerRunes_; } + // Talent-driven spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) + // SpellModOp matches WotLK SpellModOp enum (server-side). + enum class SpellModOp : uint8_t { + Damage = 0, + Duration = 1, + Threat = 2, + Effect1 = 3, + Charges = 4, + Range = 5, + Radius = 6, + CritChance = 7, + AllEffects = 8, + NotLoseCastingTime = 9, + CastingTime = 10, + Cooldown = 11, + Effect2 = 12, + IgnoreArmor = 13, + Cost = 14, + CritDamageBonus = 15, + ResistMissChance = 16, + JumpTargets = 17, + ChanceOfSuccess = 18, + ActivationTime = 19, + Efficiency = 20, + MultipleValue = 21, + ResistDispelChance = 22, + Effect3 = 23, + BonusMultiplier = 24, + ProcPerMinute = 25, + ValueMultiplier = 26, + ResistPushback = 27, + MechanicDuration = 28, + StartCooldown = 29, + PeriodicBonus = 30, + AttackPower = 31, + }; + static constexpr int SPELL_MOD_OP_COUNT = 32; + + // Key: (SpellModOp, groupIndex) — value: accumulated flat or pct modifier + // pct values are stored in integer percent (e.g. -20 means -20% reduction). + struct SpellModKey { + SpellModOp op; + uint8_t group; + bool operator==(const SpellModKey& o) const { + return op == o.op && group == o.group; + } + }; + struct SpellModKeyHash { + std::size_t operator()(const SpellModKey& k) const { + return std::hash()( + (static_cast(static_cast(k.op)) << 8) | k.group); + } + }; + + // Returns the sum of all flat modifiers for a given op across all groups. + // (Callers that need per-group resolution can use getSpellFlatMods() directly.) + int32_t getSpellFlatMod(SpellModOp op) const { + int32_t total = 0; + for (const auto& [k, v] : spellFlatMods_) + if (k.op == op) total += v; + return total; + } + // Returns the sum of all pct modifiers for a given op across all groups (in %). + int32_t getSpellPctMod(SpellModOp op) const { + int32_t total = 0; + for (const auto& [k, v] : spellPctMods_) + if (k.op == op) total += v; + return total; + } + + // Convenience: apply flat+pct modifier to a base value. + // result = (base + flatMod) * (1.0 + pctMod/100.0), clamped to >= 0. + static int32_t applySpellMod(int32_t base, int32_t flat, int32_t pct) { + int64_t v = static_cast(base) + flat; + if (pct != 0) v = v + (v * pct + 50) / 100; // round half-up + return static_cast(v < 0 ? 0 : v); + } + struct FactionStandingInit { uint8_t flags = 0; int32_t standing = 0; @@ -3100,6 +3178,11 @@ private: // ---- WotLK Calendar: pending invite counter ---- uint32_t calendarPendingInvites_ = 0; ///< Unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING) + + // ---- Spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) ---- + // Keyed by (SpellModOp, groupIndex); cleared on logout/character change. + std::unordered_map spellFlatMods_; + std::unordered_map spellPctMods_; }; } // namespace game diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7233b0ae..61daa687 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3756,12 +3756,29 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_FEATURE_SYSTEM_STATUS: - case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: - case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: - // Different formats than SMSG_SPELL_DELAYED — consume and ignore packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: + case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: { + // WotLK format: one or more (uint8 groupIndex, uint8 modOp, int32 value) tuples + // Each tuple is 6 bytes; iterate until packet is consumed. + const bool isFlat = (*logicalOp == Opcode::SMSG_SET_FLAT_SPELL_MODIFIER); + auto& modMap = isFlat ? spellFlatMods_ : spellPctMods_; + while (packet.getSize() - packet.getReadPos() >= 6) { + uint8_t groupIndex = packet.readUInt8(); + uint8_t modOpRaw = packet.readUInt8(); + int32_t value = static_cast(packet.readUInt32()); + if (groupIndex > 5 || modOpRaw >= SPELL_MOD_OP_COUNT) continue; + SpellModKey key{ static_cast(modOpRaw), groupIndex }; + modMap[key] = value; + LOG_DEBUG(isFlat ? "SMSG_SET_FLAT_SPELL_MODIFIER" : "SMSG_SET_PCT_SPELL_MODIFIER", + ": group=", (int)groupIndex, " op=", (int)modOpRaw, " value=", value); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELL_DELAYED: { // WotLK: packed_guid (caster) + uint32 delayMs // TBC/Classic: uint64 (caster) + uint32 delayMs @@ -7930,6 +7947,8 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); + spellFlatMods_.clear(); + spellPctMods_.clear(); actionBar = {}; playerAuras.clear(); targetAuras.clear(); diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 8c78ab7d..3d2ceeed 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -525,7 +525,7 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle // Resource cost + cast time on same row (WoW style) if (!info->isPassive()) { - // Left: resource cost + // Left: resource cost (with talent flat/pct modifier applied) char costBuf[64] = ""; if (info->manaCost > 0) { const char* powerName = "Mana"; @@ -535,16 +535,26 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle case 4: powerName = "Focus"; break; default: break; } - std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName); + // Apply SMSG_SET_FLAT/PCT_SPELL_MODIFIER Cost modifier (SpellModOp::Cost = 14) + int32_t flatCost = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::Cost); + int32_t pctCost = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::Cost); + uint32_t displayCost = static_cast( + game::GameHandler::applySpellMod(static_cast(info->manaCost), flatCost, pctCost)); + std::snprintf(costBuf, sizeof(costBuf), "%u %s", displayCost, powerName); } - // Right: cast time + // Right: cast time (with talent CastingTime modifier applied) char castBuf[32] = ""; if (info->castTimeMs == 0) { std::snprintf(castBuf, sizeof(castBuf), "Instant cast"); } else { - float secs = info->castTimeMs / 1000.0f; - std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs); + // Apply SpellModOp::CastingTime (10) modifiers + int32_t flatCT = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::CastingTime); + int32_t pctCT = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::CastingTime); + int32_t modCT = game::GameHandler::applySpellMod( + static_cast(info->castTimeMs), flatCT, pctCT); + float secs = static_cast(modCT) / 1000.0f; + std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs > 0.0f ? secs : 0.0f); } if (costBuf[0] || castBuf[0]) { From d3159791debd5add548bd43840329f3c8c30e27d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 00:47:04 -0700 Subject: [PATCH 29/50] fix: rewrite handleTalentsInfo with correct WotLK SMSG_TALENTS_INFO packet format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TalentsInfoParser used a completely wrong byte layout (expected big-endian counts, wrong field order), causing unspentTalentPoints to always be misread. This made canLearn always false so clicking talents did nothing. New format matches the actual WoW 3.3.5a wire format: uint8 talentType, uint32 unspentTalents, uint8 groupCount, uint8 activeGroup, per-group: uint8 talentCount, [uint32 id + uint8 rank]×N, uint8 glyphCount, [uint16]×M Matches the proven parsing logic in handleInspectResults. --- src/game/game_handler.cpp | 79 +++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 61daa687..0f8a5c7f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16435,44 +16435,67 @@ void GameHandler::handleUnlearnSpells(network::Packet& packet) { // ============================================================ void GameHandler::handleTalentsInfo(network::Packet& packet) { - TalentsInfoData data; - if (!TalentsInfoParser::parse(packet, data)) return; + // SMSG_TALENTS_INFO (WotLK 3.3.5a) correct wire format: + // uint8 talentType (0 = own talents, 1 = inspect result — own talent packets always 0) + // uint32 unspentTalents + // uint8 talentGroupCount + // uint8 activeTalentGroup + // Per group: uint8 talentCount, [uint32 talentId + uint8 rank] × count, + // uint8 glyphCount, [uint16 glyphId] × count + + if (packet.getSize() - packet.getReadPos() < 1) return; + uint8_t talentType = packet.readUInt8(); + if (talentType != 0) { + // type 1 = inspect result; handled by handleInspectResults — ignore here + return; + } + if (packet.getSize() - packet.getReadPos() < 6) { + LOG_WARNING("handleTalentsInfo: packet too short for header"); + return; + } + + uint32_t unspentTalents = packet.readUInt32(); + uint8_t talentGroupCount = packet.readUInt8(); + uint8_t activeTalentGroup = packet.readUInt8(); + if (activeTalentGroup > 1) activeTalentGroup = 0; // Ensure talent DBCs are loaded loadTalentDbc(); - // Validate spec number - if (data.talentSpec > 1) { - LOG_WARNING("Invalid talent spec: ", (int)data.talentSpec); - return; + activeTalentSpec_ = activeTalentGroup; + + for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t talentCount = packet.readUInt8(); + learnedTalents_[g].clear(); + for (uint8_t t = 0; t < talentCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t talentId = packet.readUInt32(); + uint8_t rank = packet.readUInt8(); + learnedTalents_[g][talentId] = rank; + } + learnedGlyphs_[g].fill(0); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t glyphCount = packet.readUInt8(); + for (uint8_t gl = 0; gl < glyphCount; ++gl) { + if (packet.getSize() - packet.getReadPos() < 2) break; + uint16_t glyphId = packet.readUInt16(); + if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; + } } - // Store talents for this spec - unspentTalentPoints_[data.talentSpec] = data.unspentPoints; + unspentTalentPoints_[activeTalentGroup] = + static_cast(unspentTalents > 255 ? 255 : unspentTalents); - // Clear and rebuild learned talents map for this spec - // Note: If a talent appears in the packet, it's learned (ranks are 0-indexed) - learnedTalents_[data.talentSpec].clear(); - for (const auto& talent : data.talents) { - learnedTalents_[data.talentSpec][talent.talentId] = talent.currentRank; - } + LOG_INFO("handleTalentsInfo: unspent=", unspentTalents, + " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, + " learned=", learnedTalents_[activeTalentGroup].size()); - LOG_INFO("Talents loaded: spec=", (int)data.talentSpec, - " unspent=", (int)unspentTalentPoints_[data.talentSpec], - " learned=", learnedTalents_[data.talentSpec].size()); - - // If this is the first spec received after login, set it as the active spec if (!talentsInitialized_) { talentsInitialized_ = true; - activeTalentSpec_ = data.talentSpec; - - // Show message to player about active spec - if (unspentTalentPoints_[data.talentSpec] > 0) { - std::string msg = "You have " + std::to_string(unspentTalentPoints_[data.talentSpec]) + - " unspent talent point"; - if (unspentTalentPoints_[data.talentSpec] > 1) msg += "s"; - msg += " in spec " + std::to_string(data.talentSpec + 1); - addSystemChatMessage(msg); + if (unspentTalents > 0) { + addSystemChatMessage("You have " + std::to_string(unspentTalents) + + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); } } } From acf99354b3c931bffe5d1e8b4b33df9ca622b665 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 00:59:36 -0700 Subject: [PATCH 30/50] feat: add ghost mode grayscale screen effect - FXAA path: repurpose _pad field as 'desaturate' push constant; when ghostMode_ is true, convert final pixel to grayscale with slight cool blue tint using luma(0.299,0.587,0.114) mix - Non-FXAA path: apply a high-opacity gray overlay (rgba 0.5,0.5,0.55,0.82) over the scene for a washed-out look - Both parallel (SEC_POST) and single-threaded render paths covered - ghostMode_ flag set each frame from gameHandler->isPlayerGhost() --- assets/shaders/fxaa.frag.glsl | 12 +++++++++--- assets/shaders/fxaa.frag.spv | Bin 7764 -> 14932 bytes include/rendering/renderer.hpp | 2 ++ src/rendering/renderer.cpp | 17 +++++++++++++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/assets/shaders/fxaa.frag.glsl b/assets/shaders/fxaa.frag.glsl index 158f2f98..3da10854 100644 --- a/assets/shaders/fxaa.frag.glsl +++ b/assets/shaders/fxaa.frag.glsl @@ -2,7 +2,7 @@ // FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass. // Reads the resolved scene color and outputs a smoothed result. -// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), unused. +// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), desaturate (1=ghost grayscale). layout(set = 0, binding = 0) uniform sampler2D uScene; @@ -11,8 +11,8 @@ layout(location = 0) out vec4 outColor; layout(push_constant) uniform PC { vec2 rcpFrame; - float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range) - float _pad; + float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range) + float desaturate; // 1 = full grayscale (ghost mode), 0 = normal color } pc; // Quality tuning @@ -145,5 +145,11 @@ void main() { fxaaResult = clamp(fxaaResult + s * (fxaaResult - blur), 0.0, 1.0); } + // Ghost mode: desaturate to grayscale (with a slight cool blue tint). + if (pc.desaturate > 0.5) { + float gray = dot(fxaaResult, vec3(0.299, 0.587, 0.114)); + fxaaResult = mix(fxaaResult, vec3(gray, gray, gray * 1.05), pc.desaturate); + } + outColor = vec4(fxaaResult, 1.0); } diff --git a/assets/shaders/fxaa.frag.spv b/assets/shaders/fxaa.frag.spv index 7803f3e27e4b92579b95e608e8653b07d921062a..b87b3dee4c2bf8247c4e11b0939b6ffa4a51c031 100644 GIT binary patch literal 14932 zcmZ9S37nQ?^@cxz0aV-t+yzurP~3MB5D^y;6vbT}n8nd?24@Dv9Ym2VZTXv~Wtrt( znYO99plR7|rrBm%YGv8(URu}n&3ohX|K8u6-*eyhInQ~{dEVuF`S=D7*lI|vwpwk? z+FG?018VhSz1jej8rtCcIDN*P854T@S|;qi$Ib?88oMyKCicL0a8L7@RQJB_KJ=c}+_SK&wYRs> z?YE`1x4CaoPjeqLtYr(dg%o4$TZ4Pr=g(xQ^X(WH%~{aeRgWE1o6zq+BL02)`J8Y{ zbN;mO8U6g&@VNl@!UV3TV_`GZSNw#^Z$kYiHuz7e{4St>UW0#r_qfd8R{3pE ze|v-f^!T}K^!J4~FRt^8;62SrrL|})|U3xL%Ms;0}|6zd#}>ww)VJ%b!{Uq%F~FMv#_~-Pa3xw z8mZd*PMg!wj%xmKYFs0`)aMPV?NsqyE519ty{EY){(UMQ|Do`O-Mt6)uRExAIJ~>9 z?TFb`9i4FV&zv!*x^4&8daE2AtzCU{7BqLZa`#!w;{IH#uJ;T2xvA>@CGeK+uGZ9Y zNyYnXX{s%QyVkU>db!MLs$KfNW*ExA+H$mxD(5xe@_gUaz@LJ*^!BZ)>BWA&s;1ZE z9X*wQaP2L4Rl~s@UCo{SYc|#1MepTY%&70bbu`sh;{ma-V{vQethTn^*1ozn6s^6x zyXF1!Hl()p|Boy34ytX4ug!&ynOmPfUcR`w`KZ?3MV(v?_PZxqPu1@};Q5`4q;=n^ zaGzQ3{~|Ew`dod};XcQGUU?isM4o|L^-HTXTC1rIE#gR3*M>1prv~S$nb+ss7|bJl zbU&?gKRdGS2F!BaXmSi>6psK6W*mi$B%hxza{Z&=)}f}2hOf@3Uwu4Wyf$|Ei0cjr zUk85Ev=NiS*M&c`_?3w}5N)3IFy|Pj&vl10n#-D8cLbxc>hg^kSF8Ab6?ae7wy5?q zIn3jn%^9uN{c?`ny-*tt-;!|@W)5o&ht}thX6%pkb9nXIHgIzrUmoF`vTI{;80Y%i zRzYg6FZXw&dX*=+XXm!;Dc+%>Lk7ZONp>OoSPu9_`?Y zt4Es!HjkS19>eIKTT^l`1K)YaxQXGH!8s1tkTbOc;JgnpdcD|>eagM=)UwvKVAs;3 z-3YeEQoFt4W$pJ=acZgKNiaRHy5^pP-Fe3ytFF1{>;B2B_)B$OTbcQ<_VdZsKXSGH zzU{~BVl;eIzrVhod<@3lDn3?Harf)wem-d&U7Keq+}xKjx~E=SuI+dwn)h<}lW_iO z#_!Ax;5ApCg9T{UU$=3+SLb)by-(811(3G3<&*a08d3Lp@^Js(LTO_3ym(KI#H=UW zceL=K;H?|@ga*D(1E1Q!XB6D)$@iu*KOgO@xPIT8O26+-;rdT0xb@8|xc>PC*T10P z`r8VwzrEo4eQzq)^FzPn7Zu$3z9YpizoCKO(!hO3O1$g&juh_v`wFiAfr9IQu;BVv z7F_=$1=s&r!Sz30aQ#m-@TVHM??|c7_!k@adjFLOyHv@2j|z9b?@-~^ zG#u_LCgd9Wq*XRP^s zR_agwoxo-Oo#EzBZM%c5=`z-H{XH;0&()V!+MZzN=}TREfz7Qib?psS*F4I)d}ixU zUHgK|y7q&YbsYdN>zY)b>mI&e*JQBs^rfyTU~^B#QrCfCbfw!)F?Tqrw?EpJh-MMXyYR+{(&cM{dJHhTr_?ck)9LCNX--WsN z+hE4(Pi!~XzQPxRtu=Gc0%xwV`s3>tk+n3>0{KF^)Cjig)afy z@3QJTUJ5p53FiEZ7}XMg30N)sQn2}arg|QiV``USsqJ!bdRhiH=3e4gFn$0tr!jA` z-Uk^y-r+jZcLk>Ad$ibfhcI4+d2Jq9t^46hV_2nqq|#hFaUZR;0gSWBG@kKenDdR- zzdvU0S2F5*m+D>LI$VD&?Joou@B#eI0CWeW~jkV0F!-tm`hge(Uo3z8~}4 zdXDd_c%HrAgs+LI>%W&#Eps0NyZ`zgWRz#_!(ivC>wkbzEps0QyGQySVU%a?x4_O- z*T0fcEpxvOwhw)eGs-jfJ7DLk>wk<<&AEdazl+T+`1imkVD7R0?_JU<7kh5rJ45w@c0`Ilg0)U(#F!0OhT=j*S*uH|*S3ua7u`wdwCbC@+e z%czz$UjVBm*Kfh*+6H?bdjU(X-+}e3Czo1sy$p7*6aRbgOPG3M{s8uR&H4Bv*f{mX zyaHZ{CFW0HQ{5{{n8qQo~=t)}Wr4zk$8hlK<~up3@f@)sp+aVD}+4n#3B_6SErHN_cV(a3M@RF$2M6jf3FE zsi(#!Fn{%Psy}lFgUfR|1a1xLi5Utm@AK8+#;GS}4Y2#2I@g37r=A*yf%&V~pg(=D z1@@fk&-}H))~qjUtONGi%Di>q)~cSE^}yvlus+;4^~7udF3u@lC^;-3( zt`T5s)9*Qbj~KP&-Wcq84&MZB%tJhPRxoY~H%8t0e#fL{y#CF>YQC?HV)XERO?^vj z8_f45@f30%gMTaVri^14v+mYlb=UBFD*4;YGpF;-llj|%oj;Z_^S1-5XTH3gZ=S?% z4|WY>{f<#9xevRli@9vE1$+IumwZiuU zmwESx8>61Q6T$q|^XfOR-xaDS&jDc93ZDcvCfC_yurcahOH&y=yq5GGh#ic1Er^rn zAh74~V&Vo9KMkyA4Sqi<_c}12bIp;t)4|S7th|hMPGS!Mo6lJ1$n_0p&BMSW8TB0s zmh1EWm;v_O+tVyY4|`HS0-J+bmpIq=Y;fcCeI%OtZ0;e~JPNGlXT0mq1glxU>zLEr z$$2!m@t!*dO+7j1g3YI%oN8rGbEofP!LDoU;f(V1eH_@nZ>#p_I z3&1&#tzZx9S8u`8tY4h^+rVZ0?QpfS{?p*rF$T-L4zPOaKOL;*{M3I2IP(6th2W)PA*3rqRmUa5T<$b>h?tPzk z%(KD#)!$c*)t~jw0qf7ZcrRGZ+};D{g587g^T1w*)~5e_urcb^wwO^Z@fU*2`4_>R z?;be+Vz4pl&cA?BO}vD0X@xH-@G@}j$x92o96XAgo}bIXUMr4o(8~uHufRO?f3Wg9 zTI-hR9|D`>Zff(naurzZO3dp}z5+9sxy8;k_QPQ7&As>$u$uSc#~3}#q5e@!%^YHL zjK%+d;O!W*cUQyR&-@$r<6vXd^Kaa1!Rnfa!vM_vy9S~^|0etdcujojuHpTE9oXwB z*V^@9HD)_ zW7O04=fLWkheP_l38FuJe;#b#>goGtaM|}QaO2eN+v`frzEiVf`o0ys1WVtyfz|B$ z4n`0AR=*unvu|-~xD)LD<}>~aVE&lJcP@a$eG%+^nS1t2U^VaAe74^OcfLMr&U%io z^9_gB=$FCPXdlK~OYSp$>FsWCc~0+vtJ&LE89nSx{VSN7y@^xv*TC*!diy$dE^ExV%0dg{ztKF~;On{}!fZPO&+BHa-qMyx`vkFUIoh z{SH{oID6HnW=+ZGn3|pd+iR}b?}F8=>HCbSN&S16nl*`2(+|L9O+SQtujYCFBe0rr z)}&9(nv%~^eg&iX9nJ4E_{ZSo*b>f%{3l?wCo%KsQ*-|1jLvue)8kLU8)5eJGe!^3 zo%%D_uQBI{^Ev-4*mujZjOOvV_;av&VtxU3f6JI(!qxNa{1w=F>el)+qnfo^i|e|6 zay|!k-7$<=_cvhm#5@mn-7@9{xO&$8E!cVLSy!!G*Y&f;@4(h(>{E>L?B7e^jWGA` z4~!n}pZf2yzhL$uPK|#Ad!Ea^c?GVXm_LCV_xR6f>gnTEu=CWd?PW$a`>+<*b^YZ0 zE7*0%VCn5|VD-fO9o)Fb|3FjEy8i?_Pd)3ZmFv2G*7z6L+KhdXQ6AsxVD~iq4X`o! z|Ccwx#;800HAXe#eMY|xR?GZ%z{?8$Z}6p9{vG=tuyc&lXU?}6)sy2ra9PuT;nuX2 zd(3N{ZLJxj?)-Nd)l$;{a9Ptpc$sey+>OkWG`Tz#(NZ#>k}-#TEm5~&w*^~g`ApgluAct32dg@U}Mzt?z$6LUGwmo5xSngC(>^{{x)(K zYzusT{$)RQt@Qh=>w7nNT_etY-2?97b)#>0OwD~2n|n0=y};%kg_+xHb8j?tYw@>C z^7Ohdxa@U5xYu#^ZhyFQ$~~V5SI>7x2Y}V`eaIxRn)lUG>avE(aC7U+K28Cq`$u zgVi;U>~VTH2Hif4OAm9wWe>-~%N~w{H}-HmntEzJ0c_3csrf{(x%8#xlfbU0FEyVG zR@XclYd!_tx{bT4Iv4Z6>iHgIAF$dK{N9JnSmS*-ztZ!ZZGo#Tz`TD>WmI!tJm0Ng z?{B%EyKP`&;%_hf#-9ea4&PJc9bjYBQ}^j$b#vvMoB?(%eY07~xt(D5GZy_!^!?Fe z!>Zo_cA>j(<1zC&-`G5(^!rTAbHy=xwGixHbu*^dv%u)e^rLT<-a~aAQ)}c@6yhiszhO2v@rR%br~X zHji`lr|*lwjeRdcQ_tL`V6|BG`w}$wdje+7u3>EUTfh6AJ#kEr%fR-S-`rdZR`ZB< zS*5u~wB?m%Xnvb>ImG$K>-V~_hASEM`P-bmF@JmS{`y&HFz!O?evt literal 7764 zcmZXZd#u)V9mjti&VkS)dW3V(NIY=DP>2M)u*EqXXo|PRO>226o%9E`oB}mBa=@5@ znD-hJO)awGc&Szy*-}%dscBThO3l)-%AkujHE&z5*YkYe&whLQj?ccI&--)ze!iFI zIS->c2F6s?d#VGggQ|(6s>bN4IzTG0(T#cW87t11wPx+gS;rlJtOnz%rlB?mS7WM9 zX6bVmth$(~IvlzhIs`j$5>$)f#3w_JQ>w1&{Y|zSD0495ioF*K)={u-=EcO-SIoH6 znWsXI$WxkPL$;RW9gmlOQ;6XzSrL!kApV%-82;WVB~L{)VCn= zA;^0!dwFms*82BA`e>)lxV_BAGG{;XVa%>o{3AuSM`Dvpf8x`uud_M=-B`%=>!a-2 z#CnmBWbTKIVQrBS!8=)ZY~81^)ih*dYhNFcC((tYP-v(B2aA!I`IYUz!!^!eHlF*Q z-pqz~M4r=-HaswsHM+jJkT&A6nGau88y3W75J?;HU?&$@JlGO=3hQSlqZ_mDG!y)`%kS^vo zkk+bXybbSqK98*4c@Qtp%KISi$U?lPFi(X%|NT&G2H?e&J*P)Qp3zU>y@ zmGQoQsNm`xq;J&Pjo!Q`6wH10yqZJ*#5@t+JlZAZJa}=*(H?WSV8--(x5r!vX3SwI zF&Dv$OOE!KrxwhZ-jDW}r-2z$yTm*lUR-jt$6Q`8V|u^ZW3B)*rgn*WCcL=hNX)OW z2cE5U9aUw2dZ1wI!CYS*+XQBPb!;=3@$1-CVD6zM5=I%8;?n3vad=F@gl>ET9r_11Jn!M;>5 z-?+rs3~wK-YZIi+F6@05u7r%Ejd$=WX2)CH9d%nFG4G-LvXbXo_}rCX*A>iM!LBbD zrB_SYml@1AK>BO1-uGgN+`hN!-XxcAfjP~cJGdR**mEG?;`VcRGnnU4yPU&Y;Ke0} z{??M1I|^pZ+oATD-vl$Jc8Pfhytw3Ok9k+YjJXqPk9jwkF||v~d*H<-M`CWI-@ZRT zf_!g`l{e>Jc;l!`oFBuBOOC|J9lMsr+mjwB*o|PWsg7*}v!*(BGnnzktjjz0B$Trr z?5To#&V&65Ox%06p0lOzgi)L=WcNaO4`jXeO?y8xwdqEy{uxNUpC8(8DK$I`uWloh zyYU>nxG{1Mo`=`pdoUZ)CimtAc=diR$Noil?WaM0K5G;Em*CZlr~bdeYj6E~A=hfZ z(}TZ*X=87*rhUlzxF+?9_3wfwmVL6%iS;UaZJKXdvp@eSKE_g?Sg#e_SocM!ZD- z-WU6vSO=ol#xgt0x zpZfbbx4ylT;O&<>-`;*^hi|XCBcW-K@1T6%)DOV>4lp&|O8vCaHa63Xjq!X>W8?f< z`Fwnkx{YOQ?e*VT?%FJPYjDl>H~8FwyNC8T_w7V*eVX^Vc^>B>^RIEX)yH-|ym9Kb zgUI}A*s4#DhT)B)-X49F*@k3JiYiiw)z-DKW)vo zggN=_$H$-*kn!Zx?`81riDPMbvzM!fd~3{WEdBJ4-&ye4%lLh~_^7M<>2JNg#CwPu zjc*Ow8}mE##B=pYcx$!(bC@0WOZ;p|%sS*#$NBJnzBqFJF973T<2|!K{!O9Xa?Xe~ zjDTqqKWj~F=hXTrcG_6KKIXNy&oDc3-d7@vS(|)nTLo_nrdDkiAt-xyQ=4|F?QZO~ ziJ!G4wsUHG3_ESCS0D4{&RzoVnf9#aT=nvKB%U6vhUZ_S4t4gZd9It^LBCM+)3M1p z_8!>3b?D6RUYb*EeGRkb&B*q{IcN0`*n6g2$M4{`)^xwE>3&nw8T)Bujm)^;;lu*Ds&*eI2}cJwrFZ$4C5nNK7C3d{_D^yx)~vOTH^@0~41VPN-Yoi*7^E z#_v0MyS5{XyYKE#aye&z?7RD5&DvW>YQF{EUR(RE@D9(v_}3sYYnM;$Ux&AL_b;{Y z027xSPN-Y8??BL|Ui-I@#Z!B7Ij45dljqCzXm1_XI>PL*R{OjY60=tMeE!`9&p)iH z%0A@#(mjCQ1%C_3+x}f-?bX@K#B;VD&!uO|y|zZ}&1F45WOk%?KR_0<9{JRBFFgMm z^|-dwvkS1jo}Ykeug-cB&pGvYH{3sK)ZSd*E@%2@@Ybu1H9o-Xuts(FLt@q_Z;qUA z@tl!|;EiL9N0<{s{9#DU81i{b9)tI6`<56tHqO@WJ@)QuV=nFWPtM2TQ}>hb$tnH> zBxX+eyj4%bdnS`JHqOcEJEM(tXs^HZZDlr|^9K6od*K`5o3Ojs)WZIZU@Wo3dJ#ceb@f=r%)0jBV@_+;-aM}Bugs3D>o3UvfUHG6wY&`P z9yo&S1It=d%YHCz)me-2jb+UE{R`e$OfBpcgse5OUIo)uT|JgDv#x&<$DG!xy?OGx z#2fIgTbq3MdUYqz?TVgw>wl3moW4-#AH=l#E;QbubCZB^J1{0SY)*!o(J@dEh*^s{v`=<7=#~anzp_l4uOg1yZi!pG0#Tc&0%Ew zuP$p_2=Cg|Wo?V##U)49me`BI%~g+m3bL`)CHATC##Wcur@@O$j>NV`e-A$$-2B>Q zy-VO-ueSL<^b%zCj_l*5 o1v5vm)dgdsx6R*a*C6Pxz54m+@;j|M>sia}y>Nb)s^>xf0~8U`L;wH) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 07d8091f..a198b0c7 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -638,6 +638,8 @@ private: bool terrainEnabled = true; bool terrainLoaded = false; + bool ghostMode_ = false; // set each frame from gameHandler->isPlayerGhost() + // CPU timing stats (last frame/update). double lastUpdateMs = 0.0; double lastRenderMs = 0.0; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 8dcf724c..67426ff3 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -5044,7 +5044,7 @@ void Renderer::renderFXAAPass() { vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); - // Pass rcpFrame + sharpness (vec4, 16 bytes). + // Pass rcpFrame + sharpness + desaturate (vec4, 16 bytes). // When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the // post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes. float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f; @@ -5052,7 +5052,7 @@ void Renderer::renderFXAAPass() { 1.0f / static_cast(ext.width), 1.0f / static_cast(ext.height), sharpness, - 0.0f + ghostMode_ ? 1.0f : 0.0f // desaturate: 1=ghost grayscale, 0=normal }; vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc); @@ -5092,6 +5092,9 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { lastWMORenderMs = 0.0; lastM2RenderMs = 0.0; + // Cache ghost state for use in overlay and FXAA passes this frame. + ghostMode_ = (gameHandler && gameHandler->isPlayerGhost()); + uint32_t frameIdx = vkCtx->getCurrentFrame(); VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx]; const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f); @@ -5237,6 +5240,12 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint, cmd); } } + // Ghost mode desaturation overlay (non-FXAA path approximation). + // When FXAA is active the FXAA shader applies true per-pixel desaturation; + // otherwise a high-opacity gray overlay gives a similar washed-out effect. + if (ghostMode_ && overlayPipeline && !fxaa_.enabled) { + renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f), cmd); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -5369,6 +5378,10 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint); } } + // Ghost mode desaturation overlay (non-FXAA path approximation). + if (ghostMode_ && overlayPipeline && !fxaa_.enabled) { + renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f)); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) From 022d387d954e5cf521a6dbf13d012640c79e9b18 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 00:59:43 -0700 Subject: [PATCH 31/50] fix: correct corpse retrieval coordinate mismatch and detect corpse objects - canReclaimCorpse() and getCorpseDistance() compared canonical movementInfo (x=north=server_y, y=west=server_x) against raw server corpseX_/Y_ causing the proximity check to always report wrong distance even when standing on corpse - Fix: use corpseY_ for canonical north and corpseX_ for canonical west - Also detect OBJECT_TYPE_CORPSE update blocks owned by the player to set corpse coordinates at login-as-ghost (before SMSG_DEATH_RELEASE_LOC arrives) --- include/game/game_handler.hpp | 6 ++++-- src/game/game_handler.cpp | 29 ++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 08311cb2..e0baba9e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1106,8 +1106,10 @@ public: /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ float getCorpseDistance() const { if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; - float dx = movementInfo.x - corpseX_; - float dy = movementInfo.y - corpseY_; + // movementInfo is canonical (x=north=server_y, y=west=server_x); + // corpse coords are raw server (x=west, y=north) — swap to compare. + float dx = movementInfo.x - corpseY_; + float dy = movementInfo.y - corpseX_; float dz = movementInfo.z - corpseZ_; return std::sqrt(dx*dx + dy*dy + dz*dz); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0f8a5c7f..eee30296 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10046,6 +10046,27 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { go->getX(), go->getY(), go->getZ(), go->getOrientation()); } } + // Detect player's own corpse object so we have the position even when + // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). + if (block.objectType == ObjectType::CORPSE && block.hasMovement) { + // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) + uint16_t ownerLowIdx = 6; + auto ownerLowIt = block.fields.find(ownerLowIdx); + uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; + auto ownerHighIt = block.fields.find(ownerLowIdx + 1); + uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; + uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; + if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { + // Server coords from movement block + corpseX_ = block.x; + corpseY_ = block.y; + corpseZ_ = block.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Corpse object detected: server=(", block.x, ", ", block.y, ", ", block.z, + ") map=", corpseMapId_); + } + } + // Track online item objects (CONTAINER = bags, also tracked as items) if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); @@ -12192,9 +12213,11 @@ bool GameHandler::canReclaimCorpse() const { if (!releasedSpirit_ || corpseMapId_ == 0) return false; // Only if ghost is on the same map as their corpse if (currentMapId_ != corpseMapId_) return false; - // Must be within 40 yards (server also validates proximity) - float dx = movementInfo.x - corpseX_; - float dy = movementInfo.y - corpseY_; + // movementInfo.x/y are canonical (x=north=server_y, y=west=server_x). + // corpseX_/Y_ are raw server coords (x=west, y=north). + // Convert corpse to canonical before comparing. + float dx = movementInfo.x - corpseY_; // canonical north - server.y + float dy = movementInfo.y - corpseX_; // canonical west - server.x float dz = movementInfo.z - corpseZ_; return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } From 1108aa9ae643194bb6abe4f61cab7dff589f2a98 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:17:30 -0700 Subject: [PATCH 32/50] feat: implement M2 ribbon emitter rendering for spell trail effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse M2RibbonEmitter data (WotLK format) from M2 files — bone index, position, color/alpha/height tracks, edgesPerSecond, edgeLifetime, gravity. Add CPU-side trail simulation per instance (edge birth at bone world position, lifetime expiry, gravity droop). New m2_ribbon.vert/frag shaders render a triangle-strip quad per emitter using the existing particleTexLayout_ descriptor set. Supports both alpha-blend and additive pipeline variants based on material blend mode. Fixes invisible spell trail effects (~5-10%% of spell visuals) that were silently skipped. --- assets/shaders/m2_ribbon.frag.glsl | 25 ++ assets/shaders/m2_ribbon.frag.spv | Bin 0 -> 1484 bytes assets/shaders/m2_ribbon.vert.glsl | 43 ++++ assets/shaders/m2_ribbon.vert.spv | Bin 0 -> 3032 bytes include/pipeline/m2_loader.hpp | 26 +++ include/rendering/m2_renderer.hpp | 36 +++ src/pipeline/m2_loader.cpp | 119 ++++++++++ src/rendering/m2_renderer.cpp | 354 ++++++++++++++++++++++++++++- src/rendering/renderer.cpp | 2 + 9 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 assets/shaders/m2_ribbon.frag.glsl create mode 100644 assets/shaders/m2_ribbon.frag.spv create mode 100644 assets/shaders/m2_ribbon.vert.glsl create mode 100644 assets/shaders/m2_ribbon.vert.spv diff --git a/assets/shaders/m2_ribbon.frag.glsl b/assets/shaders/m2_ribbon.frag.glsl new file mode 100644 index 00000000..4e0e483e --- /dev/null +++ b/assets/shaders/m2_ribbon.frag.glsl @@ -0,0 +1,25 @@ +#version 450 + +// M2 ribbon emitter fragment shader. +// Samples the ribbon texture, multiplied by vertex color and alpha. +// Uses additive blending (pipeline-level) for magic/spell trails. + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(location = 0) in vec3 vColor; +layout(location = 1) in float vAlpha; +layout(location = 2) in vec2 vUV; +layout(location = 3) in float vFogFactor; + +layout(location = 0) out vec4 outColor; + +void main() { + vec4 tex = texture(uTexture, vUV); + // For additive ribbons alpha comes from texture luminance; multiply by vertex alpha. + float a = tex.a * vAlpha; + if (a < 0.01) discard; + vec3 rgb = tex.rgb * vColor; + // Ribbons fade slightly with fog (additive blend attenuated toward black = invisible in fog). + rgb *= vFogFactor; + outColor = vec4(rgb, a); +} diff --git a/assets/shaders/m2_ribbon.frag.spv b/assets/shaders/m2_ribbon.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..9b0a3fe900055969d69e1e665c1b5e31b1210568 GIT binary patch literal 1484 zcmYk6+fGwa5QZ1DMFcs?NyI}7QB)L9fOt%Zk=`^x06Fd*pB!QH~Qr_)dd7`^%MTd4IoLc~Pw& z9aWE$&#gwa)oeGa2hDDyo;3Rnd7}foX1m_%9Sjl*Fa~ERaK>rQNE#=hz~2OM?@i+* z>2>KAKyvAv{kQMZcpj{l;`wy;{%-5&BY?a$@b2LU;(U6i{_{?&)3sJe@7Le!9PZWX zNyNNwr?&ph(g(%fZTUCn(7bI*+RS-RM#MAf?S z0?`P*h%LhR9n96N$nPj#Oj|;^F}!upA6zkCrr$UPdG0@xk;EQnxp#JX-W1+`&LV#r zZ!dBA=4v2jeGVUIoF`vMaei3qS-wc@{p6p=yVo=9XRd}~^2IM>V?S1(%rQlg*txFY zt;If9i9J``zBlmpFt@L{Zy^@X@h!~d-^1I>9@fo$8?kM^7w@m;F0o%A1-x9}z;8D0 zRRh1vm^<5AmOq8B){~ezo2%tnOda3T)qS&=9PwwIXn(O?>h?c}iK(SM)M5z}kNE{+ zwfw>!>lZOO;`Ve#vB>v36#E9hh%aGc>i-k|D&F;*^Q^64VrpUh2Q`hw?CCwOVa~aa zO1{PQ3=?+^VQMSi`c2Gta%S^en0v&XSslgV%(sbs$GG2rbaK9C`|lEq$&35NB7c)u xe(bkJtOl<#&9;ft0supRnms)F8(nbcZ zQk5HSc_WG^;1PHPF1ewK|J&JC78m1cPyc@(rl+U1rRk+(&P}N#W-;@ z*udjvLo4*v?V3|O>6&M zNy_eot2a9JX0zVd>~sf%&M5sU4m)ww4?DeR7-g zVe$iYChEY9>Li}~#nIk=+8zYma3@HI(T~MAx|rEFqG6He4jI3m#EC33?pT(uB0f0y zE(-f;Zh2kTjBNQGggBVjP&bmlBJEdmZQNqW}2)CNV!|6Yh@!M{c-33mo$Z z_g8@SSobdY@uYX$9Ynb>;!VB81 zKl9QSg{ch=8$#YC?eqjYb%8JE9C@kZieyTH|0lVPT;NZ&TVC_OE{^_NT{on&@L$o+ zJ$!9tcUL;+gE=3}Y{3x^|45RYeRbFHrZ9_qb?x}tJA#=-ID1!SaVp2;XBM#&-_VZD z&IdD_aLi3Jw^1|xE$zg@lON3NTE1_j6Ki%bvkZ4jzcunOLo8zc7Q;7#T=b0JJN+~~ zSHSd|Z@k&TeB+Cl?>qRu^3ju1lB#4^r*Oy9Ij3#H&E_06n%|k6qu!P>lJl&Dc>J;R z`*@&RP&2kQoxz;GD`B>oU+Q3HFG=9RoO^IOLUo}Anp&T@Vrot(^~<@``O zyyg5zI&qfsW9j(7lZV;8Dq+5i>yjH1&LZ}f1RF8DBiLf@NVk}pbc^{?IyPc%YX_U{E9r2C?@Ona zSNYpNkd6<$`8Svj?b#3s+XefbqRg&ef7W3U-C0H#B&ZlRrL`6EeZbg y8auyVn;m}Z*r|tpe=T_|;eOjWwpyPG@8mXq!(ePp?cYk;65@7q{Etq(Bl#CtSltEy literal 0 HcmV?d00001 diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index d3949f88..185ca653 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -165,6 +165,29 @@ struct M2ParticleEmitter { bool enabled = true; }; +// Ribbon emitter definition parsed from M2 (WotLK format) +struct M2RibbonEmitter { + int32_t ribbonId = 0; + uint32_t bone = 0; // Bone that drives the ribbon spine + glm::vec3 position{0.0f}; // Offset from bone pivot + + uint16_t textureIndex = 0; // First texture lookup index + uint16_t materialIndex = 0; // First material lookup index (blend mode) + + // Animated tracks + M2AnimationTrack colorTrack; // RGB 0..1 + M2AnimationTrack alphaTrack; // float 0..1 (stored as fixed16 on disk) + M2AnimationTrack heightAboveTrack; // Half-width above bone + M2AnimationTrack heightBelowTrack; // Half-width below bone + M2AnimationTrack visibilityTrack; // 0=hidden, 1=visible + + float edgesPerSecond = 15.0f; // How many edge points are generated per second + float edgeLifetime = 0.5f; // Seconds before edges expire + float gravity = 0.0f; // Downward pull on edges per s² + uint16_t textureRows = 1; + uint16_t textureCols = 1; +}; + // Complete M2 model structure struct M2Model { // Model metadata @@ -213,6 +236,9 @@ struct M2Model { // Particle emitters std::vector particleEmitters; + // Ribbon emitters + std::vector ribbonEmitters; + // Collision mesh (simplified geometry for physics) std::vector collisionVertices; std::vector collisionIndices; // 3 per triangle diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 3d79379f..4ddea931 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,11 @@ struct M2ModelGPU { std::vector particleTextures; // Resolved Vulkan textures per emitter std::vector particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc) + // Ribbon emitter data (kept from M2Model) + std::vector ribbonEmitters; + std::vector ribbonTextures; // Resolved texture per ribbon emitter + std::vector ribbonTexSets; // Descriptor sets per ribbon emitter + // Texture transform data for UV animation std::vector textureTransforms; std::vector textureTransformLookup; @@ -180,6 +186,19 @@ struct M2Instance { std::vector emitterAccumulators; // fractional particle counter per emitter std::vector particles; + // Ribbon emitter state + struct RibbonEdge { + glm::vec3 worldPos; // Spine world position when this edge was born + glm::vec3 color; // Interpolated color at birth + float alpha; // Interpolated alpha at birth + float heightAbove;// Half-width above spine + float heightBelow;// Half-width below spine + float age; // Seconds since spawned + }; + // One deque of edges per ribbon emitter on this instance + std::vector> ribbonEdges; + std::vector ribbonEdgeAccumulators; // fractional edge counter per emitter + // Cached model flags (set at creation to avoid per-frame hash lookups) bool cachedHasAnimation = false; bool cachedDisableAnimation = false; @@ -295,6 +314,11 @@ public: */ void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + /** + * Render M2 ribbon emitters (spell trails / wing effects) + */ + void renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + void setInstancePosition(uint32_t instanceId, const glm::vec3& position); void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen); @@ -374,6 +398,11 @@ private: VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE; + // Ribbon pipelines (additive + alpha-blend) + VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; // Alpha-blend ribbons + VkPipeline ribbonAdditivePipeline_ = VK_NULL_HANDLE; // Additive ribbons + VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE; + // Descriptor set layouts VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1 VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2 @@ -385,6 +414,12 @@ private: static constexpr uint32_t MAX_MATERIAL_SETS = 8192; static constexpr uint32_t MAX_BONE_SETS = 8192; + // Dynamic ribbon vertex buffer (CPU-written triangle strip) + static constexpr size_t MAX_RIBBON_VERTS = 2048; // 9 floats each + ::VkBuffer ribbonVB_ = VK_NULL_HANDLE; + VmaAllocation ribbonVBAlloc_ = VK_NULL_HANDLE; + void* ribbonVBMapped_ = nullptr; + // Dynamic particle buffers ::VkBuffer smokeVB_ = VK_NULL_HANDLE; VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE; @@ -535,6 +570,7 @@ private: glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio); void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt); void updateParticles(M2Instance& inst, float dt); + void updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt); // Helper to allocate descriptor sets VkDescriptorSet allocateMaterialSet(); diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index b3d057d6..b1f82973 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector& m2Data) { } // end size check } + // Parse ribbon emitters (WotLK only; vanilla format TBD). + // WotLK M2RibbonEmitter = 0xAC (172) bytes per entry. + static constexpr uint32_t RIBBON_SIZE_WOTLK = 0xAC; + if (header.nRibbonEmitters > 0 && header.ofsRibbonEmitters > 0 && + header.nRibbonEmitters < 64 && header.version >= 264) { + + if (static_cast(header.ofsRibbonEmitters) + + static_cast(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) { + + // Build sequence flags for parseAnimTrack + std::vector ribSeqFlags; + ribSeqFlags.reserve(model.sequences.size()); + for (const auto& seq : model.sequences) { + ribSeqFlags.push_back(seq.flags); + } + + for (uint32_t ri = 0; ri < header.nRibbonEmitters; ri++) { + uint32_t base = header.ofsRibbonEmitters + ri * RIBBON_SIZE_WOTLK; + + M2RibbonEmitter rib; + rib.ribbonId = readValue(m2Data, base + 0x00); + rib.bone = readValue(m2Data, base + 0x04); + rib.position.x = readValue(m2Data, base + 0x08); + rib.position.y = readValue(m2Data, base + 0x0C); + rib.position.z = readValue(m2Data, base + 0x10); + + // textureIndices M2Array (0x14): count + offset → first element = texture lookup index + { + uint32_t nTex = readValue(m2Data, base + 0x14); + uint32_t ofsTex = readValue(m2Data, base + 0x18); + if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) { + rib.textureIndex = readValue(m2Data, ofsTex); + } + } + + // materialIndices M2Array (0x1C): count + offset → first element = material index + { + uint32_t nMat = readValue(m2Data, base + 0x1C); + uint32_t ofsMat = readValue(m2Data, base + 0x20); + if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) { + rib.materialIndex = readValue(m2Data, ofsMat); + } + } + + // colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1) + if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x24); + parseAnimTrack(m2Data, disk, rib.colorTrack, TrackType::VEC3, ribSeqFlags); + } + + // alphaTrack M2TrackDisk at 0x38 (fixed16: int16/32767) + // Same nested-array layout as parseAnimTrack but keys are int16. + if (base + 0x38 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x38); + auto& track = rib.alphaTrack; + track.interpolationType = disk.interpolationType; + track.globalSequence = disk.globalSequence; + uint32_t nSeqs = disk.nTimestamps; + if (nSeqs > 0 && nSeqs <= 4096) { + track.sequences.resize(nSeqs); + for (uint32_t s = 0; s < nSeqs; s++) { + if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue; + uint32_t tsHdr = disk.ofsTimestamps + s * 8; + uint32_t keyHdr = disk.ofsKeys + s * 8; + if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue; + uint32_t tsCount = readValue(m2Data, tsHdr); + uint32_t tsOfs = readValue(m2Data, tsHdr + 4); + uint32_t kCount = readValue(m2Data, keyHdr); + uint32_t kOfs = readValue(m2Data, keyHdr + 4); + if (tsCount == 0 || kCount == 0) continue; + if (tsOfs + tsCount * 4 > m2Data.size()) continue; + if (kOfs + kCount * sizeof(int16_t) > m2Data.size()) continue; + track.sequences[s].timestamps = readArray(m2Data, tsOfs, tsCount); + auto raw = readArray(m2Data, kOfs, kCount); + track.sequences[s].floatValues.reserve(raw.size()); + for (auto v : raw) { + track.sequences[s].floatValues.push_back( + static_cast(v) / 32767.0f); + } + } + } + } + + // heightAboveTrack M2TrackDisk at 0x4C (float) + if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x4C); + parseAnimTrack(m2Data, disk, rib.heightAboveTrack, TrackType::FLOAT, ribSeqFlags); + } + + // heightBelowTrack M2TrackDisk at 0x60 (float) + if (base + 0x60 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x60); + parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags); + } + + rib.edgesPerSecond = readValue(m2Data, base + 0x74); + rib.edgeLifetime = readValue(m2Data, base + 0x78); + rib.gravity = readValue(m2Data, base + 0x7C); + rib.textureRows = readValue(m2Data, base + 0x80); + rib.textureCols = readValue(m2Data, base + 0x82); + if (rib.textureRows == 0) rib.textureRows = 1; + if (rib.textureCols == 0) rib.textureCols = 1; + + // Clamp to sane values + if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f; + if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f; + + // visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1) + if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x98); + parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags); + } + + model.ribbonEmitters.push_back(std::move(rib)); + } + core::Logger::getInstance().debug(" Ribbon emitters: ", model.ribbonEmitters.size()); + } + } + // Read collision mesh (bounding triangles/vertices/normals) if (header.nBoundingVertices > 0 && header.ofsBoundingVertices > 0) { struct Vec3Disk { float x, y, z; }; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 96659828..6ef65e5f 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -540,6 +540,54 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout .build(device); } + // --- Build ribbon pipelines --- + // Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats = 36 bytes + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + // Reuse particleTexLayout_ for set 1 (single texture sampler) + VkDescriptorSetLayout ribLayouts[] = {perFrameLayout, particleTexLayout_}; + VkPipelineLayoutCreateInfo lci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + lci.setLayoutCount = 2; + lci.pSetLayouts = ribLayouts; + vkCreatePipelineLayout(device, &lci, nullptr, &ribbonPipelineLayout_); + + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // pos + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, // alpha + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, // uv + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + // Clean up shader modules m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); @@ -570,6 +618,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout bci.size = MAX_GLOW_SPRITES * 9 * sizeof(float); vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &glowVB_, &glowVBAlloc_, &allocInfo); glowVBMapped_ = allocInfo.pMappedData; + + // Ribbon vertex buffer — triangle strip: pos(3)+color(3)+alpha(1)+uv(2)=9 floats/vert + bci.size = MAX_RIBBON_VERTS * 9 * sizeof(float); + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &ribbonVB_, &ribbonVBAlloc_, &allocInfo); + ribbonVBMapped_ = allocInfo.pMappedData; } // --- Create white fallback texture --- @@ -666,10 +719,11 @@ void M2Renderer::shutdown() { whiteTexture_.reset(); glowTexture_.reset(); - // Clean up particle buffers + // Clean up particle/ribbon buffers if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; } if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; } if (glowVB_) { vmaDestroyBuffer(alloc, glowVB_, glowVBAlloc_); glowVB_ = VK_NULL_HANDLE; } + if (ribbonVB_) { vmaDestroyBuffer(alloc, ribbonVB_, ribbonVBAlloc_); ribbonVB_ = VK_NULL_HANDLE; } smokeParticles.clear(); // Destroy pipelines @@ -681,10 +735,13 @@ void M2Renderer::shutdown() { destroyPipeline(particlePipeline_); destroyPipeline(particleAdditivePipeline_); destroyPipeline(smokePipeline_); + destroyPipeline(ribbonPipeline_); + destroyPipeline(ribbonAdditivePipeline_); if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; } if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; } + if (ribbonPipelineLayout_) { vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); ribbonPipelineLayout_ = VK_NULL_HANDLE; } // Destroy descriptor pools and layouts if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } @@ -719,6 +776,11 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; } } model.particleTexSets.clear(); + // Free ribbon texture descriptor sets + for (auto& rSet : model.ribbonTexSets) { + if (rSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &rSet); rSet = VK_NULL_HANDLE; } + } + model.ribbonTexSets.clear(); } void M2Renderer::destroyInstanceBones(M2Instance& inst) { @@ -1345,6 +1407,43 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Copy ribbon emitter data and resolve textures + gpuModel.ribbonEmitters = model.ribbonEmitters; + if (!model.ribbonEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get()); + gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE); + for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) { + // Resolve texture via textureLookup table + uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex; + uint32_t texIdx = (texLookupIdx < model.textureLookup.size()) + ? model.textureLookup[texLookupIdx] : UINT32_MAX; + if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { + gpuModel.ribbonTextures[ri] = allTextures[texIdx]; + } + // Allocate descriptor set (reuse particleTexLayout_ = single sampler) + if (particleTexLayout_ && materialDescPool_) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.ribbonTexSets[ri]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.ribbonTextures[ri]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.ribbonTexSets[ri]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + LOG_DEBUG(" Ribbon emitters loaded: ", model.ribbonEmitters.size()); + } + // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; @@ -2241,6 +2340,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: if (!instance.cachedModel) continue; emitParticles(instance, *instance.cachedModel, deltaTime); updateParticles(instance, deltaTime); + if (!instance.cachedModel->ribbonEmitters.empty()) { + updateRibbons(instance, *instance.cachedModel, deltaTime); + } } } @@ -3375,6 +3477,214 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { } } +// --------------------------------------------------------------------------- +// Ribbon emitter simulation +// --------------------------------------------------------------------------- +void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) { + const auto& emitters = gpu.ribbonEmitters; + if (emitters.empty()) return; + + // Grow per-instance state arrays if needed + if (inst.ribbonEdges.size() != emitters.size()) { + inst.ribbonEdges.resize(emitters.size()); + } + if (inst.ribbonEdgeAccumulators.size() != emitters.size()) { + inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f); + } + + for (size_t ri = 0; ri < emitters.size(); ri++) { + const auto& em = emitters[ri]; + auto& edges = inst.ribbonEdges[ri]; + auto& accum = inst.ribbonEdgeAccumulators[ri]; + + // Determine bone world position for spine + glm::vec3 spineWorld = inst.position; + if (em.bone < inst.boneMatrices.size()) { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); + } else { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * local); + } + + // Evaluate animated tracks (use first available sequence key, or fallback value) + auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { + for (const auto& seq : track.sequences) { + if (!seq.floatValues.empty()) return seq.floatValues[0]; + } + return fallback; + }; + auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 { + for (const auto& seq : track.sequences) { + if (!seq.vec3Values.empty()) return seq.vec3Values[0]; + } + return fallback; + }; + + float visibility = getFloatVal(em.visibilityTrack, 1.0f); + float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f); + float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f); + glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f)); + float alpha = getFloatVal(em.alphaTrack, 1.0f); + + // Age existing edges and remove expired ones + for (auto& e : edges) { + e.age += dt; + // Apply gravity + if (em.gravity != 0.0f) { + e.worldPos.z -= em.gravity * dt * dt * 0.5f; + } + } + while (!edges.empty() && edges.front().age >= em.edgeLifetime) { + edges.pop_front(); + } + + // Emit new edges based on edgesPerSecond + if (visibility > 0.5f) { + accum += em.edgesPerSecond * dt; + while (accum >= 1.0f) { + accum -= 1.0f; + M2Instance::RibbonEdge e; + e.worldPos = spineWorld; + e.color = color; + e.alpha = alpha; + e.heightAbove = heightAbove; + e.heightBelow = heightBelow; + e.age = 0.0f; + edges.push_back(e); + // Cap trail length + if (edges.size() > 128) edges.pop_front(); + } + } else { + accum = 0.0f; + } + } +} + +// --------------------------------------------------------------------------- +// Ribbon rendering +// --------------------------------------------------------------------------- +void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!ribbonPipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; + + // Build camera right vector for billboard orientation + // For ribbons we orient the quad strip along the spine with screen-space up. + // Simple approach: use world-space Z=up for the ribbon cross direction. + const glm::vec3 upWorld(0.0f, 0.0f, 1.0f); + + float* dst = static_cast(ribbonVBMapped_); + size_t written = 0; + + struct DrawCall { + VkDescriptorSet texSet; + VkPipeline pipeline; + uint32_t firstVertex; + uint32_t vertexCount; + }; + std::vector draws; + + for (const auto& inst : instances) { + if (!inst.cachedModel) continue; + const auto& gpu = *inst.cachedModel; + if (gpu.ribbonEmitters.empty()) continue; + + for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) { + if (ri >= inst.ribbonEdges.size()) continue; + const auto& edges = inst.ribbonEdges[ri]; + if (edges.size() < 2) continue; + + const auto& em = gpu.ribbonEmitters[ri]; + + // Select blend pipeline based on material blend mode + bool additive = false; + if (em.materialIndex < gpu.batches.size()) { + additive = (gpu.batches[em.materialIndex].blendMode >= 3); + } + VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_; + + // Descriptor set for texture + VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) + ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; + if (!texSet) continue; + + uint32_t firstVert = static_cast(written); + + // Emit triangle strip: 2 verts per edge (top + bottom) + for (size_t ei = 0; ei < edges.size(); ei++) { + if (written + 2 > MAX_RIBBON_VERTS) break; + const auto& e = edges[ei]; + float t = (em.edgeLifetime > 0.0f) + ? 1.0f - (e.age / em.edgeLifetime) : 1.0f; + float a = e.alpha * t; + float u = static_cast(ei) / static_cast(edges.size() - 1); + + // Top vertex (above spine along upWorld) + glm::vec3 top = e.worldPos + upWorld * e.heightAbove; + dst[written * 9 + 0] = top.x; + dst[written * 9 + 1] = top.y; + dst[written * 9 + 2] = top.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 0.0f; // v = top + written++; + + // Bottom vertex (below spine) + glm::vec3 bot = e.worldPos - upWorld * e.heightBelow; + dst[written * 9 + 0] = bot.x; + dst[written * 9 + 1] = bot.y; + dst[written * 9 + 2] = bot.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 1.0f; // v = bottom + written++; + } + + uint32_t vertCount = static_cast(written) - firstVert; + if (vertCount >= 4) { + draws.push_back({texSet, pipe, firstVert, vertCount}); + } else { + // Rollback if too few verts + written = firstVert; + } + } + } + + if (draws.empty() || written == 0) return; + + VkExtent2D ext = vkCtx_->getSwapchainExtent(); + VkViewport vp{}; + vp.x = 0; vp.y = 0; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.minDepth = 0.0f; vp.maxDepth = 1.0f; + VkRect2D sc{}; + sc.offset = {0, 0}; + sc.extent = ext; + vkCmdSetViewport(cmd, 0, 1, &vp); + vkCmdSetScissor(cmd, 0, 1, &sc); + + VkPipeline lastPipe = VK_NULL_HANDLE; + for (const auto& dc : draws) { + if (dc.pipeline != lastPipe) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + lastPipe = dc.pipeline; + } + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset); + vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0); + } +} + void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (!particlePipeline_ || !m2ParticleVB_) return; @@ -4505,6 +4815,8 @@ void M2Renderer::recreatePipelines() { if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } + if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; } + if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; } // --- Load shaders --- rendering::VkShaderModule m2Vert, m2Frag; @@ -4624,6 +4936,46 @@ void M2Renderer::recreatePipelines() { .build(device); } + // --- Ribbon pipelines --- + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); smokeVert.destroy(); smokeFrag.destroy(); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 67426ff3..05305cc2 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -5159,6 +5159,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(cmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(cmd, perFrameSet); m2Renderer->renderM2Particles(cmd, perFrameSet); + m2Renderer->renderM2Ribbons(cmd, perFrameSet); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -5344,6 +5345,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(currentCmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); m2Renderer->renderM2Particles(currentCmd, perFrameSet); + m2Renderer->renderM2Ribbons(currentCmd, perFrameSet); lastM2RenderMs = std::chrono::duration( std::chrono::steady_clock::now() - m2Start).count(); } From 13c096f3e925f209cd8879f15c3a0ca42044bc0c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:27:30 -0700 Subject: [PATCH 33/50] fix: resolve keybinding conflicts for Q, M, and grave keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TOGGLE_QUEST_LOG: change default from Q to None — Q conflicts with strafe-left in camera_controller; quest log already accessible via TOGGLE_QUESTS (L, the standard WoW binding) - Equipment Set Manager: remove hardcoded SDL_SCANCODE_GRAVE shortcut (~` should not be used for this) - World map M key: remove duplicate SDL_SCANCODE_M self-handler from world_map.cpp::render() that was desync-ing with game_screen's TOGGLE_WORLD_MAP binding; game_screen now owns open/close, render() handles initial zone load and ESC-close signalling via isOpen() --- src/rendering/world_map.cpp | 64 ++++++++++++++++++----------------- src/ui/game_screen.cpp | 7 ++-- src/ui/keybinding_manager.cpp | 6 ++-- 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 138d39db..7ee4f43a 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -842,45 +842,47 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr if (!zones.empty()) updateExploration(playerRenderPos); - if (open) { - if (input.isKeyJustPressed(SDL_SCANCODE_M) || - input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { - open = false; - return; + // game_screen owns the open/close toggle (via showWorldMap_ + TOGGLE_WORLD_MAP keybinding). + // render() is only called when showWorldMap_ is true, so treat each call as "should be open". + if (!open) { + // First time shown: load zones and navigate to player's location. + open = true; + if (zones.empty()) loadZonesFromDBC(); + + int bestContinent = findBestContinentForPlayer(playerRenderPos); + if (bestContinent >= 0 && bestContinent != continentIdx) { + continentIdx = bestContinent; + compositedIdx = -1; } + int playerZone = findZoneForPlayer(playerRenderPos); + if (playerZone >= 0 && continentIdx >= 0 && + zoneBelongsToContinent(playerZone, continentIdx)) { + loadZoneTextures(playerZone); + requestComposite(playerZone); + currentIdx = playerZone; + viewLevel = ViewLevel::ZONE; + } else if (continentIdx >= 0) { + loadZoneTextures(continentIdx); + requestComposite(continentIdx); + currentIdx = continentIdx; + viewLevel = ViewLevel::CONTINENT; + } + } + + // ESC closes the map; game_screen will sync showWorldMap_ via wm->isOpen() next frame. + if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { + open = false; + return; + } + + { auto& io = ImGui::GetIO(); float wheelDelta = io.MouseWheel; if (std::abs(wheelDelta) < 0.001f) wheelDelta = input.getMouseWheelDelta(); if (wheelDelta > 0.0f) zoomIn(playerRenderPos); else if (wheelDelta < 0.0f) zoomOut(); - } else { - auto& io = ImGui::GetIO(); - if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) { - open = true; - if (zones.empty()) loadZonesFromDBC(); - - int bestContinent = findBestContinentForPlayer(playerRenderPos); - if (bestContinent >= 0 && bestContinent != continentIdx) { - continentIdx = bestContinent; - compositedIdx = -1; - } - - int playerZone = findZoneForPlayer(playerRenderPos); - if (playerZone >= 0 && continentIdx >= 0 && - zoneBelongsToContinent(playerZone, continentIdx)) { - loadZoneTextures(playerZone); - requestComposite(playerZone); - currentIdx = playerZone; - viewLevel = ViewLevel::ZONE; - } else if (continentIdx >= 0) { - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - } } if (!open) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3a0194bc..4da386cb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2341,10 +2341,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showTitlesWindow_ = !showTitlesWindow_; } - // Toggle Equipment Set Manager with ` (backtick / grave — unused in standard WoW) - if (input.isKeyJustPressed(SDL_SCANCODE_GRAVE) && !ImGui::GetIO().WantCaptureKeyboard) { - showEquipSetWindow_ = !showEquipSetWindow_; - } // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { @@ -6387,6 +6383,9 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { int screenW = window ? window->getWidth() : 1280; int screenH = window ? window->getHeight() : 720; wm->render(playerPos, screenW, screenH); + + // Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay). + if (!wm->isOpen()) showWorldMap_ = false; } // ============================================================ diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index 5ac79927..fbe70e33 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -22,15 +22,15 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_SPELLBOOK)] = ImGuiKey_P; // WoW standard key bindings_[static_cast(Action::TOGGLE_TALENTS)] = ImGuiKey_N; // WoW standard key bindings_[static_cast(Action::TOGGLE_QUESTS)] = ImGuiKey_L; - bindings_[static_cast(Action::TOGGLE_MINIMAP)] = ImGuiKey_M; + bindings_[static_cast(Action::TOGGLE_MINIMAP)] = ImGuiKey_None; // minimap is always visible; no default toggle bindings_[static_cast(Action::TOGGLE_SETTINGS)] = ImGuiKey_Escape; bindings_[static_cast(Action::TOGGLE_CHAT)] = ImGuiKey_Enter; bindings_[static_cast(Action::TOGGLE_GUILD_ROSTER)] = ImGuiKey_O; bindings_[static_cast(Action::TOGGLE_DUNGEON_FINDER)] = ImGuiKey_J; // Originally I, reassigned to avoid conflict - bindings_[static_cast(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_W; + bindings_[static_cast(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) - bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q; + bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_None; // Q conflicts with strafe-left; quest log accessible via TOGGLE_QUESTS (L) bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) } From 367b48af6b6350b85ab9a39cabb3b5e2285c9129 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:29:21 -0700 Subject: [PATCH 34/50] fix: handle short loot-failure response in LootResponseParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers send a 9-byte packet (guid+lootType) with lootType=LOOT_NONE when loot is unavailable (locked chest, another player looting, needs a key). The previous parser required ≥14 bytes (guid+lootType+gold+itemCount) and logged a spurious WARNING for every such failure response. Now: - Accept the 9-byte form; return false so the caller skips opening the loot window (correct behaviour for a failure/empty response). - Log at DEBUG level instead of WARNING for the short form. - Keep the original WARNING for genuinely malformed packets < 9 bytes. --- src/game/world_packets.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index e2fb3772..e183ff16 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3971,13 +3971,27 @@ network::Packet LootReleasePacket::build(uint64_t lootGuid) { bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) { data = LootResponseData{}; - if (packet.getSize() - packet.getReadPos() < 14) { - LOG_WARNING("LootResponseParser: packet too short"); + size_t avail = packet.getSize() - packet.getReadPos(); + + // Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with + // lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened, + // needs a key, or another player is looting). We treat this as an empty-loot + // signal and return false so the caller knows not to open the loot window. + if (avail < 9) { + LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)"); return false; } data.lootGuid = packet.readUInt64(); data.lootType = packet.readUInt8(); + + // Short failure packet — no gold/item data follows. + avail = packet.getSize() - packet.getReadPos(); + if (avail < 5) { + LOG_DEBUG("LootResponseParser: lootType=", (int)data.lootType, " (empty/failure response)"); + return false; + } + data.gold = packet.readUInt32(); uint8_t itemCount = packet.readUInt8(); From f855327054933c43e8bb1310a6c2f934c36038f7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:45:31 -0700 Subject: [PATCH 35/50] fix: eliminate 490ms transport-doodad stall and GPU device-loss crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes identified from wowee.log crash at frame 134368: 1. processPendingTransportDoodads() was doing N separate synchronous GPU uploads (vkQueueSubmit + vkWaitForFences per texture per doodad). With 30+ doodads × multiple textures, this caused the 489ms stall in the 'gameobject/transport queues' update stage. Fixed by wrapping the entire batch in beginUploadBatch()/endUploadBatch() so all texture layout transitions are submitted in a single async command buffer. 2. Game objects whose M2 model has no geometry/particles (empty or unsupported format) were retried every frame because loadModel() returns false without adding to gameObjectDisplayIdModelCache_. Added gameObjectDisplayIdFailedCache_ to permanently skip these display IDs after the first failure, stopping the per-frame spam. 3. renderM2Ribbons() only checked ribbonPipeline_ != null, not ribbonAdditivePipeline_. If additive pipeline creation failed, any ribbon with additive blending would call vkCmdBindPipeline with VK_NULL_HANDLE, causing VK_ERROR_DEVICE_LOST on the GPU side. Extended the early-return guard to cover both ribbon pipelines. --- include/core/application.hpp | 1 + src/core/application.cpp | 18 ++++++++++++++++++ src/rendering/m2_renderer.cpp | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 7da1469b..85339c04 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -271,6 +271,7 @@ private: }; std::unordered_map gameObjectDisplayIdToPath_; std::unordered_map gameObjectDisplayIdModelCache_; // displayId → M2 modelId + std::unordered_set gameObjectDisplayIdFailedCache_; // displayIds that permanently fail to load std::unordered_map gameObjectDisplayIdWmoCache_; // displayId → WMO modelId std::unordered_map gameObjectInstances_; // guid → instance info struct PendingTransportMove { diff --git a/src/core/application.cpp b/src/core/application.cpp index 0feba036..a782363c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -7234,6 +7234,11 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto* m2Renderer = renderer->getM2Renderer(); if (!m2Renderer) return; + // Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s). + // Without this guard the same empty model is re-parsed every frame, causing + // sustained log spam and wasted CPU. + if (gameObjectDisplayIdFailedCache_.count(displayId)) return; + uint32_t modelId = 0; auto itCache = gameObjectDisplayIdModelCache_.find(displayId); if (itCache != gameObjectDisplayIdModelCache_.end()) { @@ -7252,12 +7257,14 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto m2Data = assetManager->readFile(modelPath); if (m2Data.empty()) { LOG_WARNING("Failed to read gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } @@ -7269,6 +7276,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (!m2Renderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load gameobject model: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } @@ -8189,6 +8197,13 @@ void Application::processPendingTransportDoodads() { auto startTime = std::chrono::steady_clock::now(); static constexpr float kDoodadBudgetMs = 4.0f; + // Batch all GPU uploads into a single async command buffer submission so that + // N doodads with multiple textures each don't each block on vkQueueSubmit + + // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds + // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. + auto* vkCtx = renderer->getVkContext(); + if (vkCtx) vkCtx->beginUploadBatch(); + size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; for (auto it = pendingTransportDoodadBatches_.begin(); it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { @@ -8256,6 +8271,9 @@ void Application::processPendingTransportDoodads() { ++it; } } + + // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). + if (vkCtx) vkCtx->endUploadBatch(); } void Application::processPendingMount() { diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 6ef65e5f..c5af6c76 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -3565,7 +3565,7 @@ void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt // Ribbon rendering // --------------------------------------------------------------------------- void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { - if (!ribbonPipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; + if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; // Build camera right vector for billboard orientation // For ribbons we orient the quad strip along the spine with screen-space up. From d58c55ce8d0b3efd8d03d3c3f105d45efef37ab8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:49:22 -0700 Subject: [PATCH 36/50] fix: allow ribbon-only M2 models to load and silence transport doodad load errors Two follow-up fixes for the ribbon emitter implementation and the transport-doodad stall fix: 1. loadModel() rejected any M2 with no vertices AND no particles, but ribbon-only spell-effect models (e.g. weapon trail or aura ribbons) have neither. These models were silently invisible even though the ribbon rendering pipeline added in 1108aa9 is fully capable of rendering them. Extended the guard to also accept models that have ribbon emitters, matching the particle-emitter precedent. 2. processPendingTransportDoodads() ignored the bool return of loadModel(), calling createInstance() even when the model was rejected, generating spurious "Cannot create instance: model X not loaded" warnings for every failed doodad path. Check the return value and continue to the next doodad on failure. --- src/core/application.cpp | 2 +- src/rendering/m2_renderer.cpp | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index a782363c..44a82570 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -8247,7 +8247,7 @@ void Application::processPendingTransportDoodads() { } if (!m2Model.isValid()) continue; - m2Renderer->loadModel(m2Model, doodadModelId); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); if (m2InstanceId == 0) continue; m2Renderer->setSkipCollision(m2InstanceId, true); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index c5af6c76..d7ae0b2a 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -944,8 +944,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); bool hasParticles = !model.particleEmitters.empty(); - if (!hasGeometry && !hasParticles) { - LOG_WARNING("M2 model has no geometry and no particles: ", model.name); + bool hasRibbons = !model.ribbonEmitters.empty(); + if (!hasGeometry && !hasParticles && !hasRibbons) { + LOG_WARNING("M2 model has no renderable content: ", model.name); return false; } From d4bf8c871e7ba7df2be9110f35c60873208c6122 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 01:53:59 -0700 Subject: [PATCH 37/50] fix: clear gameObjectDisplayIdFailedCache_ on world reset and zone change The failed-model cache introduced in f855327 would persist across map changes, permanently suppressing models that failed on one map but might be valid assets on another (or after a client update). Clear it in the world reset path alongside the existing gameObjectDisplayIdModelCache_ clear, so model loads get a fresh attempt on each zone change. --- src/core/application.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index 44a82570..a65f2135 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4113,6 +4113,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float gameObjectInstances_.clear(); gameObjectDisplayIdModelCache_.clear(); + gameObjectDisplayIdFailedCache_.clear(); // Force player character re-spawn on new map playerCharacterSpawned = false; From 862d743f874e37b69240bcad4c6c36344acf0709 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 02:25:06 -0700 Subject: [PATCH 38/50] fix: WMO culling dead-end group fallback and minimap arrow direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wmo_renderer: when portal BFS starts from a group with no portal refs (utility/transition group), the rest of the WMO becomes invisible because BFS only adds the starting group. Fix: if cameraGroup has portalCount==0, fall back to marking all groups visible (same as camera-outside behavior). renderer: minimap player-orientation arrow was pointing the wrong direction. The shader convention is arrowRotation=0→North, positive→clockwise (West), negative→East. The correct mapping from canonical yaw is arrowRotation = -canonical_yaw. Fixed both render paths (cameraController and gameHandler) in both the FXAA and non-FXAA minimap render calls. World-map W-key: already corrected in prior commit (13c096f); users with stale ~/.wowee/settings.cfg should rebind via Settings > Controls. --- src/rendering/renderer.cpp | 24 ++++++++++++------------ src/rendering/wmo_renderer.cpp | 12 ++++++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 05305cc2..9f85c3d5 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -5256,14 +5256,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (cameraController) { float facingRad = glm::radians(characterYaw); glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + // atan2(-x,y) = canonical yaw (0=North); negate for shader convention. + minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - // Server orientation is in WoW space: π/2 = North, 0 = East. - // Minimap arrow expects render space: 0 = North, π/2 = East. - // Convert: minimap_angle = server_orientation - π/2 - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation - - static_cast(M_PI_2); + // movementInfo.orientation is canonical yaw: 0=North, π/2=East. + // Minimap shader: arrowRotation=0 points up (North), positive rotates CW + // (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw. + minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation; hasMinimapPlayerOrientation = true; } minimap->render(cmd, *camera, minimapCenter, @@ -5393,14 +5393,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (cameraController) { float facingRad = glm::radians(characterYaw); glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + // atan2(-x,y) = canonical yaw (0=North); negate for shader convention. + minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - // Server orientation is in WoW space: π/2 = North, 0 = East. - // Minimap arrow expects render space: 0 = North, π/2 = East. - // Convert: minimap_angle = server_orientation - π/2 - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation - - static_cast(M_PI_2); + // movementInfo.orientation is canonical yaw: 0=North, π/2=East. + // Minimap shader: arrowRotation=0 points up (North), positive rotates CW + // (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw. + minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation; hasMinimapPlayerOrientation = true; } minimap->render(currentCmd, *camera, minimapCenter, diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index bc9aa362..4207b24b 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -2063,6 +2063,18 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, return; } + // If the camera group has no portal refs, it's a dead-end group (utility/transition group). + // Fall back to showing all groups to avoid the rest of the WMO going invisible. + if (cameraGroup < static_cast(model.groupPortalRefs.size())) { + auto [portalStart, portalCount] = model.groupPortalRefs[cameraGroup]; + if (portalCount == 0) { + for (size_t gi = 0; gi < model.groups.size(); gi++) { + outVisibleGroups.insert(static_cast(gi)); + } + return; + } + } + // BFS through portals from camera's group std::vector visited(model.groups.size(), false); std::vector queue; From 61adb4a803ddfbee78690f4f126b24db16b57410 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 02:33:02 -0700 Subject: [PATCH 39/50] fix: free terrain descriptor sets when unloading mid-finalization tiles When unloadTile() was called for a tile still in finalizingTiles_ (mid-incremental-finalization), terrain chunks already uploaded to the GPU (terrainMeshDone=true) were not being cleaned up. The early-return path correctly removed water and M2/WMO instances but missed calling terrainRenderer->removeTile(), causing descriptor sets to leak. After ~20 minutes of play the VkDescriptorPool (MAX_MATERIAL_SETS=16384) filled up, causing all subsequent terrain material allocations to fail and the log to flood with "failed to allocate material descriptor set". Fix: check fit->terrainMeshDone before the early return and call terrainRenderer->removeTile() to free those descriptor sets. --- src/rendering/terrain_manager.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 579a909a..340b242d 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -1377,6 +1377,10 @@ void TerrainManager::unloadTile(int x, int y) { // Water may have already been loaded in TERRAIN phase, so clean it up. for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) { if (fit->pending && fit->pending->coord == coord) { + // If terrain chunks were already uploaded, free their descriptor sets + if (fit->terrainMeshDone && terrainRenderer) { + terrainRenderer->removeTile(x, y); + } // If past TERRAIN phase, water was already loaded — remove it if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) { waterRenderer->removeTile(x, y); From 1cd8e53b2f17d3c33ab69b6afc2658440691eeb5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 02:38:53 -0700 Subject: [PATCH 40/50] fix: handle SPLINEFLAG_ANIMATION in UPDATE_OBJECT legacy spline layout When SPLINEFLAG_ANIMATION (0x00400000) is set, AzerothCore inserts 5 bytes (uint8 animationType + int32 animTime) between durationModNext and verticalAccel in the SMSG_UPDATE_OBJECT MoveSpline block. The parser was not accounting for these bytes, causing verticalAccel, effectStartTime, and pointCount to be read from the wrong offset. This produced garbage pointCount values (e.g. 3322451254) triggering the "Spline pointCount invalid (legacy+compact)" fallback path and breaking UPDATE_OBJECT parsing for animated-spline entities, causing all subsequent update blocks in the same packet to be dropped. --- src/game/world_packets.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index e183ff16..48ac2cfe 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1031,6 +1031,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Legacy UPDATE_OBJECT spline layout used by many servers: // timePassed, duration, splineId, durationMod, durationModNext, + // [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)], // verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint. const size_t legacyStart = packet.getReadPos(); if (!bytesAvailable(12 + 8 + 8 + 4)) return false; @@ -1039,6 +1040,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*uint32_t splineId =*/ packet.readUInt32(); /*float durationMod =*/ packet.readFloat(); /*float durationModNext =*/ packet.readFloat(); + // Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel + if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION + if (!bytesAvailable(5)) return false; + packet.readUInt8(); // animationType + packet.readUInt32(); // animTime + } /*float verticalAccel =*/ packet.readFloat(); /*uint32_t effectStartTime =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); From 27213c1d400596a607e2655769d69c372d8e8d7c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 02:47:40 -0700 Subject: [PATCH 41/50] fix: robust SMSG_ATTACKERSTATEUPDATE parsing for WotLK format Two issues in the WotLK SMSG_ATTACKERSTATEUPDATE parser: 1. subDamageCount could read a school-mask byte when a packed GUID is off by one byte, producing values like 32/40/44/48 (shadow/frost/etc school masks) as the count. The parser then tried to read 32-48 sub-damages before hitting EOF. Fix: silently clamp subDamageCount to floor(remaining/20) so we only attempt entries that actually fit. 2. After sub-damages, AzerothCore sends victimState(4)+unk1(4)+unk2(4)+ overkill(4) (16 bytes), not the 8-byte victimState+overkill the parser was reading. Fix: consume unk1 and unk2 before reading overkill. Also handle the hitInfo-conditional HITINFO_BLOCK/RAGE_GAIN/FAKE_DAMAGE fields at the end of the packet. --- src/game/world_packets.cpp | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 48ac2cfe..14dc7a20 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3241,17 +3241,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); - // Cap subDamageCount to prevent OOM (each entry is 20 bytes: 4+4+4+4+4) - if (data.subDamageCount > 64) { - LOG_WARNING("AttackerStateUpdate: subDamageCount capped (requested=", (int)data.subDamageCount, ")"); - data.subDamageCount = 64; + // Cap subDamageCount: each entry is 20 bytes. If the claimed count + // exceeds what the remaining bytes can hold, a GUID was mis-parsed + // (off by one byte), causing the school-mask byte to be read as count. + // In that case silently clamp to the number of full entries that fit. + { + size_t remaining = packet.getSize() - packet.getReadPos(); + size_t maxFit = remaining / 20; + if (data.subDamageCount > maxFit) { + data.subDamageCount = static_cast(maxFit > 0 ? 1 : 0); + } else if (data.subDamageCount > 64) { + data.subDamageCount = 64; + } } + if (data.subDamageCount == 0) return false; data.subDamages.reserve(data.subDamageCount); for (uint8_t i = 0; i < data.subDamageCount; ++i) { // Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4) if (packet.getSize() - packet.getReadPos() < 20) { - LOG_WARNING("AttackerStateUpdate: truncated subDamage at index ", (int)i, "/", (int)data.subDamageCount); data.subDamageCount = i; break; } @@ -3266,21 +3274,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda // Validate victimState + overkill fields (8 bytes) if (packet.getSize() - packet.getReadPos() < 8) { - LOG_WARNING("AttackerStateUpdate: truncated victimState/overkill"); data.victimState = 0; data.overkill = 0; return !data.subDamages.empty(); } data.victimState = packet.readUInt32(); - data.overkill = static_cast(packet.readUInt32()); + // WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill. + // Older parsers omitted these, reading overkill from the wrong offset. + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() >= 4) packet.readUInt32(); // unk1 (always 0) + if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack) + data.overkill = (rem() >= 4) ? static_cast(packet.readUInt32()) : -1; - // Read blocked amount (optional, 4 bytes) - if (packet.getSize() - packet.getReadPos() >= 4) { - data.blocked = packet.readUInt32(); - } else { - data.blocked = 0; - } + // hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40) + if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32(); + else data.blocked = 0; + // RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip + if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain + if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", data.isCrit() ? " (CRIT)" : "", From 8f3f1b21af5e9fa6d59f21fa6fb17c1f5e4d72da Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 02:58:42 -0700 Subject: [PATCH 42/50] fix: check vertices before skin load so WotLK (v264) character M2s parse correctly TBC races like Draenei use version-264 M2 files with no embedded skin; indices come from a separate .skin file loaded after M2::load(). The premature isValid() check (which requires non-empty indices) always failed for WotLK-format character models, making Draenei (and Blood Elf) players invisible. Fix: only check vertices.empty() right after load(), then validate fully with isValid() after the skin file is loaded. --- src/core/application.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index a65f2135..79bc6c7f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -6612,7 +6612,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (!model.isValid() || model.vertices.empty()) { + if (model.vertices.empty()) { LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); return; } @@ -6624,6 +6624,12 @@ void Application::spawnOnlinePlayer(uint64_t guid, pipeline::M2Loader::loadSkin(skinData, model); } + // After skin loading, full model must be valid (vertices + indices) + if (!model.isValid()) { + LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); + return; + } + // Load only core external animations (stand/walk/run) to avoid stalls for (uint32_t si = 0; si < model.sequences.size(); si++) { if (!(model.sequences[si].flags & 0x20)) { From 64439673ce323722a8d184d4b352644b895ed2c7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 03:06:45 -0700 Subject: [PATCH 43/50] fix: show repair button when vendor has NPC_FLAG_REPAIR (0x40) set Vendors that open directly (without gossip menu) never triggered the armorer gossip path, so canRepair was always false and the Repair button was hidden. Now also check the NPC's unit flags for NPC_FLAG_REPAIR when the vendor list arrives, fixing armorers accessed directly. --- src/game/game_handler.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index eee30296..6324e391 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18741,8 +18741,20 @@ void GameHandler::handleGossipComplete(network::Packet& packet) { } void GameHandler::handleListInventory(network::Packet& packet) { - bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set before openVendor() + bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path if (!ListInventoryParser::parse(packet, currentVendorItems)) return; + + // Check NPC_FLAG_REPAIR (0x40) on the vendor entity — this handles vendors that open + // directly without going through the gossip armorer option. + if (!savedCanRepair && currentVendorItems.vendorGuid != 0) { + auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); + if (entity && entity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (unit->getNpcFlags() & 0x40) { // NPC_FLAG_REPAIR + savedCanRepair = true; + } + } + } currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens From ebd9cf55429e2da465a4993fc6ee3a14e5715af4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 03:13:29 -0700 Subject: [PATCH 44/50] fix: handle MSG_MOVE_SET_*_SPEED opcodes to suppress unhandled opcode warnings --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e0baba9e..3481285b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2199,6 +2199,7 @@ private: // ---- Other player movement (MSG_MOVE_* from server) ---- void handleOtherPlayerMovement(network::Packet& packet); + void handleMoveSetSpeed(network::Packet& packet); // ---- Phase 5 handlers ---- void handleLootResponse(network::Packet& packet); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6324e391..17f845e7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5338,6 +5338,22 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + // ---- Broadcast speed changes (server→client, no ACK) ---- + // Format: PackedGuid (mover) + MovementInfo (variable) + float speed + // MovementInfo is complex (optional transport/fall/spline blocks based on flags). + // We consume the packet to suppress "Unhandled world opcode" warnings. + case Opcode::MSG_MOVE_SET_RUN_SPEED: + case Opcode::MSG_MOVE_SET_RUN_BACK_SPEED: + case Opcode::MSG_MOVE_SET_WALK_SPEED: + case Opcode::MSG_MOVE_SET_SWIM_SPEED: + case Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED: + case Opcode::MSG_MOVE_SET_FLIGHT_SPEED: + case Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED: + if (state == WorldState::IN_WORLD) { + handleMoveSetSpeed(packet); + } + break; + // ---- Mail ---- case Opcode::SMSG_SHOW_MAILBOX: handleShowMailbox(packet); @@ -15108,6 +15124,40 @@ void GameHandler::handlePvpLogData(network::Packet& packet) { } } +void GameHandler::handleMoveSetSpeed(network::Packet& packet) { + // MSG_MOVE_SET_*_SPEED: PackedGuid (WotLK) / full uint64 (Classic/TBC) + MovementInfo + float speed. + // The MovementInfo block is variable-length; rather than fully parsing it, we read the + // fixed prefix, skip over optional blocks by consuming remaining bytes until 4 remain, + // then read the speed float. This is safe because the speed is always the last field. + const bool useFull = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t moverGuid = useFull + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + + // Skip to the last 4 bytes — the speed float — by advancing past the MovementInfo. + // This avoids duplicating the full variable-length MovementInfo parser here. + const size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4) return; + if (remaining > 4) { + // Advance past all MovementInfo bytes (flags, time, position, optional blocks). + // Speed is always the last 4 bytes in the packet. + packet.setReadPos(packet.getSize() - 4); + } + + float speed = packet.readFloat(); + if (!std::isfinite(speed) || speed <= 0.01f || speed > 200.0f) return; + + // Update local player speed state if this broadcast targets us. + if (moverGuid != playerGuid) return; + const uint16_t wireOp = packet.getOpcode(); + if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_SPEED)) serverRunSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_BACK_SPEED)) serverRunBackSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_WALK_SPEED)) serverWalkSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_SPEED)) serverSwimSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED)) serverSwimBackSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_SPEED)) serverFlightSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED))serverFlightBackSpeed_= speed; +} + void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); From 952f36b73238bdbe4009d1ae2b20668c6d3bcf8e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 03:19:05 -0700 Subject: [PATCH 45/50] =?UTF-8?q?fix:=20correct=20minimap=20player=20arrow?= =?UTF-8?q?=20orientation=20(was=2090=C2=B0=20off,=20E/W=20appeared=20flip?= =?UTF-8?q?ped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/game_screen.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4da386cb..aad87838 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15555,7 +15555,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sinB = 0.0f; if (minimap->isRotateWithCamera()) { glm::vec3 fwd = camera->getForward(); - bearing = std::atan2(-fwd.x, fwd.y); + // Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)). + // Clockwise bearing from North: atan2(fwd.y, -fwd.x). + bearing = std::atan2(fwd.y, -fwd.x); cosB = std::cos(bearing); sinB = std::sin(bearing); } @@ -15593,7 +15595,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { // The player is always at centerX, centerY on the minimap. // Draw a yellow arrow pointing in the player's facing direction. glm::vec3 fwd = camera->getForward(); - float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north + float facing = std::atan2(fwd.y, -fwd.x); // clockwise bearing from North float cosF = std::cos(facing - bearing); float sinF = std::sin(facing - bearing); float arrowLen = 8.0f; From 863faf9b544041276b515c0de3f89213bfb91e1f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 03:32:45 -0700 Subject: [PATCH 46/50] =?UTF-8?q?fix:=20correct=20talent=20rank=20indexing?= =?UTF-8?q?=20=E2=80=94=20store=201-indexed,=20fix=20prereq=20and=20learn?= =?UTF-8?q?=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_TALENTS_INFO wire format sends 0-indexed ranks (0=has rank 1). Both handlers were storing raw 0-indexed values, but handleSpellLearnedServer correctly stored rank+1 (1-indexed). This caused: - getTalentRank() returning 0 for both "not learned" and "has rank 1", making pointsInTree always wrong and blocking tier access - Prereq check `prereqRank < DBC_prereqRank` always met when not learned (0 < 0 = false), incorrectly unlocking talents - Click handler sending wrong desiredRank to server Fixes: - Both SMSG_TALENTS_INFO handlers: store rank+1u (1-indexed) - talent_screen.cpp prereq check: change < to <= (DBC is 0-indexed, storage is 1-indexed; must use > for "met", <= for "not met") - talent_screen.cpp click handler: send currentRank directly (1-indexed value equals what CMSG_LEARN_TALENT requestedRank expects) - Tooltip: display prereqRank+1 so "Requires 1 point" shows correctly --- src/game/game_handler.cpp | 4 ++-- src/ui/talent_screen.cpp | 23 +++++++++-------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 17f845e7..2621d11a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12701,7 +12701,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t talentId = packet.readUInt32(); uint8_t rank = packet.readUInt8(); - learnedTalents_[g][talentId] = rank; + learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed } if (packet.getSize() - packet.getReadPos() < 1) break; learnedGlyphs_[g].fill(0); @@ -16545,7 +16545,7 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t talentId = packet.readUInt32(); uint8_t rank = packet.readUInt8(); - learnedTalents_[g][talentId] = rank; + learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed } learnedGlyphs_[g].fill(0); if (packet.getSize() - packet.getReadPos() < 1) break; diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index e0598ad2..bed817ab 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -303,7 +303,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue; uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]); - bool met = prereqRank >= talent->prereqRank[i]; + bool met = prereqRank > talent->prereqRank[i]; // storage 1-indexed, DBC 0-indexed ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150); ImVec2 from = fromIt->second.center; @@ -374,7 +374,7 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, for (int i = 0; i < 3; ++i) { if (talent.prereqTalent[i] != 0) { uint8_t prereqRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - if (prereqRank < talent.prereqRank[i]) { + if (prereqRank <= talent.prereqRank[i]) { // storage 1-indexed, DBC 0-indexed prereqsMet = false; canLearn = false; break; @@ -541,14 +541,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, if (!prereq || prereq->rankSpells[0] == 0) continue; uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - bool met = prereqCurrentRank >= talent.prereqRank[i]; + bool met = prereqCurrentRank > talent.prereqRank[i]; // storage 1-indexed, DBC 0-indexed ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1); const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]); ImGui::Spacing(); + const uint8_t reqRankDisplay = talent.prereqRank[i] + 1u; // DBC 0-indexed → display 1-indexed ImGui::TextColored(pColor, "Requires %u point%s in %s", - talent.prereqRank[i], - talent.prereqRank[i] > 1 ? "s" : "", + reqRankDisplay, + reqRankDisplay > 1 ? "s" : "", prereqName.empty() ? "prerequisite" : prereqName.c_str()); } @@ -573,16 +574,10 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, ImGui::EndTooltip(); } - // Handle click + // Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...) + // CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value) if (clicked && canLearn && prereqsMet) { - const auto& learned = gameHandler.getLearnedTalents(); - uint8_t desiredRank; - if (learned.find(talent.talentId) == learned.end()) { - desiredRank = 0; // First rank (0-indexed on wire) - } else { - desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn - } - gameHandler.learnTalent(talent.talentId, desiredRank); + gameHandler.learnTalent(talent.talentId, currentRank); } ImGui::PopID(); From 0487d2eda6790bf6a62aafa68669842003bea70f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 03:41:42 -0700 Subject: [PATCH 47/50] fix: check loadModel return before createInstance for WMO doodads When the M2 model cache is full (>6000 entries), loadModel() returns false and the model is never added to the GPU cache. The WMO instance doodad path was calling createInstanceWithMatrix() unconditionally, generating hundreds of "Cannot create instance: model X not loaded" warnings on zone entry. Add the same guard already present in the terrain doodad path. --- src/core/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 79bc6c7f..6c74e854 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4464,7 +4464,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float glm::vec3 worldPos = glm::vec3(worldMatrix[3]); uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); - m2Renderer->loadModel(m2Model, doodadModelId); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos); if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true); loadedDoodads++; From 85767187b1aef3686a921d059a967c193ab744dc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 03:43:55 -0700 Subject: [PATCH 48/50] fix: clear gameObjectDisplayIdWmoCache_ on world transition, add stale-entry guard gameObjectDisplayIdWmoCache_ was not cleared on world unload/transition, causing stale WMO model IDs (e.g. 40006, 40003) to be looked up after the renderer cleared its model list, resulting in "Cannot create instance of unloaded WMO model" errors on zone re-entry. Changes: - Clear gameObjectDisplayIdWmoCache_ alongside other GO caches on world reset - Add WMORenderer::isModelLoaded() for cache-hit validation - Inline GO WMO path now verifies cached model is still renderer-resident before using it; evicts stale entries and falls back to reload --- include/rendering/wmo_renderer.hpp | 6 ++++++ src/core/application.cpp | 12 ++++++++++-- src/rendering/wmo_renderer.cpp | 4 ++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 08108dc0..7f6728af 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -69,6 +69,12 @@ public: */ bool loadModel(const pipeline::WMOModel& model, uint32_t id); + /** + * Check if a WMO model is currently resident in the renderer + * @param id WMO model identifier + */ + bool isModelLoaded(uint32_t id) const; + /** * Unload WMO model and free GPU resources * @param id WMO model identifier diff --git a/src/core/application.cpp b/src/core/application.cpp index 6c74e854..83886782 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4113,6 +4113,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float gameObjectInstances_.clear(); gameObjectDisplayIdModelCache_.clear(); + gameObjectDisplayIdWmoCache_.clear(); gameObjectDisplayIdFailedCache_.clear(); // Force player character re-spawn on new map @@ -7115,8 +7116,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); if (itCache != gameObjectDisplayIdWmoCache_.end()) { modelId = itCache->second; - loadedAsWmo = true; - } else { + // Only use cached entry if the model is still resident in the renderer + if (wmoRenderer->isModelLoaded(modelId)) { + loadedAsWmo = true; + } else { + gameObjectDisplayIdWmoCache_.erase(itCache); + modelId = 0; + } + } + if (!loadedAsWmo && modelId == 0) { auto wmoData = assetManager->readFile(modelPath); if (!wmoData.empty()) { pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 4207b24b..7210d1be 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -805,6 +805,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { return true; } +bool WMORenderer::isModelLoaded(uint32_t id) const { + return loadedModels.find(id) != loadedModels.end(); +} + void WMORenderer::unloadModel(uint32_t id) { auto it = loadedModels.find(id); if (it == loadedModels.end()) { From 499638142ee5fcc33b0a072a97be2cc5bb36df20 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 04:04:29 -0700 Subject: [PATCH 49/50] feat: make quest tracker movable, resizable, and right-edge-anchored - Remove NoDecoration flag to allow ImGui drag/resize - Store questTrackerRightOffset_ instead of absolute X so tracker stays pinned to the right edge when the window is resized - Persist position (right offset + Y) and size in settings.cfg - Clamp to screen bounds after drag --- include/ui/game_screen.hpp | 2 ++ src/ui/game_screen.cpp | 64 ++++++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 12ce23a1..e8fbca0f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -158,6 +158,8 @@ private: ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default + ImVec2 questTrackerSize_ = ImVec2(220.0f, 200.0f); // saved size + float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default bool questTrackerPosInit_ = false; bool showEscapeMenu = false; bool showEscapeSettingsNotice = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index aad87838..92dccefe 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7911,18 +7911,25 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; - // Default position: top-right, below minimap + buff bar space - if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) { - questTrackerPos_ = ImVec2(screenW - TRACKER_W - RIGHT_MARGIN, 320.0f); + // Default position: top-right, below minimap + buff bar space. + // questTrackerRightOffset_ stores pixels from the right edge so the tracker + // stays anchored to the right side when the window is resized. + if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) { + questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned + questTrackerPos_.y = 320.0f; questTrackerPosInit_ = true; } + // Recompute X from right offset every frame (handles window resize) + questTrackerPos_.x = screenW - questTrackerRightOffset_; ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); + ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; + ImGuiWindowFlags_NoBringToFrontOnFocus; ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); @@ -7938,7 +7945,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); ImGui::PushStyleColor(ImGuiCol_Text, titleCol); if (ImGui::Selectable(q.title.c_str(), false, - ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) { + ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) { questLogScreen.openAndSelectQuest(q.questId); } if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { @@ -8061,15 +8068,28 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } } - // Capture position after drag - ImVec2 newPos = ImGui::GetWindowPos(); + // Capture position and size after drag/resize + ImVec2 newPos = ImGui::GetWindowPos(); + ImVec2 newSize = ImGui::GetWindowSize(); + bool changed = false; + + // Clamp within screen + newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { - newPos.x = std::clamp(newPos.x, 0.0f, screenW - TRACKER_W); - newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); questTrackerPos_ = newPos; - saveSettings(); + // Update right offset so resizes keep the new position anchored + questTrackerRightOffset_ = screenW - newPos.x; + changed = true; } + if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f || + std::abs(newSize.y - questTrackerSize_.y) > 0.5f) { + questTrackerSize_ = newSize; + changed = true; + } + if (changed) saveSettings(); } ImGui::End(); @@ -16960,9 +16980,11 @@ void GameScreen::saveSettings() { out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; out << "fov=" << pendingFov << "\n"; - // Quest tracker position - out << "quest_tracker_x=" << questTrackerPos_.x << "\n"; + // Quest tracker position/size + out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n"; out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; + out << "quest_tracker_w=" << questTrackerSize_.x << "\n"; + out << "quest_tracker_h=" << questTrackerSize_.y << "\n"; // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; @@ -17108,15 +17130,25 @@ void GameScreen::loadSettings() { if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); } } - // Quest tracker position + // Quest tracker position/size else if (key == "quest_tracker_x") { - questTrackerPos_.x = std::stof(val); + // Legacy: ignore absolute X (right_offset supersedes it) + (void)val; + } + else if (key == "quest_tracker_right_offset") { + questTrackerRightOffset_ = std::stof(val); questTrackerPosInit_ = true; } else if (key == "quest_tracker_y") { questTrackerPos_.y = std::stof(val); questTrackerPosInit_ = true; } + else if (key == "quest_tracker_w") { + questTrackerSize_.x = std::max(100.0f, std::stof(val)); + } + else if (key == "quest_tracker_h") { + questTrackerSize_.y = std::max(60.0f, std::stof(val)); + } // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); From 8f08d75748c7c3a316c294108a36f559a9af05ac Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Mar 2026 04:04:38 -0700 Subject: [PATCH 50/50] fix: cache player death position so corpse reclaim works in Classic Classic 1.12 does not send SMSG_DEATH_RELEASE_LOC, leaving corpseMapId_=0 and preventing the 'Resurrect from Corpse' button from appearing. - When health reaches 0 via VALUES update, immediately cache movementInfo as corpse position (canonical->server axis swap applied correctly) - Do the same on UNIT_DYNFLAG_DEAD set path - Clear corpseMapId_ when ghost flag is removed (corpse reclaimed) - Clear corpseMapId_ in same-map spirit-healer resurrection path The CORPSE object detection (UPDATE_OBJECT) and SMSG_DEATH_RELEASE_LOC (TBC/WotLK) will still override with exact server coordinates when received. --- src/game/game_handler.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2621d11a..90fa6a12 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10266,7 +10266,18 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerDead_ = true; releasedSpirit_ = false; stopAutoAttack(); - LOG_INFO("Player died!"); + // Cache death position as corpse location. + // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so + // this is the primary source for canReclaimCorpse(). + // movementInfo is canonical (x=north, y=west); corpseX_/Y_ + // are raw server coords (x=west, y=north) — swap axes. + corpseX_ = movementInfo.y; // canonical west = server X + corpseY_ = movementInfo.x; // canonical north = server Y + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died! Corpse position cached at server=(", + corpseX_, ",", corpseY_, ",", corpseZ_, + ") map=", corpseMapId_); } if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { npcDeathCallback_(block.guid); @@ -10301,7 +10312,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (!wasDead && nowDead) { playerDead_ = true; releasedSpirit_ = false; - LOG_INFO("Player died (dynamic flags)"); + corpseX_ = movementInfo.y; + corpseY_ = movementInfo.x; + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); } else if (wasDead && !nowDead) { playerDead_ = false; releasedSpirit_ = false; @@ -10575,6 +10590,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerDead_ = false; repopPending_ = false; resurrectPending_ = false; + corpseMapId_ = 0; // corpse reclaimed LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (ghostStateCallback_) ghostStateCallback_(false); } @@ -19390,6 +19406,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; + corpseMapId_ = 0; hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true;