From f88d90ee889702cd9a5ea367e277d4bcf2984372 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 04:36:30 -0700 Subject: [PATCH 01/26] feat: track and display honor/arena points from update fields Add PLAYER_FIELD_HONOR_CURRENCY and PLAYER_FIELD_ARENA_CURRENCY to the update field system for WotLK (indices 1422/1423) and TBC (1505/1506). Parse values from both CREATE_OBJECT and VALUES update paths, and show them in the character Stats tab under a PvP Currency section. --- Data/expansions/tbc/update_fields.json | 2 ++ Data/expansions/wotlk/update_fields.json | 2 ++ include/game/game_handler.hpp | 6 ++++++ include/game/update_field_table.hpp | 4 ++++ src/game/game_handler.cpp | 20 ++++++++++++++++++++ src/ui/inventory_screen.cpp | 16 ++++++++++++++++ 6 files changed, 50 insertions(+) diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index 05e37180..fa443aa1 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -37,6 +37,8 @@ "PLAYER_FIELD_BANKBAG_SLOT_1": 784, "PLAYER_SKILL_INFO_START": 928, "PLAYER_EXPLORED_ZONES_START": 1312, + "PLAYER_FIELD_HONOR_CURRENCY": 1505, + "PLAYER_FIELD_ARENA_CURRENCY": 1506, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_DURABILITY": 60, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 1628b94c..7b5e12e8 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -49,6 +49,8 @@ "PLAYER_RANGED_CRIT_PERCENTAGE": 1030, "PLAYER_SPELL_CRIT_PERCENTAGE1": 1032, "PLAYER_FIELD_COMBAT_RATING_1": 1231, + "PLAYER_FIELD_HONOR_CURRENCY": 1422, + "PLAYER_FIELD_ARENA_CURRENCY": 1423, "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 569261b2..cb34d462 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -299,6 +299,10 @@ public: // Money (copper) uint64_t getMoneyCopper() const { return playerMoneyCopper_; } + // PvP currency (TBC/WotLK only) + uint32_t getHonorPoints() const { return playerHonorPoints_; } + uint32_t getArenaPoints() const { return playerArenaPoints_; } + // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } @@ -3067,6 +3071,8 @@ private: float pendingLootMoneyNotifyTimer_ = 0.0f; std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; + uint32_t playerHonorPoints_ = 0; + uint32_t playerArenaPoints_ = 0; int32_t playerArmorRating_ = 0; int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index e4687352..4cc2a44a 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -77,6 +77,10 @@ enum class UF : uint16_t { PLAYER_SPELL_CRIT_PERCENTAGE1, // Spell crit chance % (first school; 7 consecutive float fields) PLAYER_FIELD_COMBAT_RATING_1, // First of 25 int32 combat rating slots (CR_* indices) + // Player PvP currency (TBC/WotLK only — Classic uses the old weekly honor system) + PLAYER_FIELD_HONOR_CURRENCY, // Accumulated honor points (uint32) + PLAYER_FIELD_ARENA_CURRENCY, // Accumulated arena points (uint32) + // GameObject fields GAMEOBJECT_DISPLAYID, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 16666085..89002564 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11781,6 +11781,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); + const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); 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); @@ -11814,6 +11816,14 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem playerMoneyCopper_ = val; LOG_DEBUG("Money set from update fields: ", val, " copper"); } + else if (ufHonor != 0xFFFF && key == ufHonor) { + playerHonorPoints_ = val; + LOG_DEBUG("Honor points from update fields: ", val); + } + else if (ufArena != 0xFFFF && key == ufArena) { + playerArenaPoints_ = val; + LOG_DEBUG("Arena points from update fields: ", val); + } else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); @@ -12207,6 +12217,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); + const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES); @@ -12254,6 +12266,14 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem playerMoneyCopper_ = val; LOG_DEBUG("Money updated via VALUES: ", val, " copper"); } + else if (ufHonorV != 0xFFFF && key == ufHonorV) { + playerHonorPoints_ = val; + LOG_DEBUG("Honor points updated: ", val); + } + else if (ufArenaV != 0xFFFF && key == ufArenaV) { + playerArenaPoints_ = val; + LOG_DEBUG("Arena points updated: ", val); + } else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 366e9fa0..bee298c9 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1249,6 +1249,22 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn(); ImGui::Columns(1); } + + // PvP Currency (TBC/WotLK only) + uint32_t honor = gameHandler.getHonorPoints(); + uint32_t arena = gameHandler.getArenaPoints(); + if (honor > 0 || arena > 0) { + ImGui::Separator(); + ImGui::TextDisabled("PvP Currency"); + ImGui::Columns(2, "##pvpcurrency", false); + ImGui::SetColumnWidth(0, 130); + ImGui::Text("Honor Points:"); ImGui::NextColumn(); + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.2f, 1.0f), "%u", honor); ImGui::NextColumn(); + ImGui::Text("Arena Points:"); ImGui::NextColumn(); + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.2f, 1.0f), "%u", arena); ImGui::NextColumn(); + ImGui::Columns(1); + } + ImGui::EndTabItem(); } From ae56f2eb8000d93bfa5063b6ca3e62c197256288 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 04:43:46 -0700 Subject: [PATCH 02/26] feat: implement equipment set save, update, and delete Add saveEquipmentSet() and deleteEquipmentSet() methods that send CMSG_EQUIPMENT_SET_SAVE and CMSG_DELETEEQUIPMENT_SET packets. The save packet captures all 19 equipment slot GUIDs via packed GUID encoding. The Outfits tab now always shows (not just when sets exist), with an input field to create new sets and Update/Delete buttons per set. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 43 ++++++++++++++++++++++++++++++ src/ui/inventory_screen.cpp | 50 +++++++++++++++++++++++++---------- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index cb34d462..67e88844 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1535,6 +1535,9 @@ public: }; const std::vector& getEquipmentSets() const { return equipmentSetInfo_; } void useEquipmentSet(uint32_t setId); + void saveEquipmentSet(const std::string& name, const std::string& iconName = "INV_Misc_QuestionMark", + uint64_t existingGuid = 0, uint32_t setIndex = 0xFFFFFFFF); + void deleteEquipmentSet(uint64_t setGuid); // NPC Gossip void interactWithNpc(uint64_t guid); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 89002564..e542dbca 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10668,6 +10668,49 @@ void GameHandler::useEquipmentSet(uint32_t setId) { socket->send(pkt); } +void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, + uint64_t existingGuid, uint32_t setIndex) { + if (state != WorldState::IN_WORLD) return; + // CMSG_EQUIPMENT_SET_SAVE: uint64 setGuid + uint32 setIndex + string name + string iconName + // + 19 × PackedGuid itemGuid (one per equipment slot, 0–18) + if (setIndex == 0xFFFFFFFF) { + // Auto-assign next free index + setIndex = 0; + for (const auto& es : equipmentSets_) { + if (es.setId >= setIndex) setIndex = es.setId + 1; + } + } + network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE)); + pkt.writeUInt64(existingGuid); // 0 = create new, nonzero = update + pkt.writeUInt32(setIndex); + pkt.writeString(name); + pkt.writeString(iconName); + for (int slot = 0; slot < 19; ++slot) { + uint64_t guid = getEquipSlotGuid(slot); + MovementPacket::writePackedGuid(pkt, guid); + } + socket->send(pkt); + LOG_INFO("CMSG_EQUIPMENT_SET_SAVE: name=\"", name, "\" guid=", existingGuid, " index=", setIndex); +} + +void GameHandler::deleteEquipmentSet(uint64_t setGuid) { + if (state != WorldState::IN_WORLD || setGuid == 0) return; + // CMSG_DELETEEQUIPMENT_SET: uint64 setGuid + network::Packet pkt(wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET)); + pkt.writeUInt64(setGuid); + socket->send(pkt); + // Remove locally so UI updates immediately + equipmentSets_.erase( + std::remove_if(equipmentSets_.begin(), equipmentSets_.end(), + [setGuid](const EquipmentSet& es) { return es.setGuid == setGuid; }), + equipmentSets_.end()); + equipmentSetInfo_.erase( + std::remove_if(equipmentSetInfo_.begin(), equipmentSetInfo_.end(), + [setGuid](const EquipmentSetInfo& es) { return es.setGuid == setGuid; }), + equipmentSetInfo_.end()); + LOG_INFO("CMSG_DELETEEQUIPMENT_SET: guid=", setGuid); +} + void GameHandler::sendMinimapPing(float wowX, float wowY) { if (state != WorldState::IN_WORLD) return; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index bee298c9..3597dc68 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1438,32 +1438,54 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } - // Equipment Sets tab (WotLK only) - const auto& eqSets = gameHandler.getEquipmentSets(); - if (!eqSets.empty()) { - if (ImGui::BeginTabItem("Outfits")) { - ImGui::Spacing(); - ImGui::TextDisabled("Saved Equipment Sets"); - ImGui::Separator(); + // Equipment Sets tab (WotLK — always show so player can create sets) + if (ImGui::BeginTabItem("Outfits")) { + ImGui::Spacing(); + + // Save current gear as new set + static char newSetName[64] = {}; + ImGui::SetNextItemWidth(160.0f); + ImGui::InputTextWithHint("##newsetname", "New set name...", newSetName, sizeof(newSetName)); + ImGui::SameLine(); + bool canSave = (newSetName[0] != '\0'); + if (!canSave) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Save Current Gear")) { + gameHandler.saveEquipmentSet(newSetName); + newSetName[0] = '\0'; + } + if (!canSave) ImGui::EndDisabled(); + + ImGui::Separator(); + + const auto& eqSets = gameHandler.getEquipmentSets(); + if (eqSets.empty()) { + ImGui::TextDisabled("No saved equipment sets."); + } else { ImGui::BeginChild("##EqSetsList", ImVec2(0, 0), false); for (const auto& es : eqSets) { ImGui::PushID(static_cast(es.setId)); - // Icon placeholder or name const char* displayName = es.name.empty() ? "(Unnamed)" : es.name.c_str(); ImGui::Text("%s", displayName); - if (!es.iconName.empty()) { - ImGui::SameLine(); - ImGui::TextDisabled("(%s)", es.iconName.c_str()); - } - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); + float btnAreaW = 150.0f; + ImGui::SameLine(ImGui::GetContentRegionAvail().x - btnAreaW + ImGui::GetCursorPosX()); if (ImGui::SmallButton("Equip")) { gameHandler.useEquipmentSet(es.setId); } + ImGui::SameLine(); + if (ImGui::SmallButton("Update")) { + gameHandler.saveEquipmentSet(es.name, es.iconName, es.setGuid, es.setId); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Delete")) { + gameHandler.deleteEquipmentSet(es.setGuid); + ImGui::PopID(); + break; // Iterator invalidated + } ImGui::PopID(); } ImGui::EndChild(); - ImGui::EndTabItem(); } + ImGui::EndTabItem(); } ImGui::EndTabBar(); From f4d705738bd93a2300585a80101cff81801d6d90 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 04:50:49 -0700 Subject: [PATCH 03/26] fix: send CMSG_SET_WATCHED_FACTION when tracking a reputation setWatchedFactionId() previously only stored the faction locally. Now it also sends CMSG_SET_WATCHED_FACTION with the correct repListId to the server, so the tracked faction persists across sessions. --- include/game/game_handler.hpp | 2 +- src/game/game_handler.cpp | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 67e88844..24cbd3e6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1800,7 +1800,7 @@ public: const std::string& getFactionNamePublic(uint32_t factionId) const; uint32_t getWatchedFactionId() const { return watchedFactionId_; } - void setWatchedFactionId(uint32_t id) { watchedFactionId_ = id; } + void setWatchedFactionId(uint32_t factionId); uint32_t getLastContactListMask() const { return lastContactListMask_; } uint32_t getLastContactListCount() const { return lastContactListCount_; } bool isServerMovementAllowed() const { return serverMovementAllowed_; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e542dbca..65e9350b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -25737,6 +25737,21 @@ uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const { return (it != factionIdToRepList_.end()) ? it->second : 0xFFFFFFFFu; } +void GameHandler::setWatchedFactionId(uint32_t factionId) { + watchedFactionId_ = factionId; + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_SET_WATCHED_FACTION: int32 repListId (-1 = unwatch) + int32_t repListId = -1; + if (factionId != 0) { + uint32_t rl = getRepListIdByFactionId(factionId); + if (rl != 0xFFFFFFFFu) repListId = static_cast(rl); + } + network::Packet pkt(wireOpcode(Opcode::CMSG_SET_WATCHED_FACTION)); + pkt.writeUInt32(static_cast(repListId)); + socket->send(pkt); + LOG_DEBUG("CMSG_SET_WATCHED_FACTION: repListId=", repListId, " (factionId=", factionId, ")"); +} + std::string GameHandler::getFactionName(uint32_t factionId) const { auto it = factionNameCache_.find(factionId); if (it != factionNameCache_.end()) return it->second; From 1ae4cfaf3ff9baa3860435cc3069c22a5f81219e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 04:53:54 -0700 Subject: [PATCH 04/26] fix: auto-acknowledge cinematic and movie triggers to prevent server hangs Send CMSG_NEXT_CINEMATIC_CAMERA in response to SMSG_TRIGGER_CINEMATIC and CMSG_COMPLETE_MOVIE in response to SMSG_TRIGGER_MOVIE. Some WotLK servers block further packets or disconnect clients that don't respond to these triggers, especially during the intro cinematic on first login. --- src/game/game_handler.cpp | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 65e9350b..583d4599 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4554,11 +4554,16 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_TRIGGER_CINEMATIC: - // uint32 cinematicId — we don't play cinematics; consume and skip. + case Opcode::SMSG_TRIGGER_CINEMATIC: { + // uint32 cinematicId — we don't play cinematics; acknowledge immediately. packet.setReadPos(packet.getSize()); - LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped"); + // Send CMSG_NEXT_CINEMATIC_CAMERA to signal cinematic completion; + // servers may block further packets until this is received. + network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA)); + socket->send(ack); + LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped, sent CMSG_NEXT_CINEMATIC_CAMERA"); break; + } case Opcode::SMSG_LOOT_MONEY_NOTIFY: { // Format: uint32 money + uint8 soleLooter @@ -6298,9 +6303,19 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Movie trigger ---- - case Opcode::SMSG_TRIGGER_MOVIE: + case Opcode::SMSG_TRIGGER_MOVIE: { + // uint32 movieId — we don't play movies; acknowledge immediately. packet.setReadPos(packet.getSize()); + // WotLK servers expect CMSG_COMPLETE_MOVIE after the movie finishes; + // without it, the server may hang or disconnect the client. + uint16_t wire = wireOpcode(Opcode::CMSG_COMPLETE_MOVIE); + if (wire != 0xFFFF) { + network::Packet ack(wire); + socket->send(ack); + LOG_DEBUG("SMSG_TRIGGER_MOVIE: skipped, sent CMSG_COMPLETE_MOVIE"); + } break; + } // ---- Equipment sets ---- case Opcode::SMSG_EQUIPMENT_SET_LIST: From 9600dd40e3701770f607bc7cd2f89cab9b62d463 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:01:21 -0700 Subject: [PATCH 05/26] fix: correct CMSG_EQUIPMENT_SET_USE packet format The packet previously sent only a uint32 setId, which does not match the WotLK protocol. AzerothCore/TrinityCore expect 19 iterations of (PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot). Now looks up the equipment set's target item GUIDs and searches equipment, backpack, and extra bags to provide correct source locations for each item. --- src/game/game_handler.cpp | 53 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 583d4599..a9ed7a4b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10676,11 +10676,58 @@ void GameHandler::sendRequestVehicleExit() { } void GameHandler::useEquipmentSet(uint32_t setId) { - if (state != WorldState::IN_WORLD) return; - // CMSG_EQUIPMENT_SET_USE: uint32 setId + if (state != WorldState::IN_WORLD || !socket) return; + // Find the equipment set to get target item GUIDs per slot + const EquipmentSet* es = nullptr; + for (const auto& s : equipmentSets_) { + if (s.setId == setId) { es = &s; break; } + } + if (!es) { + addUIError("Equipment set not found."); + return; + } + // CMSG_EQUIPMENT_SET_USE: 19 × (PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot) network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE)); - pkt.writeUInt32(setId); + for (int slot = 0; slot < 19; ++slot) { + uint64_t itemGuid = es->itemGuids[slot]; + MovementPacket::writePackedGuid(pkt, itemGuid); + uint8_t srcBag = 0xFF; + uint8_t srcSlot = 0; + if (itemGuid != 0) { + bool found = false; + // Check if item is already in an equipment slot + for (int eq = 0; eq < 19 && !found; ++eq) { + if (getEquipSlotGuid(eq) == itemGuid) { + srcBag = 0xFF; // INVENTORY_SLOT_BAG_0 + srcSlot = static_cast(eq); + found = true; + } + } + // Check backpack (slots 23-38 in the body container) + for (int bp = 0; bp < 16 && !found; ++bp) { + if (getBackpackItemGuid(bp) == itemGuid) { + srcBag = 0xFF; + srcSlot = static_cast(23 + bp); + found = true; + } + } + // Check extra bags (bag indices 19-22) + for (int bag = 0; bag < 4 && !found; ++bag) { + int bagSize = inventory.getBagSize(bag); + for (int s = 0; s < bagSize && !found; ++s) { + if (getBagItemGuid(bag, s) == itemGuid) { + srcBag = static_cast(19 + bag); + srcSlot = static_cast(s); + found = true; + } + } + } + } + pkt.writeUInt8(srcBag); + pkt.writeUInt8(srcSlot); + } socket->send(pkt); + LOG_INFO("CMSG_EQUIPMENT_SET_USE: setId=", setId); } void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, From e68a1fa2eca62e643c42f66a519141f8dea6c405 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:12:24 -0700 Subject: [PATCH 06/26] fix: guard equipment set packets against unsupported expansions Classic and TBC lack equipment set opcodes, so sending save/use/delete packets would transmit wire opcode 0xFFFF and potentially disconnect the client. Now all three methods check wireOpcode != 0xFFFF before sending, and the Outfits tab is only shown when the expansion supports equipment sets (via supportsEquipmentSets() check). --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 16 +++++++++++++--- src/ui/inventory_screen.cpp | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 24cbd3e6..97e3dde3 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1534,6 +1534,7 @@ public: std::string iconName; }; const std::vector& getEquipmentSets() const { return equipmentSetInfo_; } + bool supportsEquipmentSets() const; void useEquipmentSet(uint32_t setId); void saveEquipmentSet(const std::string& name, const std::string& iconName = "INV_Misc_QuestionMark", uint64_t existingGuid = 0, uint32_t setIndex = 0xFFFFFFFF); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a9ed7a4b..cd799532 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10675,8 +10675,14 @@ void GameHandler::sendRequestVehicleExit() { vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) } +bool GameHandler::supportsEquipmentSets() const { + return wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE) != 0xFFFF; +} + void GameHandler::useEquipmentSet(uint32_t setId) { if (state != WorldState::IN_WORLD || !socket) return; + uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE); + if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } // Find the equipment set to get target item GUIDs per slot const EquipmentSet* es = nullptr; for (const auto& s : equipmentSets_) { @@ -10687,7 +10693,7 @@ void GameHandler::useEquipmentSet(uint32_t setId) { return; } // CMSG_EQUIPMENT_SET_USE: 19 × (PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot) - network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE)); + network::Packet pkt(wire); for (int slot = 0; slot < 19; ++slot) { uint64_t itemGuid = es->itemGuids[slot]; MovementPacket::writePackedGuid(pkt, itemGuid); @@ -10733,6 +10739,8 @@ void GameHandler::useEquipmentSet(uint32_t setId) { void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, uint64_t existingGuid, uint32_t setIndex) { if (state != WorldState::IN_WORLD) return; + uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE); + if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } // CMSG_EQUIPMENT_SET_SAVE: uint64 setGuid + uint32 setIndex + string name + string iconName // + 19 × PackedGuid itemGuid (one per equipment slot, 0–18) if (setIndex == 0xFFFFFFFF) { @@ -10742,7 +10750,7 @@ void GameHandler::saveEquipmentSet(const std::string& name, const std::string& i if (es.setId >= setIndex) setIndex = es.setId + 1; } } - network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE)); + network::Packet pkt(wire); pkt.writeUInt64(existingGuid); // 0 = create new, nonzero = update pkt.writeUInt32(setIndex); pkt.writeString(name); @@ -10757,8 +10765,10 @@ void GameHandler::saveEquipmentSet(const std::string& name, const std::string& i void GameHandler::deleteEquipmentSet(uint64_t setGuid) { if (state != WorldState::IN_WORLD || setGuid == 0) return; + uint16_t wire = wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET); + if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } // CMSG_DELETEEQUIPMENT_SET: uint64 setGuid - network::Packet pkt(wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET)); + network::Packet pkt(wire); pkt.writeUInt64(setGuid); socket->send(pkt); // Remove locally so UI updates immediately diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 3597dc68..74b52b3d 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1438,8 +1438,8 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } - // Equipment Sets tab (WotLK — always show so player can create sets) - if (ImGui::BeginTabItem("Outfits")) { + // Equipment Sets tab (WotLK only — requires server support) + if (gameHandler.supportsEquipmentSets() && ImGui::BeginTabItem("Outfits")) { ImGui::Spacing(); // Save current gear as new set From 595ea466c20bfad0ed70fb3393fc825cf7cce526 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:17:27 -0700 Subject: [PATCH 07/26] fix: update local equipment set GUID on save confirmation and auto-request played time on login SMSG_EQUIPMENT_SET_SAVED now updates the local set's GUID from the server response, preventing duplicate set creation when clicking "Update" on a newly-saved set. New sets are also added to the local list immediately so the UI reflects them without a relog. Additionally, CMSG_PLAYED_TIME is now auto-sent on initial world entry (with sendToChat=false) so the character Stats tab shows total and level time immediately without requiring /played. --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 54 +++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 97e3dde3..3fd92f22 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -3479,6 +3479,8 @@ private: std::array itemGuids{}; }; std::vector equipmentSets_; + std::string pendingSaveSetName_; // Saved between CMSG_EQUIPMENT_SET_SAVE and SMSG_EQUIPMENT_SET_SAVED + std::string pendingSaveSetIcon_; std::vector equipmentSetInfo_; // public-facing copy // ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ---- diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index cd799532..48d28282 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4218,19 +4218,52 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 12) { uint32_t setIndex = packet.readUInt32(); uint64_t setGuid = packet.readUInt64(); - for (const auto& es : equipmentSets_) { - if (es.setGuid == setGuid || - (es.setGuid == 0 && es.setId == setIndex)) { + // Update the local set's GUID so subsequent "Update" calls + // use the server-assigned GUID instead of 0 (which would + // create a duplicate instead of updating). + bool found = false; + for (auto& es : equipmentSets_) { + if (es.setGuid == setGuid || es.setId == setIndex) { + es.setGuid = setGuid; setName = es.name; + found = true; break; } } - (void)setIndex; + // Also update public-facing info + for (auto& info : equipmentSetInfo_) { + if (info.setGuid == setGuid || info.setId == setIndex) { + info.setGuid = setGuid; + break; + } + } + // If the set doesn't exist locally yet (new save), add a + // placeholder entry so it shows up in the UI immediately. + if (!found && setGuid != 0) { + EquipmentSet newEs; + newEs.setGuid = setGuid; + newEs.setId = setIndex; + newEs.name = pendingSaveSetName_; + newEs.iconName = pendingSaveSetIcon_; + for (int s = 0; s < 19; ++s) + newEs.itemGuids[s] = getEquipSlotGuid(s); + equipmentSets_.push_back(std::move(newEs)); + EquipmentSetInfo newInfo; + newInfo.setGuid = setGuid; + newInfo.setId = setIndex; + newInfo.name = pendingSaveSetName_; + newInfo.iconName = pendingSaveSetIcon_; + equipmentSetInfo_.push_back(std::move(newInfo)); + setName = pendingSaveSetName_; + } + pendingSaveSetName_.clear(); + pendingSaveSetIcon_.clear(); + LOG_INFO("SMSG_EQUIPMENT_SET_SAVED: index=", setIndex, + " guid=", setGuid, " name=", setName); } addSystemChatMessage(setName.empty() ? std::string("Equipment set saved.") : "Equipment set \"" + setName + "\" saved."); - LOG_DEBUG("Equipment set saved"); break; } case Opcode::SMSG_PERIODICAURALOG: { @@ -9396,6 +9429,14 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); } } + + // Auto-request played time on login so the character Stats tab is + // populated immediately without requiring /played. + if (socket) { + auto ptPkt = RequestPlayedTimePacket::build(false); // false = don't show in chat + socket->send(ptPkt); + LOG_INFO("Auto-requested played time on login"); + } } } @@ -10759,6 +10800,9 @@ void GameHandler::saveEquipmentSet(const std::string& name, const std::string& i uint64_t guid = getEquipSlotGuid(slot); MovementPacket::writePackedGuid(pkt, guid); } + // Track pending save so SMSG_EQUIPMENT_SET_SAVED can add the new set locally + pendingSaveSetName_ = name; + pendingSaveSetIcon_ = iconName; socket->send(pkt); LOG_INFO("CMSG_EQUIPMENT_SET_SAVE: name=\"", name, "\" guid=", existingGuid, " index=", setIndex); } From 5230815353d4f4f7365a65c55c520bff26dea9f1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:28:45 -0700 Subject: [PATCH 08/26] feat: display detailed honor/arena/token costs for vendor items Load ItemExtendedCost.dbc and show specific costs (e.g. "2000 Honor", "200 Arena", "30x Badge of Justice") instead of generic "[Tokens]" for vendor items with extended costs. Items with both gold and token costs now show both. Token item names are resolved from item info cache. --- include/ui/game_screen.hpp | 12 +++++++ src/ui/game_screen.cpp | 66 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 4bc10707..04326e75 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -423,6 +423,18 @@ private: bool spellIconDbLoaded_ = false; VkDescriptorSet getSpellIcon(uint32_t spellId, pipeline::AssetManager* am); + // ItemExtendedCost.dbc cache: extendedCostId -> cost details + struct ExtendedCostEntry { + uint32_t honorPoints = 0; + uint32_t arenaPoints = 0; + uint32_t itemId[5] = {}; + uint32_t itemCount[5] = {}; + }; + std::unordered_map extendedCostCache_; + bool extendedCostDbLoaded_ = false; + void loadExtendedCostDBC(); + std::string formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler); + // Death Knight rune bar: client-predicted fill (0.0=depleted, 1.0=ready) for smooth animation float runeClientFill_[6] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f}; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 811ef73e..9de70ce4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16020,6 +16020,60 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// ItemExtendedCost.dbc loader +// ============================================================ + +void GameScreen::loadExtendedCostDBC() { + if (extendedCostDbLoaded_) return; + extendedCostDbLoaded_ = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + auto dbc = am->loadDBC("ItemExtendedCost.dbc"); + if (!dbc || !dbc->isLoaded()) return; + // WotLK ItemExtendedCost.dbc: field 0=ID, 1=honorPoints, 2=arenaPoints, + // 3=arenaSlotRestrictions, 4-8=itemId[5], 9-13=itemCount[5], 14=reqRating, 15=purchaseGroup + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + if (id == 0) continue; + ExtendedCostEntry e; + e.honorPoints = dbc->getUInt32(i, 1); + e.arenaPoints = dbc->getUInt32(i, 2); + for (int j = 0; j < 5; ++j) { + e.itemId[j] = dbc->getUInt32(i, 4 + j); + e.itemCount[j] = dbc->getUInt32(i, 9 + j); + } + extendedCostCache_[id] = e; + } + LOG_INFO("ItemExtendedCost.dbc: loaded ", extendedCostCache_.size(), " entries"); +} + +std::string GameScreen::formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler) { + loadExtendedCostDBC(); + auto it = extendedCostCache_.find(extendedCostId); + if (it == extendedCostCache_.end()) return "[Tokens]"; + const auto& e = it->second; + std::string result; + if (e.honorPoints > 0) { + result += std::to_string(e.honorPoints) + " Honor"; + } + if (e.arenaPoints > 0) { + if (!result.empty()) result += ", "; + result += std::to_string(e.arenaPoints) + " Arena"; + } + for (int j = 0; j < 5; ++j) { + if (e.itemId[j] == 0 || e.itemCount[j] == 0) continue; + if (!result.empty()) result += ", "; + const auto* itemInfo = gameHandler.getItemInfo(e.itemId[j]); + if (itemInfo && itemInfo->valid && !itemInfo->name.empty()) { + result += std::to_string(e.itemCount[j]) + "x " + itemInfo->name; + } else { + result += std::to_string(e.itemCount[j]) + "x Item#" + std::to_string(e.itemId[j]); + } + } + return result.empty() ? "[Tokens]" : result; +} + // ============================================================ // Vendor Window (Phase 5) // ============================================================ @@ -16272,8 +16326,9 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(2); if (item.buyPrice == 0 && item.extendedCost != 0) { - // Token-only item (no gold cost) - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "[Tokens]"); + // Token-only item — show detailed cost from ItemExtendedCost.dbc + std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", costStr.c_str()); } else { uint32_t g = item.buyPrice / 10000; uint32_t s = (item.buyPrice / 100) % 100; @@ -16284,6 +16339,13 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { } else { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); } + // Show additional token cost if both gold and tokens are required + if (item.extendedCost != 0) { + std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); + if (costStr != "[Tokens]") { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 0.8f), "+ %s", costStr.c_str()); + } + } } ImGui::TableSetColumnIndex(3); From 9d1fb393632329cfa72cb095da32e1325cd5d824 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:34:17 -0700 Subject: [PATCH 09/26] feat: add "Usable" filter to auction house and query token item names Add a "Usable" checkbox to the AH search UI that filters results to items the player can actually equip/use (server-side filtering via the usableOnly parameter in CMSG_AUCTION_LIST_ITEMS). Also ensure token item names for extended costs are queried from the server via ensureItemInfo() so they display properly instead of "Item#12345". --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 04326e75..4121b974 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -581,6 +581,7 @@ private: uint32_t auctionBrowseOffset_ = 0; // Pagination offset for browse results int auctionItemClass_ = -1; // Item class filter (-1 = All) int auctionItemSubClass_ = -1; // Item subclass filter (-1 = All) + bool auctionUsableOnly_ = false; // Filter to items usable by current class/level // Guild bank money input int guildBankMoneyInput_[3] = {0, 0, 0}; // gold, silver, copper diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9de70ce4..464c395c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16064,6 +16064,7 @@ std::string GameScreen::formatExtendedCost(uint32_t extendedCostId, game::GameHa for (int j = 0; j < 5; ++j) { if (e.itemId[j] == 0 || e.itemCount[j] == 0) continue; if (!result.empty()) result += ", "; + gameHandler.ensureItemInfo(e.itemId[j]); // query if not cached const auto* itemInfo = gameHandler.getItemInfo(e.itemId[j]); if (itemInfo && itemInfo->valid && !itemInfo->name.empty()) { result += std::to_string(e.itemCount[j]) + "x " + itemInfo->name; @@ -21752,7 +21753,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { gameHandler.auctionSearch(auctionSearchName_, static_cast(auctionLevelMin_), static_cast(auctionLevelMax_), - q, getSearchClassId(), getSearchSubClassId(), 0, 0, offset); + q, getSearchClassId(), getSearchSubClassId(), 0, + auctionUsableOnly_ ? 1 : 0, offset); }; // Row 1: Name + Level range @@ -21798,6 +21800,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } + ImGui::SameLine(); + ImGui::Checkbox("Usable", &auctionUsableOnly_); ImGui::SameLine(); float delay = gameHandler.getAuctionSearchDelay(); if (delay > 0.0f) { From 29c938dec2a98cf577f771d0573a2c192bd41e26 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:40:53 -0700 Subject: [PATCH 10/26] feat: add Isle of Conquest to battleground score frame Add IoC (map 628) to the BG score display with Alliance/Horde reinforcement counters (world state keys 4221/4222, max 300). --- src/ui/game_screen.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 464c395c..a246cbe0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -23507,6 +23507,8 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { { 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" }, // Strand of the Ancients (WotLK) { 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" }, + // Isle of Conquest (WotLK): reinforcements (300 default) + { 628, "Isle of Conquest", 4221, 4222, 0, 300, "reinforcements" }, }; const BgScoreDef* def = nullptr; From 90edb3bc077ac5dca2ce04b3a82033eb96db82fe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:52:47 -0700 Subject: [PATCH 11/26] feat: use M2 animation duration for spell visual lifetime Spell visual effects previously used a fixed 3.5s duration for all effects, causing some to linger too long and overlap during combat. Now queries the M2 model's default animation duration via the new getInstanceAnimDuration() method and clamps it to 0.5-5s. Effects without animations fall back to a 2s default. This makes spell impacts feel more responsive and reduces visual clutter. --- include/rendering/m2_renderer.hpp | 1 + include/rendering/renderer.hpp | 9 +++++++-- src/rendering/m2_renderer.cpp | 12 ++++++++++++ src/rendering/renderer.cpp | 11 ++++++++--- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 1f19b46e..08d83d32 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -323,6 +323,7 @@ public: 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); + float getInstanceAnimDuration(uint32_t instanceId) const; void removeInstance(uint32_t instanceId); void removeInstances(const std::vector& instanceIds); void setSkipCollision(uint32_t instanceId, bool skip); diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 588fa3af..1a99a6f4 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -334,7 +334,11 @@ private: pipeline::AssetManager* cachedAssetManager = nullptr; // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL/IMPACT - struct SpellVisualInstance { uint32_t instanceId; float elapsed; }; + struct SpellVisualInstance { + uint32_t instanceId; + float elapsed; + float duration; // per-instance lifetime in seconds (from M2 anim or default) + }; std::vector activeSpellVisuals_; std::unordered_map spellVisualCastPath_; // visualId → cast M2 path std::unordered_map spellVisualImpactPath_; // visualId → impact M2 path @@ -343,7 +347,8 @@ private: bool spellVisualDbcLoaded_ = false; void loadSpellVisualDbc(); void updateSpellVisuals(float deltaTime); - static constexpr float SPELL_VISUAL_DURATION = 3.5f; + static constexpr float SPELL_VISUAL_MAX_DURATION = 5.0f; + static constexpr float SPELL_VISUAL_DEFAULT_DURATION = 2.0f; uint32_t currentZoneId = 0; std::string currentZoneName; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 40ffd4b1..365bf7c3 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -3956,6 +3956,18 @@ void M2Renderer::setInstanceAnimationFrozen(uint32_t instanceId, bool frozen) { } } +float M2Renderer::getInstanceAnimDuration(uint32_t instanceId) const { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return 0.0f; + const auto& inst = instances[idxIt->second]; + if (!inst.cachedModel) return 0.0f; + const auto& seqs = inst.cachedModel->sequences; + if (seqs.empty()) return 0.0f; + int seqIdx = inst.currentSequenceIndex; + if (seqIdx < 0 || seqIdx >= static_cast(seqs.size())) seqIdx = 0; + return seqs[seqIdx].duration; // in milliseconds +} + void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { auto idxIt = instanceIndexById.find(instanceId); if (idxIt == instanceIndexById.end()) return; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 2d942c23..c3241337 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2892,16 +2892,21 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId); return; } - activeSpellVisuals_.push_back({instanceId, 0.0f}); + // Determine lifetime from M2 animation duration (clamp to reasonable range) + float animDurMs = m2Renderer->getInstanceAnimDuration(instanceId); + float duration = (animDurMs > 100.0f) + ? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION) + : SPELL_VISUAL_DEFAULT_DURATION; + activeSpellVisuals_.push_back({instanceId, 0.0f, duration}); LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId, - " model=", modelPath); + " duration=", duration, "s model=", modelPath); } void Renderer::updateSpellVisuals(float deltaTime) { if (activeSpellVisuals_.empty() || !m2Renderer) return; for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { it->elapsed += deltaTime; - if (it->elapsed >= SPELL_VISUAL_DURATION) { + if (it->elapsed >= it->duration) { m2Renderer->removeInstance(it->instanceId); it = activeSpellVisuals_.erase(it); } else { From bda5bb0a2b4c1465b42b83f09e33f9079d29be75 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:56:33 -0700 Subject: [PATCH 12/26] fix: add negative cache for failed spell visual model loads Spell visual M2 models that fail to load (missing file, empty model, or GPU upload failure) were re-attempted on every subsequent spell cast, causing repeated file I/O during combat. Now caches failed model IDs in spellVisualFailedModels_ so they are skipped on subsequent attempts. --- include/rendering/renderer.hpp | 2 ++ src/rendering/renderer.cpp | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 1a99a6f4..92c1de59 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -343,6 +344,7 @@ private: std::unordered_map spellVisualCastPath_; // visualId → cast M2 path std::unordered_map spellVisualImpactPath_; // visualId → impact M2 path std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId + std::unordered_set spellVisualFailedModels_; // modelIds that failed to load (negative cache) uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799 bool spellVisualDbcLoaded_ = false; void loadSpellVisualDbc(); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index c3241337..4dad508d 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2860,16 +2860,21 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition spellVisualModelIds_[modelPath] = modelId; } + // Skip models that have previously failed to load (avoid repeated I/O) + if (spellVisualFailedModels_.count(modelId)) return; + // Load the M2 model if not already loaded if (!m2Renderer->hasModel(modelId)) { auto m2Data = cachedAssetManager->readFile(modelPath); if (m2Data.empty()) { LOG_DEBUG("SpellVisual: could not read model: ", modelPath); + spellVisualFailedModels_.insert(modelId); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty() && model.particleEmitters.empty()) { LOG_DEBUG("SpellVisual: empty model: ", modelPath); + spellVisualFailedModels_.insert(modelId); return; } // Load skin file for WotLK-format M2s @@ -2880,6 +2885,7 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition } if (!m2Renderer->loadModel(model, modelId)) { LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath); + spellVisualFailedModels_.insert(modelId); return; } LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath); From bc2085b0fcb0db4c3d76c0d6f78cafe6a14414c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 05:59:11 -0700 Subject: [PATCH 13/26] fix: increase compressed UPDATE_OBJECT decompressed size limit to 5MB Capital cities and large raids can produce UPDATE_OBJECT packets that decompress to more than 1MB. The real WoW client handles up to ~10MB. Bump the limit from 1MB to 5MB to avoid silently dropping entity updates in densely populated areas like Dalaran or 40-man raids. --- 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 48d28282..62b91b4e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12787,7 +12787,9 @@ void GameHandler::handleCompressedUpdateObject(network::Packet& packet) { uint32_t decompressedSize = packet.readUInt32(); LOG_DEBUG(" Decompressed size: ", decompressedSize); - if (decompressedSize == 0 || decompressedSize > 1024 * 1024) { + // Capital cities and large raids can produce very large update packets. + // The real WoW client handles up to ~10MB; 5MB covers all practical cases. + if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) { LOG_WARNING("Invalid decompressed size: ", decompressedSize); return; } From 120c2967ebd188ef90bb33e8d96fe10f99cd8298 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:04:29 -0700 Subject: [PATCH 14/26] feat: show proficiency warning in item tooltips Item tooltips now display a red "You can't use this type of item." warning when the player lacks proficiency for the weapon or armor subclass (e.g. a mage hovering over a plate item or a two-handed sword). Uses the existing canUseWeaponSubclass/canUseArmorSubclass checks against SMSG_SET_PROFICIENCY bitmasks. --- src/ui/inventory_screen.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 74b52b3d..e9fcf39a 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2627,6 +2627,20 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); } } + + // Show red warning if player lacks proficiency for this weapon/armor type + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + bool canUse = true; + if (qi->itemClass == 2) // Weapon + canUse = gameHandler_->canUseWeaponSubclass(qi->subClass); + else if (qi->itemClass == 4 && qi->subClass > 0) // Armor (skip subclass 0 = misc) + canUse = gameHandler_->canUseArmorSubclass(qi->subClass); + if (!canUse) + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "You can't use this type of item."); + } + } } auto isWeaponInventoryType = [](uint32_t invType) { From 6b7975107ed93decd8acf5369676b0981b46ae79 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:07:38 -0700 Subject: [PATCH 15/26] fix: add proficiency warning to vendor/loot item tooltips The proficiency check added in the previous commit only applied to the ItemDef tooltip variant (inventory items). Vendor, loot, and AH tooltips use the ItemQueryResponseData variant which was missing the check. Now both tooltip paths show "You can't use this type of item." in red when the player lacks weapon or armor proficiency. --- src/ui/inventory_screen.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e9fcf39a..2ea91c10 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -3298,6 +3298,17 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, else ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); } + + // Proficiency check for vendor/loot tooltips (ItemQueryResponseData has itemClass/subClass) + if (gameHandler_) { + bool canUse = true; + if (info.itemClass == 2) // Weapon + canUse = gameHandler_->canUseWeaponSubclass(info.subClass); + else if (info.itemClass == 4 && info.subClass > 0) // Armor (skip subclass 0 = misc) + canUse = gameHandler_->canUseArmorSubclass(info.subClass); + if (!canUse) + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "You can't use this type of item."); + } } // Weapon stats From d7059c66dc6f0e221f5e341492d3dfa9fb7971f6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:13:27 -0700 Subject: [PATCH 16/26] feat: add mounted/swimming/flying/stealthed/channeling macro conditionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add commonly-used WoW macro conditionals that were missing: - [mounted]/[nomounted] — checks isMounted() state - [swimming]/[noswimming] — checks SWIMMING movement flag - [flying]/[noflying] — checks CAN_FLY + FLYING movement flags - [stealthed]/[nostealthed] — checks UNIT_FLAG_SNEAKING (0x02000000) - [channeling]/[nochanneling] — checks if currently channeling a spell These are essential for common macros like mount/dismount toggles, rogue opener macros, and conditional cast sequences. --- src/ui/game_screen.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a246cbe0..787d4f44 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5742,6 +5742,32 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // swimming / noswimming + if (c == "swimming") return gameHandler.isSwimming(); + if (c == "noswimming") return !gameHandler.isSwimming(); + + // flying / noflying (CAN_FLY + FLYING flags active) + if (c == "flying") return gameHandler.isPlayerFlying(); + if (c == "noflying") return !gameHandler.isPlayerFlying(); + + // channeling / nochanneling + if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); + if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); + + // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) + auto isStealthedFn = [&]() -> bool { + auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (!pe) return false; + auto pu = std::dynamic_pointer_cast(pe); + return pu && (pu->getUnitFlags() & 0x02000000u) != 0; + }; + if (c == "stealthed") return isStealthedFn(); + if (c == "nostealthed") return !isStealthedFn(); + // noform / nostance — player is NOT in a shapeshift/stance if (c == "noform" || c == "nostance") { for (const auto& a : gameHandler.getPlayerAuras()) From a9e0a99f2b6c3ee7a9d62da5127d8c7e9e65899b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:17:23 -0700 Subject: [PATCH 17/26] feat: add /macrohelp command to list available macro conditionals Players can now type /macrohelp to see all supported macro conditionals grouped by category (state, target, form, keys, aura). Also added to the /help output and chat auto-complete list. This helps users discover the macro system without external documentation. --- src/ui/game_screen.cpp | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 787d4f44..42693c9a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2627,7 +2627,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { "/g", "/guild", "/guildinfo", "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", - "/l", "/leave", "/local", "/me", + "/l", "/leave", "/local", "/macrohelp", "/me", "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", "/r", "/raid", @@ -6117,6 +6117,31 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /macrohelp command — list available macro conditionals + if (cmdLower == "macrohelp") { + static const char* kMacroHelp[] = { + "--- Macro Conditionals ---", + "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", + "State: [combat] [nocombat] [mounted] [nomounted]", + " [swimming] [flying] [stealthed] [channeling]", + "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", + " [target=focus] [target=pet] [target=player]", + "Form: [noform] [nostance] [form:0]", + "Keys: [mod:shift] [mod:ctrl] [mod:alt]", + "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", + "Other: #showtooltip, /stopmacro [cond], /castsequence", + }; + for (const char* line : kMacroHelp) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = line; + gameHandler.addLocalChatMessage(m); + } + chatInputBuffer[0] = '\0'; + return; + } + // /help command — list available slash commands if (cmdLower == "help" || cmdLower == "?") { static const char* kHelpLines[] = { @@ -6135,7 +6160,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /loc /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /score /unstuck /logout /ticket /screenshot /help", + " /score /unstuck /logout /ticket /screenshot /macrohelp /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; From 114478271eda79a78092cb61830d8b22bae05994 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:25:02 -0700 Subject: [PATCH 18/26] feat: add [pet], [nopet], [group], [nogroup] macro conditionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add frequently-used macro conditionals for pet and group state: - [pet]/[nopet] — checks if the player has an active pet (hunters, warlocks, DKs). Essential for pet management macros. - [group]/[nogroup]/[party] — checks if the player is in a party or raid. Used for conditional targeting and ability usage. Updated /macrohelp output to list the new conditionals. --- src/ui/game_screen.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 42693c9a..fed9a119 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5768,6 +5768,14 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, if (c == "stealthed") return isStealthedFn(); if (c == "nostealthed") return !isStealthedFn(); + // pet / nopet — player has an active pet (hunters, warlocks, DKs) + if (c == "pet") return gameHandler.hasPet(); + if (c == "nopet") return !gameHandler.hasPet(); + + // group / nogroup — player is in a party or raid + if (c == "group" || c == "party") return gameHandler.isInGroup(); + if (c == "nogroup") return !gameHandler.isInGroup(); + // noform / nostance — player is NOT in a shapeshift/stance if (c == "noform" || c == "nostance") { for (const auto& a : gameHandler.getPlayerAuras()) @@ -6122,8 +6130,8 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { static const char* kMacroHelp[] = { "--- Macro Conditionals ---", "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", - "State: [combat] [nocombat] [mounted] [nomounted]", - " [swimming] [flying] [stealthed] [channeling]", + "State: [combat] [mounted] [swimming] [flying] [stealthed]", + " [channeling] [pet] [group] (prefix no- to negate)", "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", " [target=focus] [target=pet] [target=player]", "Form: [noform] [nostance] [form:0]", From fa82d32a9f44e04b8114dbd3760385ab6a382c10 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:29:33 -0700 Subject: [PATCH 19/26] feat: add [indoors]/[outdoors] macro conditionals via WMO detection Add indoor/outdoor state macro conditionals using the renderer's WMO interior detection. Essential for mount macros that need to select ground mounts indoors vs flying mounts outdoors. The Renderer now caches the insideWmo state in playerIndoors_ and exposes it via isPlayerIndoors(). Updated /macrohelp to list the new conditionals. --- include/rendering/renderer.hpp | 2 ++ src/rendering/renderer.cpp | 1 + src/ui/game_screen.cpp | 13 ++++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 92c1de59..b53e87d1 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -139,6 +139,7 @@ public: QuestMarkerRenderer* getQuestMarkerRenderer() const { return questMarkerRenderer.get(); } SkySystem* getSkySystem() const { return skySystem.get(); } const std::string& getCurrentZoneName() const { return currentZoneName; } + bool isPlayerIndoors() const { return playerIndoors_; } VkContext* getVkContext() const { return vkCtx; } VkDescriptorSetLayout getPerFrameSetLayout() const { return perFrameSetLayout; } VkRenderPass getShadowRenderPass() const { return shadowRenderPass; } @@ -356,6 +357,7 @@ private: std::string currentZoneName; bool inTavern_ = false; bool inBlacksmith_ = false; + bool playerIndoors_ = false; // Cached WMO inside state for macro conditionals float musicSwitchCooldown_ = 0.0f; bool deferredWorldInitEnabled_ = true; bool deferredWorldInitPending_ = false; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 4dad508d..6583f4bf 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -3476,6 +3476,7 @@ void Renderer::update(float deltaTime) { uint32_t insideWmoId = 0; const bool insideWmo = canQueryWmo && wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &insideWmoId); + playerIndoors_ = insideWmo; // Ambient environmental sounds: fireplaces, water, birds, etc. if (ambientSoundManager && camera && wmoRenderer && cameraController) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fed9a119..35cda2a0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5772,6 +5772,16 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, if (c == "pet") return gameHandler.hasPet(); if (c == "nopet") return !gameHandler.hasPet(); + // indoors / outdoors — WMO interior detection (affects mount type selection) + if (c == "indoors" || c == "nooutdoors") { + auto* r = core::Application::getInstance().getRenderer(); + return r && r->isPlayerIndoors(); + } + if (c == "outdoors" || c == "noindoors") { + auto* r = core::Application::getInstance().getRenderer(); + return !r || !r->isPlayerIndoors(); + } + // group / nogroup — player is in a party or raid if (c == "group" || c == "party") return gameHandler.isInGroup(); if (c == "nogroup") return !gameHandler.isInGroup(); @@ -6131,7 +6141,8 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "--- Macro Conditionals ---", "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", "State: [combat] [mounted] [swimming] [flying] [stealthed]", - " [channeling] [pet] [group] (prefix no- to negate)", + " [channeling] [pet] [group] [indoors] [outdoors]", + " (prefix no- to negate any condition)", "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", " [target=focus] [target=pet] [target=player]", "Form: [noform] [nostance] [form:0]", From a6fe5662c88ae42e4af416935f63febe142d6cb9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:38:13 -0700 Subject: [PATCH 20/26] fix: implement [target=pet] and [@pet] macro target specifiers The /macrohelp listed [target=pet] as supported but the conditional evaluator didn't handle the "pet" specifier for target= or @ syntax. Now resolves to the player's active pet GUID (or skips the alternative if no pet is active). Essential for hunter/warlock macros like: /cast [target=pet] Mend Pet /cast [@pet,dead] Revive Pet --- src/ui/game_screen.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 35cda2a0..aa46605b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5668,12 +5668,17 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); if (c.empty()) return true; - // @target specifiers: @player, @focus, @mouseover, @target + // @target specifiers: @player, @focus, @pet, @mouseover, @target if (!c.empty() && c[0] == '@') { std::string spec = c.substr(1); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); else if (spec == "focus") tgt = gameHandler.getFocusGuid(); else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } else if (spec == "mouseover") { uint64_t mo = gameHandler.getMouseoverGuid(); if (mo != 0) tgt = mo; @@ -5684,9 +5689,14 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, // target=X specifiers if (c.rfind("target=", 0) == 0) { std::string spec = c.substr(7); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); else if (spec == "focus") tgt = gameHandler.getFocusGuid(); else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } else if (spec == "mouseover") { uint64_t mo = gameHandler.getMouseoverGuid(); if (mo != 0) tgt = mo; From 22742fedb836ce8038239c7501e7e1100a2726f1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:42:43 -0700 Subject: [PATCH 21/26] feat: add [raid], [noraid], and [spec:N] macro conditionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add commonly-used WoW macro conditionals: - [raid]/[noraid] — checks if the player is in a raid group (groupType == 1) vs a regular party. Used for conditional healing/targeting in raid content. - [spec:1]/[spec:2] — checks the active talent spec (1-based index). Used for dual-spec macros that swap gear sets or use different rotations per spec. Updated /macrohelp to list the new conditionals. --- src/ui/game_screen.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index aa46605b..cbbd9acf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5796,6 +5796,17 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, if (c == "group" || c == "party") return gameHandler.isInGroup(); if (c == "nogroup") return !gameHandler.isInGroup(); + // raid / noraid — player is in a raid group (groupType == 1) + if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; + if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; + + // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) + if (c.rfind("spec:", 0) == 0) { + uint8_t wantSpec = 0; + try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} + return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); + } + // noform / nostance — player is NOT in a shapeshift/stance if (c == "noform" || c == "nostance") { for (const auto& a : gameHandler.getPlayerAuras()) @@ -6151,7 +6162,8 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "--- Macro Conditionals ---", "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", "State: [combat] [mounted] [swimming] [flying] [stealthed]", - " [channeling] [pet] [group] [indoors] [outdoors]", + " [channeling] [pet] [group] [raid] [indoors] [outdoors]", + "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", " (prefix no- to negate any condition)", "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", " [target=focus] [target=pet] [target=player]", From 72993121aba78029c92ccb2839eb22a2245e454c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:47:39 -0700 Subject: [PATCH 22/26] feat: add pulsing yellow flash to chat tabs with unread messages Chat tabs with unread messages now pulse yellow to attract attention. The existing unread count "(N)" suffix was text-only and easy to miss, especially for whisper and guild tabs. The pulsing color clears when the tab is clicked, matching standard WoW chat tab behavior. --- src/ui/game_screen.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cbbd9acf..877905e9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1315,7 +1315,12 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; } - // Use ImGuiTabItemFlags_NoPushId so label changes don't break tab identity + // Flash tab text color when unread messages exist + bool hasUnread = (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0); + if (hasUnread) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f * pulse, 0.2f * pulse, 1.0f)); + } if (ImGui::BeginTabItem(tabLabel.c_str())) { if (activeChatTab_ != i) { activeChatTab_ = i; @@ -1325,6 +1330,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } ImGui::EndTabItem(); } + if (hasUnread) ImGui::PopStyleColor(); } ImGui::EndTabBar(); } From 533831e18dbe3a9639faf849e161eecae5d291ff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 06:59:23 -0700 Subject: [PATCH 23/26] fix: sync pending spell cooldowns to action bar after login SMSG_SPELL_COOLDOWN arrives before SMSG_ACTION_BUTTONS during login, so cooldown times were stored in spellCooldowns but never applied to the newly populated action bar slots. Players would see all abilities as ready immediately after login even if spells were on cooldown. Now applies pending cooldowns from the spellCooldowns map to each matching slot when the action bar is first populated. --- src/game/game_handler.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 62b91b4e..ac58e946 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4492,6 +4492,18 @@ void GameHandler::handlePacket(network::Packet& packet) { } actionBar[i] = slot; } + // Apply any pending cooldowns from spellCooldowns to newly populated slots. + // SMSG_SPELL_COOLDOWN often arrives before SMSG_ACTION_BUTTONS during login, + // so the per-slot cooldownRemaining would be 0 without this sync. + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { + auto cdIt = spellCooldowns.find(slot.id); + if (cdIt != spellCooldowns.end() && cdIt->second > 0.0f) { + slot.cooldownRemaining = cdIt->second; + slot.cooldownTotal = cdIt->second; + } + } + } LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); packet.setReadPos(packet.getSize()); break; From 5172c07e15cdfce337cfc0e1c280b4222f0dfe16 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 07:02:57 -0700 Subject: [PATCH 24/26] fix: include category cooldowns in initial spell cooldown tracking SMSG_INITIAL_SPELLS cooldown entries have both cooldownMs (individual) and categoryCooldownMs (shared, e.g. potions). The handler only checked cooldownMs, so spells with category-only cooldowns (cooldownMs=0, categoryCooldownMs=120000) were not tracked. Now uses the maximum of both values, ensuring potion and similar shared cooldowns show on the action bar after login. --- src/game/game_handler.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ac58e946..7059473d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18753,10 +18753,12 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { knownSpells.insert(6603u); knownSpells.insert(8690u); - // Set initial cooldowns + // Set initial cooldowns — use the longer of individual vs category cooldown. + // Spells like potions have cooldownMs=0 but categoryCooldownMs=120000. for (const auto& cd : data.cooldowns) { - if (cd.cooldownMs > 0) { - spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f; + uint32_t effectiveMs = std::max(cd.cooldownMs, cd.categoryCooldownMs); + if (effectiveMs > 0) { + spellCooldowns[cd.spellId] = effectiveMs / 1000.0f; } } From ebc7d66dfebbabbfb0050084b1950d6083e9c5b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 07:12:40 -0700 Subject: [PATCH 25/26] fix: add honor/arena currency to update field name table PLAYER_FIELD_HONOR_CURRENCY and PLAYER_FIELD_ARENA_CURRENCY were added to the UF enum and JSON files in cycle 1, but the kUFNames lookup table in update_field_table.cpp was not updated. This meant the JSON loader could not map these field names to their enum values, so honor and arena point values from UPDATE_OBJECT were silently ignored. --- src/game/update_field_table.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 6a736546..57681dea 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -74,6 +74,8 @@ static const UFNameEntry kUFNames[] = { {"PLAYER_RANGED_CRIT_PERCENTAGE", UF::PLAYER_RANGED_CRIT_PERCENTAGE}, {"PLAYER_SPELL_CRIT_PERCENTAGE1", UF::PLAYER_SPELL_CRIT_PERCENTAGE1}, {"PLAYER_FIELD_COMBAT_RATING_1", UF::PLAYER_FIELD_COMBAT_RATING_1}, + {"PLAYER_FIELD_HONOR_CURRENCY", UF::PLAYER_FIELD_HONOR_CURRENCY}, + {"PLAYER_FIELD_ARENA_CURRENCY", UF::PLAYER_FIELD_ARENA_CURRENCY}, {"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS}, {"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1}, }; From e24c39f4becc163e384705d984f0d56efb095e4f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 07:16:34 -0700 Subject: [PATCH 26/26] fix: add UNIT_FIELD_AURAFLAGS to update field name table UNIT_FIELD_AURAFLAGS was defined in the UF enum and used in Classic and Turtle JSON files (index 98) but missing from the kUFNames lookup table. The JSON loader silently skipped it, so Classic/Turtle aura flag data from UPDATE_OBJECT was never mapped. This could cause aura display issues on Classic 1.12 and Turtle WoW servers. --- src/game/update_field_table.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 57681dea..85ac0458 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -34,6 +34,7 @@ static const UFNameEntry kUFNames[] = { {"UNIT_FIELD_DISPLAYID", UF::UNIT_FIELD_DISPLAYID}, {"UNIT_FIELD_MOUNTDISPLAYID", UF::UNIT_FIELD_MOUNTDISPLAYID}, {"UNIT_FIELD_AURAS", UF::UNIT_FIELD_AURAS}, + {"UNIT_FIELD_AURAFLAGS", UF::UNIT_FIELD_AURAFLAGS}, {"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS}, {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES},