From 3e3bbf915e2f5606e45076faa43e78dbc2339678 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 22:42:20 -0700 Subject: [PATCH 01/55] fix: parse SMSG_TRADE_STATUS_EXTENDED correctly for Classic/TBC WotLK inserts a uint32 tradeId between isSelf and slotCount, and appends uint32 createPlayedTime at the end of each slot (52-byte trail vs 48 for Classic/TBC). Without the expansion check, Classic and TBC parsers consumed tradeId as part of slotCount, resulting in a bogus slot count and corrupted trade window item display. Now gates the tradeId read and adjusts SLOT_TRAIL size based on isActiveExpansion("wotlk"). --- src/game/game_handler.cpp | 49 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2eacb363..f84a864d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -24487,27 +24487,32 @@ void GameHandler::resetTradeState() { } void GameHandler::handleTradeStatusExtended(network::Packet& packet) { - // WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format: - // uint8 isSelfState (1 = my trade window, 0 = peer's) - // uint32 tradeId - // uint32 slotCount (7: 6 normal + 1 extra for enchanting) - // Per slot (up to slotCount): - // uint8 slotIndex - // uint32 itemId - // uint32 displayId - // uint32 stackCount - // uint8 isWrapped - // uint64 giftCreatorGuid - // uint32 enchantId (and several more enchant/stat fields) - // ... (complex; we parse only the essential fields) - // uint64 coins (gold offered by the sender of this message) + // SMSG_TRADE_STATUS_EXTENDED format differs by expansion: + // + // Classic/TBC: + // uint8 isSelf + uint32 slotCount + [slots] + uint64 coins + // Per slot tail (after isWrapped): giftCreatorGuid(8) + enchants(24) + + // randomPropertyId(4) + suffixFactor(4) + durability(4) + maxDurability(4) = 48 bytes + // + // WotLK 3.3.5a adds: + // uint32 tradeId (after isSelf, before slotCount) + // Per slot: + createPlayedTime(4) at end of trail → trail = 52 bytes + // + // Minimum: isSelf(1) + [tradeId(4)] + slotCount(4) = 5 or 9 bytes + const bool isWotLK = isActiveExpansion("wotlk"); + size_t minHdr = isWotLK ? 9u : 5u; + if (packet.getSize() - packet.getReadPos() < minHdr) return; - size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 9) return; + uint8_t isSelf = packet.readUInt8(); + if (isWotLK) { + /*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field + } + uint32_t slotCount = packet.readUInt32(); - uint8_t isSelf = packet.readUInt8(); - uint32_t tradeId = packet.readUInt32(); (void)tradeId; - uint32_t slotCount= packet.readUInt32(); + // Per-slot tail bytes after isWrapped: + // Classic/TBC: giftCreatorGuid(8) + enchants(24) + stats(16) = 48 + // WotLK: same + createPlayedTime(4) = 52 + const size_t SLOT_TRAIL = isWotLK ? 52u : 48u; auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_; @@ -24521,12 +24526,6 @@ void GameHandler::handleTradeStatusExtended(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 1) { isWrapped = (packet.readUInt8() != 0); } - // AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped: - // giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12) - // + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24] - // + randomPropertyId (4) + suffixFactor (4) - // + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes - constexpr size_t SLOT_TRAIL = 52; if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); } else { From d99fe8de0f929cddf2244473bda83ca5a8670fa7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 23:29:50 -0700 Subject: [PATCH 02/55] feat: add Sort Bags button to backpack window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Inventory::sortBags() which collects all items from the backpack and equip bags, sorts them client-side by quality descending → item ID ascending → stack count descending, then writes them back. A "Sort Bags" SmallButton is rendered in the backpack footer with a tooltip explaining the sort order. The sort is purely local (no server packets) since the WoW protocol has no sort-bags opcode; it provides an instant, session-persistent visual reorder. --- include/game/inventory.hpp | 4 ++++ src/game/inventory.cpp | 39 +++++++++++++++++++++++++++++++++++++ src/ui/inventory_screen.cpp | 33 +++++++++++++++++++++---------- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index ea6f6110..cf092ac4 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -125,6 +125,10 @@ public: int findFreeBackpackSlot() const; bool addItem(const ItemDef& item); + // Sort all bag slots (backpack + equip bags) by quality desc → itemId asc → stackCount desc. + // Purely client-side: reorders the local inventory struct without server interaction. + void sortBags(); + // Test data void populateTestItems(); diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 0d694aba..a6de6dcb 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -1,5 +1,6 @@ #include "game/inventory.hpp" #include "core/logger.hpp" +#include namespace wowee { namespace game { @@ -185,6 +186,44 @@ bool Inventory::addItem(const ItemDef& item) { return true; } +void Inventory::sortBags() { + // Collect all items from backpack and equip bags into a flat list. + std::vector items; + items.reserve(BACKPACK_SLOTS + NUM_BAG_SLOTS * MAX_BAG_SIZE); + + for (int i = 0; i < BACKPACK_SLOTS; ++i) { + if (!backpack[i].empty()) + items.push_back(backpack[i].item); + } + for (int b = 0; b < NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < bags[b].size; ++s) { + if (!bags[b].slots[s].empty()) + items.push_back(bags[b].slots[s].item); + } + } + + // Sort: quality descending → itemId ascending → stackCount descending. + std::stable_sort(items.begin(), items.end(), [](const ItemDef& a, const ItemDef& b) { + if (a.quality != b.quality) + return static_cast(a.quality) > static_cast(b.quality); + if (a.itemId != b.itemId) + return a.itemId < b.itemId; + return a.stackCount > b.stackCount; + }); + + // Write sorted items back, filling backpack first then equip bags. + int idx = 0; + int n = static_cast(items.size()); + + for (int i = 0; i < BACKPACK_SLOTS; ++i) + backpack[i].item = (idx < n) ? items[idx++] : ItemDef{}; + + for (int b = 0; b < NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < bags[b].size; ++s) + bags[b].slots[s].item = (idx < n) ? items[idx++] : ItemDef{}; + } +} + void Inventory::populateTestItems() { // Equipment { diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 083096a7..7b91ef22 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1019,7 +1019,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, float contentH = rows * (slotSize + 4.0f) + 10.0f; if (bagIndex < 0) { int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns; - contentH += 25.0f; // money display for backpack + contentH += 36.0f; // separator + sort button + money display contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots } float gridW = columns * (slotSize + 4.0f) + 30.0f; @@ -1094,16 +1094,29 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, } } - // Money display at bottom of backpack - if (bagIndex < 0 && moneyCopper > 0) { + // Footer for backpack: sort button + money display + if (bagIndex < 0) { ImGui::Spacing(); - uint64_t gold = moneyCopper / 10000; - uint64_t silver = (moneyCopper / 100) % 100; - uint64_t copper = moneyCopper % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", - static_cast(gold), - static_cast(silver), - static_cast(copper)); + ImGui::Separator(); + + // Sort Bags button — client-side reorder by quality/type + if (ImGui::SmallButton("Sort Bags")) { + inventory.sortBags(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Sort all bag slots by quality (highest first),\nthen by item ID, then by stack size."); + } + + if (moneyCopper > 0) { + ImGui::SameLine(); + uint64_t gold = moneyCopper / 10000; + uint64_t silver = (moneyCopper / 100) % 100; + uint64_t copper = moneyCopper % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", + static_cast(gold), + static_cast(silver), + static_cast(copper)); + } } ImGui::End(); From 2acab47eeee457a867d99e3387654cdfd2fe63e0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 23:44:55 -0700 Subject: [PATCH 03/55] =?UTF-8?q?fix:=20correct=20corpse=20reclaim=20?= =?UTF-8?q?=E2=80=94=20SMSG=5FDEATH=5FRELEASE=5FLOC=20is=20graveyard,=20no?= =?UTF-8?q?t=20corpse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented "Resurrect from Corpse" from working: 1. SMSG_DEATH_RELEASE_LOC was overwriting corpseX_/Y_/Z_/MapId_ with the graveyard spawn point (where the ghost appears after releasing spirit), not the actual corpse location. canReclaimCorpse() was therefore comparing the ghost's distance to the graveyard instead of the real corpse, so the button never appeared when the ghost returned to the death position. Fix: read and log the packet but leave corpseX_/Y_/Z_ untouched. 2. reclaimCorpse() fell back to playerGuid when corpseGuid_ == 0. CMSG_RECLAIM_CORPSE requires the corpse object's own GUID; the server looks it up by GUID and silently rejects an unknown one. Fix: gate reclaimCorpse() on corpseGuid_ being known (set when the corpse object arrives in SMSG_UPDATE_OBJECT), and add canReclaimCorpse() guard for the same. Corpse position is now sourced only from: - Health-drop detection (primary, fires immediately on death) - SMSG_UPDATE_OBJECT CORPSE type (updates when object enters view range) --- src/game/game_handler.cpp | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f84a864d..2fec9a1d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2656,14 +2656,19 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { - // uint32 mapId + float x + float y + float z — corpse/spirit healer position + // uint32 mapId + float x + float y + float z + // This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location. + // The corpse remains at the death position (already cached when health dropped to 0, + // and updated when the corpse object arrives via SMSG_UPDATE_OBJECT). + // Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse() + // by making it check distance to the graveyard instead of the real corpse. if (packet.getSize() - packet.getReadPos() >= 16) { - corpseMapId_ = packet.readUInt32(); - corpseX_ = packet.readFloat(); - corpseY_ = packet.readFloat(); - corpseZ_ = packet.readFloat(); - LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_, - " x=", corpseX_, " y=", corpseY_, " z=", corpseZ_); + uint32_t relMapId = packet.readUInt32(); + float relX = packet.readFloat(); + float relY = packet.readFloat(); + float relZ = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, + " x=", relX, " y=", relY, " z=", relZ); } break; } @@ -13924,12 +13929,12 @@ void GameHandler::releaseSpirit() { } bool GameHandler::canReclaimCorpse() const { - if (!releasedSpirit_ || corpseMapId_ == 0) return false; - // Only if ghost is on the same map as their corpse + // Need: ghost state + corpse object GUID (required by CMSG_RECLAIM_CORPSE) + + // corpse map known + same map + within 40 yards. + if (!releasedSpirit_ || corpseGuid_ == 0 || corpseMapId_ == 0) return false; if (currentMapId_ != corpseMapId_) return false; // 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_; @@ -13938,12 +13943,15 @@ bool GameHandler::canReclaimCorpse() const { void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; - // Reclaim expects the corpse object guid when known; fallback to player guid. - uint64_t reclaimGuid = (corpseGuid_ != 0) ? corpseGuid_ : playerGuid; - auto packet = ReclaimCorpsePacket::build(reclaimGuid); + // CMSG_RECLAIM_CORPSE requires the corpse object's own GUID. + // Servers look up the corpse by this GUID; sending the player GUID silently fails. + if (corpseGuid_ == 0) { + LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim"); + return; + } + auto packet = ReclaimCorpsePacket::build(corpseGuid_); socket->send(packet); - LOG_INFO("Sent CMSG_RECLAIM_CORPSE for guid=0x", std::hex, reclaimGuid, std::dec, - (corpseGuid_ == 0 ? " (fallback player guid)" : "")); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec); } void GameHandler::activateSpiritHealer(uint64_t npcGuid) { From b0046fa77730b8d3aedb76836aecfc628154b485 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 23:52:45 -0700 Subject: [PATCH 04/55] feat: track PvP corpse-reclaim delay and show countdown in UI SMSG_CORPSE_RECLAIM_DELAY is now stored as an absolute expiry timestamp (steady_clock ms) instead of being discarded after a chat message. GameHandler::getCorpseReclaimDelaySec() returns remaining seconds (0 when reclaim is available). The "Resurrect from Corpse" button now: - Disables and shows the remaining seconds when a PvP delay is active - Shows the usual "Corpse: N yards" helper text when available Also resets corpseReclaimAvailableMs_ on world/session teardown. --- include/game/game_handler.hpp | 5 ++++ src/game/game_handler.cpp | 21 +++++++++++---- src/ui/game_screen.cpp | 48 +++++++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e75fedb5..d917890a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1183,6 +1183,8 @@ public: void cancelPetUnlearn() { petUnlearnPending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; + /** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */ + float getCorpseReclaimDelaySec() const; /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ float getCorpseDistance() const { if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; @@ -3298,6 +3300,9 @@ private: uint32_t corpseMapId_ = 0; float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f; uint64_t corpseGuid_ = 0; + // Absolute time (ms since epoch) when PvP corpse-reclaim delay expires. + // 0 means no active delay (reclaim allowed immediately upon proximity). + uint64_t corpseReclaimAvailableMs_ = 0; // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially std::array playerRunes_ = [] { std::array r{}; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2fec9a1d..58b0b622 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2645,13 +2645,14 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CORPSE_RECLAIM_DELAY: { - // uint32 delayMs before player can reclaim corpse + // uint32 delayMs before player can reclaim corpse (PvP deaths) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t delayMs = packet.readUInt32(); - uint32_t delaySec = (delayMs + 999) / 1000; - addSystemChatMessage("You can reclaim your corpse in " + - std::to_string(delaySec) + " seconds."); - LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + corpseReclaimAvailableMs_ = nowMs + delayMs; + LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); } break; } @@ -9085,6 +9086,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; targetGuid = 0; focusGuid = 0; lastTargetGuid = 0; @@ -13941,6 +13943,15 @@ bool GameHandler::canReclaimCorpse() const { return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } +float GameHandler::getCorpseReclaimDelaySec() const { + if (corpseReclaimAvailableMs_ == 0) return 0.0f; + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + if (nowMs >= corpseReclaimAvailableMs_) return 0.0f; + return static_cast(corpseReclaimAvailableMs_ - nowMs) / 1000.0f; +} + void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; // CMSG_RECLAIM_CORPSE requires the corpse object's own GUID. diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8cd7a6c8..808a922b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15421,28 +15421,48 @@ void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float delaySec = gameHandler.getCorpseReclaimDelaySec(); + bool onDelay = (delaySec > 0.0f); + float btnW = 220.0f, btnH = 36.0f; + float winH = btnH + 16.0f + (onDelay ? 20.0f : 0.0f); ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, winH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); if (ImGui::Begin("##ReclaimCorpse", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus)) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); - if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { - gameHandler.reclaimCorpse(); - } - ImGui::PopStyleColor(2); - float corpDist = gameHandler.getCorpseDistance(); - if (corpDist >= 0.0f) { - char distBuf[48]; - snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); - float dw = ImGui::CalcTextSize(distBuf).x; - ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); - ImGui::TextDisabled("%s", distBuf); + if (onDelay) { + // Greyed-out button while PvP reclaim timer ticks down + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::BeginDisabled(true); + char delayLabel[64]; + snprintf(delayLabel, sizeof(delayLabel), "Resurrect from Corpse (%.0fs)", delaySec); + ImGui::Button(delayLabel, ImVec2(btnW, btnH)); + ImGui::EndDisabled(); + ImGui::PopStyleColor(2); + const char* waitMsg = "You cannot reclaim your corpse yet."; + float tw = ImGui::CalcTextSize(waitMsg).x; + ImGui::SetCursorPosX((btnW + 16.0f - tw) * 0.5f); + ImGui::TextColored(ImVec4(0.8f, 0.5f, 0.2f, 1.0f), "%s", waitMsg); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); + if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { + gameHandler.reclaimCorpse(); + } + ImGui::PopStyleColor(2); + float corpDist = gameHandler.getCorpseDistance(); + if (corpDist >= 0.0f) { + char distBuf[48]; + snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); + float dw = ImGui::CalcTextSize(distBuf).x; + ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); + ImGui::TextDisabled("%s", distBuf); + } } } ImGui::End(); From 395a8f77c4bcf403350bb54c90b217f6ac8e73b6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 23:57:47 -0700 Subject: [PATCH 05/55] fix: clear corpse reclaim delay on world reset and resurrection Reset corpseReclaimAvailableMs_ to 0 in both world-teardown/re-login and ghost-flag-cleared paths so the PvP delay countdown never bleeds into subsequent deaths or sessions. --- src/game/game_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 58b0b622..4ab52388 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10988,6 +10988,7 @@ void GameHandler::forceClearTaxiAndMovementState() { playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; @@ -12168,6 +12169,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem resurrectPending_ = false; corpseMapId_ = 0; // corpse reclaimed corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (ghostStateCallback_) ghostStateCallback_(false); } From 5a5c2dcda34ab4a7e38b869f3d809c34aca163c0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 00:06:39 -0700 Subject: [PATCH 06/55] feat: implement self-resurrection (Reincarnation/Twisting Nether) SMSG_PRE_RESURRECT was silently discarded; Shamans with Reincarnation and Warlocks with Twisting Nether could never see or use the self-res ability. Now: - SMSG_PRE_RESURRECT sets selfResAvailable_ flag when addressed to the local player - Death dialog gains a "Use Self-Resurrection" button (blue, shown above Release Spirit) when the flag is set - Clicking it sends CMSG_SELF_RES (empty body) and clears the flag - selfResAvailable_ is cleared on all resurrection and session-reset paths so it never bleeds across deaths or logins --- include/game/game_handler.hpp | 5 +++++ src/game/game_handler.cpp | 24 ++++++++++++++++++++++-- src/ui/game_screen.cpp | 17 ++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d917890a..f8480d2b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1171,6 +1171,10 @@ public: bool isPlayerGhost() const { return releasedSpirit_; } bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } + /** True when SMSG_PRE_RESURRECT arrived — Reincarnation/Twisting Nether available. */ + bool canSelfRes() const { return selfResAvailable_; } + /** Send CMSG_SELF_RES to use Reincarnation / Twisting Nether. */ + void useSelfRes(); const std::string& getResurrectCasterName() const { return resurrectCasterName_; } bool showTalentWipeConfirmDialog() const { return talentWipePending_; } uint32_t getTalentWipeCost() const { return talentWipeCost_; } @@ -3314,6 +3318,7 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + bool selfResAvailable_ = false; // SMSG_PRE_RESURRECT received — Reincarnation/Twisting Nether // ---- Talent wipe confirm dialog ---- bool talentWipePending_ = false; uint64_t talentWipeNpcGuid_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4ab52388..28ffc8e1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7316,8 +7316,15 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Pre-resurrect state ---- case Opcode::SMSG_PRE_RESURRECT: { - // packed GUID of the player to enter pre-resurrect - (void)UpdateObjectParser::readPackedGuid(packet); + // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. + // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), + // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. + uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (targetGuid == playerGuid || targetGuid == 0) { + selfResAvailable_ = true; + LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x", + std::hex, targetGuid, std::dec, ")"); + } break; } @@ -9193,6 +9200,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementInfo.jumpXYSpeed = 0.0f; resurrectPending_ = false; resurrectRequestPending_ = false; + selfResAvailable_ = false; onTaxiFlight_ = false; taxiMountActive_ = false; taxiActivatePending_ = false; @@ -10985,6 +10993,7 @@ void GameHandler::forceClearTaxiAndMovementState() { vehicleId_ = 0; resurrectPending_ = false; resurrectRequestPending_ = false; + selfResAvailable_ = false; playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; @@ -11886,6 +11895,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem } else if (wasDead && !nowDead) { playerDead_ = false; releasedSpirit_ = false; + selfResAvailable_ = false; LOG_INFO("Player resurrected (dynamic flags)"); } } else if (entity->getType() == ObjectType::UNIT) { @@ -12167,6 +12177,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem playerDead_ = false; repopPending_ = false; resurrectPending_ = false; + selfResAvailable_ = false; corpseMapId_ = 0; // corpse reclaimed corpseGuid_ = 0; corpseReclaimAvailableMs_ = 0; @@ -13967,6 +13978,15 @@ void GameHandler::reclaimCorpse() { LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec); } +void GameHandler::useSelfRes() { + if (!selfResAvailable_ || !socket) return; + // CMSG_SELF_RES: empty body — server confirms resurrection via SMSG_UPDATE_OBJECT. + network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES)); + socket->send(pkt); + selfResAvailable_ = false; + LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)"); +} + void GameHandler::activateSpiritHealer(uint64_t npcGuid) { if (state != WorldState::IN_WORLD || !socket) return; pendingSpiritHealerGuid_ = npcGuid; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 808a922b..c3eed8d6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15364,8 +15364,10 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); // "Release Spirit" dialog centered on screen + const bool hasSelfRes = gameHandler.canSelfRes(); float dlgW = 280.0f; - float dlgH = 130.0f; + // Extra height when self-res button is available + float dlgH = hasSelfRes ? 170.0f : 130.0f; ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); @@ -15399,6 +15401,19 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::Spacing(); + // Self-resurrection button (Reincarnation / Twisting Nether / Deathpact) + if (hasSelfRes) { + float btnW2 = 220.0f; + ImGui::SetCursorPosX((dlgW - btnW2) / 2); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.55f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.5f, 0.75f, 1.0f)); + if (ImGui::Button("Use Self-Resurrection", ImVec2(btnW2, 30))) { + gameHandler.useSelfRes(); + } + ImGui::PopStyleColor(2); + ImGui::Spacing(); + } + // Center the Release Spirit button float btnW = 180.0f; ImGui::SetCursorPosX((dlgW - btnW) / 2); From 0f8852d29051fd483dbc52a1cb582862c21aa908 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 00:09:22 -0700 Subject: [PATCH 07/55] fix: clear selfResAvailable_ when player releases spirit --- src/game/game_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 28ffc8e1..85ceaac9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13937,6 +13937,7 @@ void GameHandler::releaseSpirit() { auto packet = RepopRequestPacket::build(); socket->send(packet); releasedSpirit_ = true; + selfResAvailable_ = false; // self-res window closes when spirit is released repopPending_ = true; lastRepopRequestMs_ = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); From 4907f4124bee64dff502eb139e4cfa86032c3826 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 00:21:46 -0700 Subject: [PATCH 08/55] feat: implement spell queue window (400ms pre-cast) When castSpell() is called while a timed cast is in progress and castTimeRemaining <= 0.4s, store the spell in queuedSpellId_ instead of silently dropping it. handleSpellGo() fires the queued spell immediately after clearing the cast state, matching the ~400ms spell queue window in Blizzlike WoW clients. Queue is cleared on all cancel/interrupt paths: cancelCast(), handleCastFailed(), SMSG_CAST_RESULT failure, SMSG_SPELL_FAILED, world-teardown, and worldport ACK. Channeled casts never queue (cancelling a channel should remain explicit). --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 38 ++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f8480d2b..7da3b9ab 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2725,6 +2725,9 @@ private: // Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes uint32_t craftQueueSpellId_ = 0; int craftQueueRemaining_ = 0; + // Spell queue: next spell to cast within the 400ms window before current cast ends + uint32_t queuedSpellId_ = 0; + uint64_t queuedSpellTarget_ = 0; // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) std::unordered_map unitCastStates_; uint64_t pendingGameObjectInteractGuid_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 85ceaac9..3e1f8be6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2252,9 +2252,11 @@ void GameHandler::handlePacket(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; - // Cancel craft queue on cast failure + // Cancel craft queue and spell queue on cast failure craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Pass player's power type so result 85 says "Not enough rage/energy/etc." int playerPowerType = -1; if (auto pe = entityManager.getEntity(playerGuid)) { @@ -3353,6 +3355,8 @@ void GameHandler::handlePacket(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; lastInteractedGoGuid_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); @@ -9090,6 +9094,8 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; @@ -17933,7 +17939,17 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { return; } - if (casting) return; // Already casting + if (casting) { + // Spell queue: if we're within 400ms of the cast completing (and not channeling), + // store the spell so it fires automatically when the cast finishes. + if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) { + queuedSpellId_ = spellId; + queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid; + LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f, + "ms remaining)"); + } + return; + } // Hearthstone: cast spell directly (server checks item in inventory) // Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which @@ -18035,9 +18051,11 @@ void GameHandler::cancelCast() { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - // Cancel craft queue when player manually cancels cast + // Cancel craft queue and spell queue when player manually cancels cast craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; } void GameHandler::startCraftQueue(uint32_t spellId, int count) { @@ -18311,6 +18329,8 @@ void GameHandler::handleCastFailed(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -18483,6 +18503,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) { if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); } + + // Spell queue: fire the next queued spell now that casting has ended + if (queuedSpellId_ != 0) { + uint32_t nextSpell = queuedSpellId_; + uint64_t nextTarget = queuedSpellTarget_; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; + LOG_INFO("Spell queue: firing queued spellId=", nextSpell); + castSpell(nextSpell, nextTarget); + } } else { if (spellCastAnimCallback_) { // End cast animation on other unit @@ -22063,6 +22093,8 @@ void GameHandler::handleNewWorld(network::Packet& packet) { pendingGameObjectInteractGuid_ = 0; lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready if (socket) { From 60d5edf97f9dcae2119b7d6b29f5af6efb0a8555 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 00:25:04 -0700 Subject: [PATCH 09/55] fix: cancel timed cast immediately on movement start When the player starts moving (forward/backward/strafe/jump) while a timed non-channeled cast is in progress, call cancelCast() before sending the movement packet. Previously the cast bar kept counting down until the server sent SMSG_SPELL_FAILED, causing a visible lag. Channeled spells are excluded (server ends those via MSG_CHANNEL_UPDATE). Turning opcodes are excluded (turning while casting is allowed in WoW). --- src/game/game_handler.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3e1f8be6..f459637a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10706,6 +10706,21 @@ void GameHandler::sendMovement(Opcode opcode) { } } + // Cancel any timed (non-channeled) cast the moment the player starts moving. + // Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server. + // Turning (MSG_MOVE_START_TURN_*) is allowed while casting. + if (casting && !castIsChannel) { + const bool isPositionalMove = + opcode == Opcode::MSG_MOVE_START_FORWARD || + opcode == Opcode::MSG_MOVE_START_BACKWARD || + opcode == Opcode::MSG_MOVE_START_STRAFE_LEFT || + opcode == Opcode::MSG_MOVE_START_STRAFE_RIGHT || + opcode == Opcode::MSG_MOVE_JUMP; + if (isPositionalMove) { + cancelCast(); + } + } + // Update movement flags based on opcode switch (opcode) { case Opcode::MSG_MOVE_START_FORWARD: From 76ba428b87f7cf899a3361f12381820dd26b6187 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 00:39:32 -0700 Subject: [PATCH 10/55] fix: /target command selects nearest matching entity Previously used arbitrary map-iteration order (last match), meaning '/target Kobold' might target a far-away enemy instead of the closest. Now computes squared distance for every prefix-matching entity and keeps the nearest one, matching WoW's own /target behaviour. --- src/ui/game_screen.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c3eed8d6..0b5306da 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6200,11 +6200,17 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "target" && spacePos != std::string::npos) { - // Search visible entities for name match (case-insensitive prefix) + // Search visible entities for name match (case-insensitive prefix). + // Among all matches, pick the nearest living unit to the player. std::string targetArg = command.substr(spacePos + 1); std::string targetArgLower = targetArg; for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + const float playerX = pmi.x; + const float playerY = pmi.y; + const float playerZ = pmi.z; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; std::string name; @@ -6217,8 +6223,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { std::string nameLower = name; for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); if (nameLower.find(targetArgLower) == 0) { - bestGuid = guid; - if (nameLower == targetArgLower) break; // Exact match wins + float dx = entity->getX() - playerX; + float dy = entity->getY() - playerY; + float dz = entity->getZ() - playerZ; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { + bestDist = dist; + bestGuid = guid; + } } } if (bestGuid) { From 6be695078b5f544296625bea0ea9ea5a845f9dba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 00:59:15 -0700 Subject: [PATCH 11/55] fix: clear spell queue in stopCasting; fix SMSG_SPELL_DELAYED castTimeTotal; clear cast on same-map res - stopCasting() (invoked by /stopcasting) now clears queuedSpellId_/ queuedSpellTarget_ and craftQueueSpellId_/craftQueueRemaining_ so a queued spell cannot fire silently after the player explicitly cancels. - SMSG_SPELL_DELAYED now extends castTimeTotal alongside castTimeRemaining for the local player, matching the existing other-unit handling and keeping the cast bar progress percentage accurate after server-imposed cast delays. - Same-map resurrection path (SMSG_NEW_WORLD same-map) now resets casting, castIsChannel, currentCastSpellId, castTimeRemaining, and the spell queue as a defensive measure (player is dead and cannot be casting, but this ensures state is clean on respawn). --- src/game/game_handler.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f459637a..59f72641 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4176,7 +4176,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (delayMs == 0) break; float delaySec = delayMs / 1000.0f; if (caster == playerGuid) { - if (casting) castTimeRemaining += delaySec; + if (casting) { + castTimeRemaining += delaySec; + castTimeTotal += delaySec; // keep progress percentage correct + } } else { auto it = unitCastStates_.find(caster); if (it != unitCastStates_.end() && it->second.casting) { @@ -13936,7 +13939,7 @@ void GameHandler::stopCasting() { socket->send(packet); } - // Reset casting state + // Reset casting state and clear any queued spell so it doesn't fire later casting = false; castIsChannel = false; currentCastSpellId = 0; @@ -13944,6 +13947,10 @@ void GameHandler::stopCasting() { lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; LOG_INFO("Cancelled spell cast"); } @@ -22050,6 +22057,12 @@ void GameHandler::handleNewWorld(network::Packet& packet) { hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true; + casting = false; + castIsChannel = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; if (socket) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK)); From 57b44d2347c290f84ceb0fea132a7387303d755a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 01:15:04 -0700 Subject: [PATCH 12/55] fix: clear craft queue on spell failure and all cast reset paths craftQueueSpellId_ and craftQueueRemaining_ were already cleared in cancelCast(), stopCasting(), and SMSG_CAST_RESULT failure, but were missing from five other cast-abort paths: - SMSG_SPELL_FAILURE (mid-cast interrupt): queue persisted after combat interruption, risking a ghost re-cast on the next SMSG_SPELL_GO - handleCastFailed() (SMSG_CAST_FAILED): queue persisted if the server rejected a craft before it started - Player login state reset: leftover queue from prior session survived into the new world session - Same-map resurrection (SMSG_NEW_WORLD): queue persisted through spirit-healer resurrection teleport - Regular world transfer (SMSG_NEW_WORLD): queue persisted across zone changes and dungeon portals --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 59f72641..8fdaf71d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3355,6 +3355,8 @@ void GameHandler::handlePacket(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; lastInteractedGoGuid_ = 0; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -9097,6 +9099,8 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; playerDead_ = false; @@ -18351,6 +18355,8 @@ void GameHandler::handleCastFailed(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; @@ -22061,6 +22067,8 @@ void GameHandler::handleNewWorld(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; @@ -22121,6 +22129,8 @@ void GameHandler::handleNewWorld(network::Packet& packet) { pendingGameObjectInteractGuid_ = 0; lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; From 5801af41bcba9be97ae31d1c2e849901494b26a0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 01:30:20 -0700 Subject: [PATCH 13/55] fix: correct Turtle WoW SMSG_INIT_WORLD_STATES format and remove dead minRepeatMs branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turtle WoW is Classic 1.12-based and uses the Classic packet format for SMSG_INIT_WORLD_STATES (no areaId uint32 field before count), not WotLK format. Including it in the WotLK branch caused the parser to consume 4 bytes of the count+first-key as a phantom areaId, misaligning all world state key/value pairs (BG scores, zone events, flag states). Also remove the dead `turtleMode ? 150 : 150` branch in performGameObjectInteractionNow — both arms were identical so the ternary had no effect; replace with a constexpr constant. --- src/game/game_handler.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8fdaf71d..147a79fe 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4018,9 +4018,9 @@ void GameHandler::handlePacket(network::Packet& packet) { } worldStateMapId_ = packet.readUInt32(); worldStateZoneId_ = packet.readUInt32(); - // WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent + // WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format size_t remaining = packet.getSize() - packet.getReadPos(); - bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle"); + bool isWotLKFormat = isActiveExpansion("wotlk"); if (isWotLKFormat && remaining >= 6) { packet.readUInt32(); // areaId (WotLK only) } @@ -19764,14 +19764,12 @@ void GameHandler::interactWithGameObject(uint64_t guid) { void GameHandler::performGameObjectInteractionNow(uint64_t guid) { if (guid == 0) return; if (state != WorldState::IN_WORLD || !socket) return; - bool turtleMode = isActiveExpansion("turtle"); - // Rate-limit to prevent spamming the server static uint64_t lastInteractGuid = 0; static std::chrono::steady_clock::time_point lastInteractTime{}; auto now = std::chrono::steady_clock::now(); // Keep duplicate suppression, but allow quick retry clicks. - int64_t minRepeatMs = turtleMode ? 150 : 150; + constexpr int64_t minRepeatMs = 150; if (guid == lastInteractGuid && std::chrono::duration_cast(now - lastInteractTime).count() < minRepeatMs) { return; From 7a0c7241ba4ac36b16c64da774459cbc6aa71550 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 01:35:39 -0700 Subject: [PATCH 14/55] fix: parse macro action bar slots from SMSG_ACTION_BUTTONS Macro slots (type 0x40 / 64) were silently dropped by the default branch of the SMSG_ACTION_BUTTONS type switch, leaving the bar empty for any slot a player had set to a macro. ActionBarSlot::MACRO already existed and the UI already rendered it; only the parser was missing the case. Add case 0x40 to map to ActionBarSlot::MACRO for Classic (type=64), TBC, and WotLK formats, which all share the same 0x40 encoding for macros. --- src/game/game_handler.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 147a79fe..212fa601 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4426,9 +4426,10 @@ void GameHandler::handlePacket(network::Packet& packet) { ActionBarSlot slot; switch (type) { case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; - case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; - case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; - default: continue; // macro or unknown — leave as-is + case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item + case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item + case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions) + default: continue; // unknown — leave as-is } actionBar[i] = slot; } From 36158ae3e3f559c140171fcf952e2c9f766cb176 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 01:42:07 -0700 Subject: [PATCH 15/55] fix: show macro ID in action bar tooltip and context menu header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macro slots stored from SMSG_ACTION_BUTTONS had no tooltip and no context menu header — hovering or right-clicking gave a blank result. Add an "else if MACRO" branch to both the tooltip and the popup-context-item so that "Macro #N" is displayed in both places. Clearing via right-click still works via the existing "Clear Slot" item which was already outside the type branches. --- src/ui/game_screen.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0b5306da..f5baf974 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7511,6 +7511,8 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Use")) { gameHandler.useItemById(slot.id); } + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::TextDisabled("Macro #%u", slot.id); } ImGui::Separator(); if (ImGui::MenuItem("Clear Slot")) { @@ -7569,6 +7571,10 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); } ImGui::EndTooltip(); + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::BeginTooltip(); + ImGui::Text("Macro #%u", slot.id); + ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { ImGui::BeginTooltip(); // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) From 1588c1029a6c1ea9f9ca1deda844521f524dbc31 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 01:46:19 -0700 Subject: [PATCH 16/55] fix: add user feedback for ATTACKSWING_NOTSTANDING and CANT_ATTACK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both handlers silently cleared state with no visible message, leaving the player unsure why their attack failed. Split the shared case block: - NOTSTANDING: show "You need to stand up to fight." (rate-limited to 1.25s via the existing autoAttackRangeWarnCooldown_ guard), keep auto-attack active so it fires once the player stands. - CANT_ATTACK: call stopAutoAttack() to end the attack loop (target is a critter, civilian, or already dead — no point retrying), then show "You can't attack that." with the same rate limiter. --- 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 212fa601..6bce4ade 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3248,9 +3248,21 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; case Opcode::SMSG_ATTACKSWING_NOTSTANDING: - case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: autoAttackOutOfRange_ = false; autoAttackOutOfRangeTime_ = 0.0f; + if (autoAttackRangeWarnCooldown_ <= 0.0f) { + addSystemChatMessage("You need to stand up to fight."); + autoAttackRangeWarnCooldown_ = 1.25f; + } + break; + case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: + // Target is permanently non-attackable (critter, civilian, already dead, etc.). + // Stop the auto-attack loop so the client doesn't spam the server. + stopAutoAttack(); + if (autoAttackRangeWarnCooldown_ <= 0.0f) { + addSystemChatMessage("You can't attack that."); + autoAttackRangeWarnCooldown_ = 1.25f; + } break; case Opcode::SMSG_ATTACKERSTATEUPDATE: handleAttackerStateUpdate(packet); From 2c86fb4fa6dce2482ed2abd424931de0e6d60e9f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:07:59 -0700 Subject: [PATCH 17/55] feat: implement client-side macro text storage and execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macros in WoW are client-side — the server sends only a macro index via SMSG_ACTION_BUTTONS, never the text. This commit adds local storage and a UI so macro slots are actually usable. - GameHandler: getMacroText/setMacroText accessors backed by macros_ map; text is persisted to the character .cfg file as macro_N_text= entries - Action bar left-click: MACRO slot executes first line of macro text as a chat/slash command (same path as /cast, /use, etc.) - Context menu: "Execute" and "Edit" items for MACRO slots; "Edit" opens a multiline modal editor (320×80 px, up to 255 chars) with Save/Cancel - Tooltip: shows macro text body below the index; hints "right-click to Edit" when no text is set yet --- include/game/game_handler.hpp | 5 +++ include/ui/game_screen.hpp | 4 +++ src/game/game_handler.cpp | 30 ++++++++++++++++++ src/ui/game_screen.cpp | 57 +++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7da3b9ab..64709cf9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -885,6 +885,10 @@ public: const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); + // Client-side macro text storage (server sends only macro index; text is stored locally) + const std::string& getMacroText(uint32_t macroId) const; + void setMacroText(uint32_t macroId, const std::string& text); + void saveCharacterConfig(); void loadCharacterConfig(); static std::string getCharacterConfigDir(); @@ -2759,6 +2763,7 @@ private: float castTimeTotal = 0.0f; std::array actionBar{}; + std::unordered_map macros_; // client-side macro text (persisted in char config) std::vector playerAuras; std::vector targetAuras; std::unordered_map> unitAurasCache_; // per-unit aura cache diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0e73c552..26db5c83 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -201,6 +201,10 @@ private: // Keybinding customization int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index bool awaitingKeyPress = false; + // Macro editor popup state + uint32_t macroEditorId_ = 0; // macro index being edited + bool macroEditorOpen_ = false; // deferred OpenPopup flag + char macroEditorBuf_[256] = {}; // edit buffer bool pendingUseOriginalSoundtrack = true; bool pendingShowActionBar2 = true; // Show second action bar above main bar float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6bce4ade..9089d438 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -23468,6 +23468,21 @@ std::string GameHandler::getCharacterConfigDir() { return dir; } +static const std::string EMPTY_MACRO_TEXT; + +const std::string& GameHandler::getMacroText(uint32_t macroId) const { + auto it = macros_.find(macroId); + return (it != macros_.end()) ? it->second : EMPTY_MACRO_TEXT; +} + +void GameHandler::setMacroText(uint32_t macroId, const std::string& text) { + if (text.empty()) + macros_.erase(macroId); + else + macros_[macroId] = text; + saveCharacterConfig(); +} + void GameHandler::saveCharacterConfig() { const Character* ch = getActiveCharacter(); if (!ch || ch->name.empty()) return; @@ -23494,6 +23509,12 @@ void GameHandler::saveCharacterConfig() { out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n"; } + // Save client-side macro text + for (const auto& [id, text] : macros_) { + if (!text.empty()) + out << "macro_" << id << "_text=" << text << "\n"; + } + // Save quest log out << "quest_log_count=" << questLog_.size() << "\n"; for (size_t i = 0; i < questLog_.size(); i++) { @@ -23534,6 +23555,15 @@ void GameHandler::loadCharacterConfig() { try { savedGender = std::stoi(val); } catch (...) {} } else if (key == "use_female_model") { try { savedUseFemaleModel = std::stoi(val); } catch (...) {} + } else if (key.rfind("macro_", 0) == 0) { + // Parse macro_N_text + size_t firstUnder = 6; // length of "macro_" + size_t secondUnder = key.find('_', firstUnder); + if (secondUnder == std::string::npos) continue; + uint32_t macroId = 0; + try { macroId = static_cast(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; } + if (key.substr(secondUnder + 1) == "text" && !val.empty()) + macros_[macroId] = val; } else if (key.rfind("action_bar_", 0) == 0) { // Parse action_bar_N_type or action_bar_N_id size_t firstUnderscore = 11; // length of "action_bar_" diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f5baf974..658ff7f4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7483,6 +7483,16 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { gameHandler.castSpell(slot.id, target); } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); + } else if (slot.type == game::ActionBarSlot::MACRO) { + const std::string& text = gameHandler.getMacroText(slot.id); + if (!text.empty()) { + // Execute first line of macro as a chat command + size_t nl = text.find('\n'); + std::string firstLine = (nl != std::string::npos) ? text.substr(0, nl) : text; + strncpy(chatInputBuffer, firstLine.c_str(), sizeof(chatInputBuffer) - 1); + chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; + sendChatMessage(gameHandler); + } } } @@ -7513,6 +7523,24 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } else if (slot.type == game::ActionBarSlot::MACRO) { ImGui::TextDisabled("Macro #%u", slot.id); + ImGui::Separator(); + if (ImGui::MenuItem("Execute")) { + const std::string& text = gameHandler.getMacroText(slot.id); + if (!text.empty()) { + size_t nl = text.find('\n'); + std::string firstLine = (nl != std::string::npos) ? text.substr(0, nl) : text; + strncpy(chatInputBuffer, firstLine.c_str(), sizeof(chatInputBuffer) - 1); + chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; + sendChatMessage(gameHandler); + } + } + if (ImGui::MenuItem("Edit")) { + const std::string& txt = gameHandler.getMacroText(slot.id); + strncpy(macroEditorBuf_, txt.c_str(), sizeof(macroEditorBuf_) - 1); + macroEditorBuf_[sizeof(macroEditorBuf_) - 1] = '\0'; + macroEditorId_ = slot.id; + macroEditorOpen_ = true; + } } ImGui::Separator(); if (ImGui::MenuItem("Clear Slot")) { @@ -7574,6 +7602,13 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } else if (slot.type == game::ActionBarSlot::MACRO) { ImGui::BeginTooltip(); ImGui::Text("Macro #%u", slot.id); + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted(macroText.c_str()); + } else { + ImGui::TextDisabled("(no text — right-click to Edit)"); + } ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { ImGui::BeginTooltip(); @@ -7786,6 +7821,28 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (i > 0) ImGui::SameLine(0, spacing); renderBarSlot(i, keyLabels1[i]); } + + // Macro editor modal — opened by "Edit" in action bar context menus + if (macroEditorOpen_) { + ImGui::OpenPopup("Edit Macro###MacroEdit"); + macroEditorOpen_ = false; + } + if (ImGui::BeginPopupModal("Edit Macro###MacroEdit", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Macro #%u (first line executes on click)", macroEditorId_); + ImGui::SetNextItemWidth(320.0f); + ImGui::InputTextMultiline("##MacroText", macroEditorBuf_, sizeof(macroEditorBuf_), + ImVec2(320.0f, 80.0f)); + if (ImGui::Button("Save")) { + gameHandler.setMacroText(macroEditorId_, std::string(macroEditorBuf_)); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } ImGui::End(); From 8abb65a813679d2b4bbb411cd5644d0b4af0e864 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:14:10 -0700 Subject: [PATCH 18/55] feat: execute macros via keyboard shortcuts; support numeric /cast spell IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two companion improvements for the macro system: - Keyboard shortcut handler now executes MACRO slots (1-0 keys) by running the first line of their text as a command, same as left-click - /cast now accepts a numeric spell ID or #ID prefix (e.g. /cast 133, /cast #133) in addition to spell names — enables standard WoW macro syntax and direct spell ID testing --- src/ui/game_screen.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 658ff7f4..712488cf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2833,6 +2833,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.castSpell(bar[slotIdx].id, target); } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { gameHandler.useItemById(bar[slotIdx].id); + } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { + const std::string& macroTxt = gameHandler.getMacroText(bar[slotIdx].id); + if (!macroTxt.empty()) { + size_t nl = macroTxt.find('\n'); + std::string firstLine = (nl != std::string::npos) ? macroTxt.substr(0, nl) : macroTxt; + strncpy(chatInputBuffer, firstLine.c_str(), sizeof(chatInputBuffer) - 1); + chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; + sendChatMessage(gameHandler); + } } } } @@ -6027,6 +6036,25 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + // Support numeric spell ID: /cast 133 or /cast #133 + { + std::string numStr = spellArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId != 0) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(spellId, targetGuid); + } + chatInputBuffer[0] = '\0'; + return; + } + } + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" int requestedRank = -1; // -1 = highest rank std::string spellName = spellArg; From fa3a5ec67ef73e4bc022f65aefe18de1f893d0a3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:20:35 -0700 Subject: [PATCH 19/55] fix: correct water refraction barrier srcAccessMask to prevent VK_ERROR_DEVICE_LOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captureSceneHistory barrier was using srcAccessMask=0 with VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT when transitioning the swapchain image from PRESENT_SRC_KHR to TRANSFER_SRC_OPTIMAL. This does not flush the GPU's color attachment write caches, causing VK_ERROR_DEVICE_LOST on strict drivers (AMD, Mali) that require explicit cache invalidation before transfer reads. Fix: use VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT + COLOR_ATTACHMENT_OUTPUT as the source mask so color writes are properly made visible to the transfer unit before the image copy begins. Also remove the now-unnecessary "requires FSR" restriction in the settings UI — water refraction can be enabled independently of FSR. --- src/rendering/water_renderer.cpp | 8 ++++++-- src/ui/game_screen.cpp | 11 ++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 2bbff1a3..6dd0b26f 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1142,10 +1142,14 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, }; // Color source: final render pass layout is PRESENT_SRC. + // srcAccessMask must be COLOR_ATTACHMENT_WRITE (not 0) so that GPU cache flushes + // happen before the transfer read. Using srcAccessMask=0 with BOTTOM_OF_PIPE + // causes VK_ERROR_DEVICE_LOST on strict drivers (AMD/Mali) because color writes + // are not made visible to the transfer unit before the copy begins. barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - 0, VK_ACCESS_TRANSFER_READ_BIT, - VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 712488cf..3c39bba3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15928,18 +15928,11 @@ void GameScreen::renderSettingsWindow() { } } { - bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled()); - if (!fsrActive && pendingWaterRefraction) { - // FSR was disabled while refraction was on — auto-disable - pendingWaterRefraction = false; - if (renderer) renderer->setWaterRefractionEnabled(false); - } - if (!fsrActive) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) { + if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } - if (!fsrActive) ImGui::EndDisabled(); } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; From b236a854546729a062e570fd974d6e1b30eb9fb4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:20:59 -0700 Subject: [PATCH 20/55] =?UTF-8?q?docs:=20update=20status.md=20=E2=80=94=20?= =?UTF-8?q?water=20refraction=20fix,=20date=202026-03-18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/status.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/status.md b/docs/status.md index 06722c2f..672c80cf 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Project Status -**Last updated**: 2026-03-11 +**Last updated**: 2026-03-18 ## What This Repo Is @@ -37,7 +37,7 @@ In progress / known gaps: - Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain - Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects) - Lava steam particles: sparse in some areas (tuning opportunity) -- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs); currently requires FSR to be active +- Water refraction: available in Settings; barrier srcAccessMask fix (2026-03-18) removed prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs ## Where To Look From db0f8685499db92a1be12d3adec04631fb6999b4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:23:47 -0700 Subject: [PATCH 21/55] feat: extend /use command to support bag/slot notation and equip slot numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WoW macro-standard /use argument forms alongside the existing item-name search: - /use 0 — backpack slot N (1-based, bag 0) - /use 1-4 — equipped bag slot N (1-based bag index) - /use — equip slot N (1-based, e.g. /use 16 = main hand) These are the standard forms used in macros like: #showtooltip /use 13 (trinket 1) /cast Arcane Blast --- src/ui/game_screen.cpp | 48 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3c39bba3..ee3dcc68 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -6122,11 +6123,56 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } - // /use — use an item from backpack/bags by name + // /use + // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, + // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) if (cmdLower == "use" && spacePos != std::string::npos) { std::string useArg = command.substr(spacePos + 1); while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + + // Check for bag/slot notation: two numbers separated by whitespace + { + std::istringstream iss(useArg); + int bagNum = -1, slotNum = -1; + iss >> bagNum >> slotNum; + if (!iss.fail() && slotNum >= 1) { + if (bagNum == 0) { + // Backpack: bag=0, slot 1-based → 0-based + gameHandler.useItemBySlot(slotNum - 1); + chatInputBuffer[0] = '\0'; + return; + } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { + // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) + gameHandler.useItemInBag(bagNum - 1, slotNum - 1); + chatInputBuffer[0] = '\0'; + return; + } + } + } + + // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) + { + std::string numStr = useArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) + int slotNum = 0; + try { slotNum = std::stoi(numStr); } catch (...) {} + if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { + auto eslot = static_cast(slotNum - 1); + const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); + if (!esl.empty()) + gameHandler.useItemById(esl.item.itemId); + } + chatInputBuffer[0] = '\0'; + return; + } + } + std::string useArgLower = useArg; for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); From c3be43de585f170cf7b84291f94a72f50ef9b7a5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:27:34 -0700 Subject: [PATCH 22/55] fix: skip #showtooltip and other # directives when executing macros Macros often start with a #showtooltip or #show directive line; these should not be executed as chat commands. The firstMacroCommand() helper now scans forward through the macro text, skipping blank lines and any line starting with '#', and executes the first actual command line. Applies to all three execution paths: left-click, keyboard shortcut, and right-click Execute menu item. --- src/ui/game_screen.cpp | 46 +++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ee3dcc68..1b32c7f2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -256,6 +256,9 @@ bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabInde return (tab.typeMask & typeBit) != 0; } +// Forward declaration — defined near sendChatMessage below +static std::string firstMacroCommand(const std::string& macroText); + void GameScreen::render(game::GameHandler& gameHandler) { // Set up chat bubble callback (once) if (!chatBubbleCallbackSet_) { @@ -2835,11 +2838,9 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { gameHandler.useItemById(bar[slotIdx].id); } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { - const std::string& macroTxt = gameHandler.getMacroText(bar[slotIdx].id); - if (!macroTxt.empty()) { - size_t nl = macroTxt.find('\n'); - std::string firstLine = (nl != std::string::npos) ? macroTxt.substr(0, nl) : macroTxt; - strncpy(chatInputBuffer, firstLine.c_str(), sizeof(chatInputBuffer) - 1); + std::string cmd = firstMacroCommand(gameHandler.getMacroText(bar[slotIdx].id)); + if (!cmd.empty()) { + strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; sendChatMessage(gameHandler); } @@ -5239,6 +5240,24 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// Returns the first executable line of a macro text block, skipping blank lines +// and # directive lines (e.g. #showtooltip). Returns empty string if none found. +static std::string firstMacroCommand(const std::string& macroText) { + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + return line; + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); @@ -7558,12 +7577,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); } else if (slot.type == game::ActionBarSlot::MACRO) { - const std::string& text = gameHandler.getMacroText(slot.id); - if (!text.empty()) { - // Execute first line of macro as a chat command - size_t nl = text.find('\n'); - std::string firstLine = (nl != std::string::npos) ? text.substr(0, nl) : text; - strncpy(chatInputBuffer, firstLine.c_str(), sizeof(chatInputBuffer) - 1); + std::string cmd = firstMacroCommand(gameHandler.getMacroText(slot.id)); + if (!cmd.empty()) { + strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; sendChatMessage(gameHandler); } @@ -7599,11 +7615,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextDisabled("Macro #%u", slot.id); ImGui::Separator(); if (ImGui::MenuItem("Execute")) { - const std::string& text = gameHandler.getMacroText(slot.id); - if (!text.empty()) { - size_t nl = text.find('\n'); - std::string firstLine = (nl != std::string::npos) ? text.substr(0, nl) : text; - strncpy(chatInputBuffer, firstLine.c_str(), sizeof(chatInputBuffer) - 1); + std::string cmd = firstMacroCommand(gameHandler.getMacroText(slot.id)); + if (!cmd.empty()) { + strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; sendChatMessage(gameHandler); } From ae3e57ac3b903837c9f80f6862a3106c1b2b42df Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:30:35 -0700 Subject: [PATCH 23/55] feat: add /cancelform, /cancelshapeshift, /cancelaura slash commands These are standard WoW macro commands: - /cancelform / /cancelshapeshift: exits current shapeshift form by cancelling the first permanent aura (flag 0x20) on the player - /cancelaura : cancels a specific player buff by spell name or numeric ID (e.g. /cancelaura Stealth, /cancelaura #1784) Also expand the Tab-autocomplete command list to include /cancelaura, /cancelform, /cancelshapeshift, /dismount, /sit, /stand, /startattack, /stopcasting, /target, and other commands that were previously missing. --- src/ui/game_screen.cpp | 62 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1b32c7f2..55dd1a49 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2594,16 +2594,19 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); static const std::vector kCmds = { - "/afk", "/away", "/cast", "/chathelp", "/clear", + "/afk", "/away", "/cancelaura", "/cancelform", "/cancelshapeshift", + "/cast", "/chathelp", "/clear", "/dance", "/do", "/dnd", "/e", "/emote", - "/cl", "/combatlog", "/equip", "/follow", "/g", "/guild", "/guildinfo", + "/cl", "/combatlog", "/dismount", "/equip", "/follow", + "/g", "/guild", "/guildinfo", "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", "/l", "/leave", "/local", "/me", "/p", "/party", "/r", "/raid", "/raidwarning", "/random", "/reply", "/roll", - "/s", "/say", "/setloot", "/shout", - "/stopattack", "/stopfollow", "/t", "/time", + "/s", "/say", "/setloot", "/shout", "/sit", "/stand", + "/startattack", "/stopattack", "/stopfollow", "/stopcasting", + "/t", "/target", "/time", "/trade", "/uninvite", "/use", "/w", "/whisper", "/who", "/wts", "/wtb", "/y", "/yell", "/zone" }; @@ -5619,6 +5622,57 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /cancelform / /cancelshapeshift — leave current shapeshift/stance + if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { + // Cancel the first permanent shapeshift aura the player has + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + // Permanent shapeshift auras have the permanent flag (0x20) set + if (aura.flags & 0x20) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer[0] = '\0'; + return; + } + + // /cancelaura — cancel a specific buff by name or ID + if (cmdLower == "cancelaura" && spacePos != std::string::npos) { + std::string auraArg = command.substr(spacePos + 1); + while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); + while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); + // Try numeric ID first + { + std::string numStr = auraArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNum = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNum) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId) gameHandler.cancelAura(spellId); + chatInputBuffer[0] = '\0'; + return; + } + } + // Name match against player auras + std::string argLow = auraArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + std::string sn = gameHandler.getSpellName(aura.spellId); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == argLow) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer[0] = '\0'; + return; + } + // /sit command if (cmdLower == "sit") { gameHandler.setStandState(1); // 1 = sit From c676d99fc26a99b39da55d876f50329ca0cc0f61 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:32:49 -0700 Subject: [PATCH 24/55] feat: add /petattack, /petfollow, /petstay, /petpassive, /petaggressive macro commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the standard WoW pet control slash commands used in macros: - /petattack — attack current target - /petfollow — follow player - /petstay / /pethalt — stop and hold position - /petpassive — set passive react mode - /petdefensive — set defensive react mode - /petaggressive — set aggressive react mode - /petdismiss — dismiss the pet All commands also appear in Tab-autocomplete. --- src/ui/game_screen.cpp | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 55dd1a49..5af139af 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2602,7 +2602,9 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", "/l", "/leave", "/local", "/me", - "/p", "/party", "/r", "/raid", + "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", + "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", + "/r", "/raid", "/raidwarning", "/random", "/reply", "/roll", "/s", "/say", "/setloot", "/shout", "/sit", "/stand", "/startattack", "/stopattack", "/stopfollow", "/stopcasting", @@ -5622,6 +5624,45 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // Pet control commands (common macro use) + // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive + if (cmdLower == "petattack") { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendPetAction(5, target); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petfollow") { + gameHandler.sendPetAction(2, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petstay" || cmdLower == "pethalt") { + gameHandler.sendPetAction(3, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petpassive") { + gameHandler.sendPetAction(1, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petdefensive") { + gameHandler.sendPetAction(4, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petaggressive") { + gameHandler.sendPetAction(6, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petdismiss") { + gameHandler.dismissPet(); + chatInputBuffer[0] = '\0'; + return; + } + // /cancelform / /cancelshapeshift — leave current shapeshift/stance if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { // Cancel the first permanent shapeshift aura the player has From ed3bca3d17987ed3b696e96804468e86c2a1630a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 02:44:28 -0700 Subject: [PATCH 25/55] fix: escape newlines in macro cfg persistence; execute all macro lines - Macro text is now escaped (\\n, \\\\) on save and unescaped on load, fixing multiline macros silently truncating after the first line in the character config file. - executeMacroText() runs every non-comment line of a macro body in sequence (WoW behaviour), replacing the firstMacroCommand() approach that only fired the first actionable line. The server still enforces one spell-cast per click; non-cast commands (target, equip, pet, etc.) now all execute correctly in the same macro activation. --- include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 32 ++++++++++++++++++++---- src/ui/game_screen.cpp | 50 ++++++++++++++++++++++++-------------- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 26db5c83..0b958cb2 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -278,6 +278,7 @@ private: * Send chat message */ void sendChatMessage(game::GameHandler& gameHandler); + void executeMacroText(game::GameHandler& gameHandler, const std::string& macroText); /** * Get chat type name diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9089d438..84010de9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -23509,10 +23509,19 @@ void GameHandler::saveCharacterConfig() { out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n"; } - // Save client-side macro text + // Save client-side macro text (escape newlines as \n literal) for (const auto& [id, text] : macros_) { - if (!text.empty()) - out << "macro_" << id << "_text=" << text << "\n"; + if (!text.empty()) { + std::string escaped; + escaped.reserve(text.size()); + for (char c : text) { + if (c == '\n') { escaped += "\\n"; } + else if (c == '\r') { /* skip CR */ } + else if (c == '\\') { escaped += "\\\\"; } + else { escaped += c; } + } + out << "macro_" << id << "_text=" << escaped << "\n"; + } } // Save quest log @@ -23562,8 +23571,21 @@ void GameHandler::loadCharacterConfig() { if (secondUnder == std::string::npos) continue; uint32_t macroId = 0; try { macroId = static_cast(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; } - if (key.substr(secondUnder + 1) == "text" && !val.empty()) - macros_[macroId] = val; + if (key.substr(secondUnder + 1) == "text" && !val.empty()) { + // Unescape \n and \\ sequences + std::string unescaped; + unescaped.reserve(val.size()); + for (size_t i = 0; i < val.size(); ++i) { + if (val[i] == '\\' && i + 1 < val.size()) { + if (val[i+1] == 'n') { unescaped += '\n'; ++i; } + else if (val[i+1] == '\\') { unescaped += '\\'; ++i; } + else { unescaped += val[i]; } + } else { + unescaped += val[i]; + } + } + macros_[macroId] = std::move(unescaped); + } } else if (key.rfind("action_bar_", 0) == 0) { // Parse action_bar_N_type or action_bar_N_id size_t firstUnderscore = 11; // length of "action_bar_" diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5af139af..dba74cda 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -258,6 +258,7 @@ bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabInde // Forward declaration — defined near sendChatMessage below static std::string firstMacroCommand(const std::string& macroText); +static std::vector allMacroCommands(const std::string& macroText); void GameScreen::render(game::GameHandler& gameHandler) { // Set up chat bubble callback (once) @@ -2843,12 +2844,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { gameHandler.useItemById(bar[slotIdx].id); } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { - std::string cmd = firstMacroCommand(gameHandler.getMacroText(bar[slotIdx].id)); - if (!cmd.empty()) { - strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); - chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; - sendChatMessage(gameHandler); - } + executeMacroText(gameHandler, gameHandler.getMacroText(bar[slotIdx].id)); } } } @@ -5263,6 +5259,34 @@ static std::string firstMacroCommand(const std::string& macroText) { return {}; } +// Collect all non-comment, non-empty lines from a macro body. +static std::vector allMacroCommands(const std::string& macroText) { + std::vector cmds; + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + cmds.push_back(std::move(line)); + if (nl == std::string::npos) break; + pos = nl + 1; + } + return cmds; +} + +// Execute all non-comment lines of a macro body in sequence. +// In WoW, every line executes per click; the server enforces spell-cast limits. +void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::string& macroText) { + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); + chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; + sendChatMessage(gameHandler); + } +} + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); @@ -7672,12 +7696,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); } else if (slot.type == game::ActionBarSlot::MACRO) { - std::string cmd = firstMacroCommand(gameHandler.getMacroText(slot.id)); - if (!cmd.empty()) { - strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); - chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; - sendChatMessage(gameHandler); - } + executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); } } @@ -7710,12 +7729,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextDisabled("Macro #%u", slot.id); ImGui::Separator(); if (ImGui::MenuItem("Execute")) { - std::string cmd = firstMacroCommand(gameHandler.getMacroText(slot.id)); - if (!cmd.empty()) { - strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); - chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; - sendChatMessage(gameHandler); - } + executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); } if (ImGui::MenuItem("Edit")) { const std::string& txt = gameHandler.getMacroText(slot.id); From 30513d0f06de5325c78305b0e24a3c375bb7a4bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:04:45 -0700 Subject: [PATCH 26/55] feat: implement WoW macro conditional evaluator for /cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds evaluateMacroConditionals() which parses the [cond1,cond2] Spell; [cond3] Spell2; Default syntax and returns the first matching alternative. Supported conditions: - mod:shift/ctrl/alt, nomod — keyboard modifier state - target=player/focus/target, @player/@focus/@target — target override - help / harm (noharm / nohelp) — target faction check - dead / nodead — target health check - exists / noexists — target presence check - combat / nocombat — player combat state - noform / nostance / form:0 — shapeshift/stance state - Unknown conditions are permissive (true) to avoid false negatives. /cast now resolves conditionals before spell lookup and routes castSpell() to the [target=X] override GUID when specified. isHostileFaction() exposed as isHostileFactionPublic() for UI use. --- include/game/game_handler.hpp | 1 + src/ui/game_screen.cpp | 187 +++++++++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 64709cf9..33cd0f85 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1888,6 +1888,7 @@ public: bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } + bool isHostileFactionPublic(uint32_t factionTemplateId) const { return isHostileFaction(factionTemplateId); } float getServerRunSpeed() const { return serverRunSpeed_; } float getServerWalkSpeed() const { return serverWalkSpeed_; } float getServerSwimSpeed() const { return serverSwimSpeed_; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dba74cda..67a045e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -259,6 +259,9 @@ bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabInde // Forward declaration — defined near sendChatMessage below static std::string firstMacroCommand(const std::string& macroText); static std::vector allMacroCommands(const std::string& macroText); +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); void GameScreen::render(game::GameHandler& gameHandler) { // Set up chat bubble callback (once) @@ -5277,6 +5280,170 @@ static std::vector allMacroCommands(const std::string& macroText) { return cmds; } +// --------------------------------------------------------------------------- +// WoW macro conditional evaluator +// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell +// Returns the first matching alternative's argument, or "" if none matches. +// targetOverride is set to a specific GUID if [target=X] was in the conditions, +// or left as UINT64_MAX to mean "use the normal target". +// --------------------------------------------------------------------------- +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + targetOverride = static_cast(-1); + + auto& input = core::Input::getInstance(); + + const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || + input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || + input.isKeyPressed(SDL_SCANCODE_RCTRL); + const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || + input.isKeyPressed(SDL_SCANCODE_RALT); + const bool anyMod = shiftHeld || ctrlHeld || altHeld; + + // Split rawArg on ';' → alternatives + std::vector alts; + { + std::string cur; + for (char c : rawArg) { + if (c == ';') { alts.push_back(cur); cur.clear(); } + else cur += c; + } + alts.push_back(cur); + } + + // Evaluate a single comma-separated condition token. + // tgt is updated if a target= or @ specifier is found. + auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { + std::string c = raw; + // trim + size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; + 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 (mouseover → skip, no tracking) + if (!c.empty() && c[0] == '@') { + std::string spec = c.substr(1); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + // mouseover: no tracking yet — treat as "use current target" + return true; + } + // target=X specifiers + if (c.rfind("target=", 0) == 0) { + std::string spec = c.substr(7); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + return true; + } + + // mod / nomod + if (c == "nomod" || c == "mod:none") return !anyMod; + if (c.rfind("mod:", 0) == 0) { + std::string mods = c.substr(4); + bool ok = true; + if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; + if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; + if (mods.find("alt") != std::string::npos && !altHeld) ok = false; + return ok; + } + + // combat / nocombat + if (c == "combat") return gameHandler.isInCombat(); + if (c == "nocombat") return !gameHandler.isInCombat(); + + // Helper to get the effective target entity + auto effTarget = [&]() -> std::shared_ptr { + if (tgt != static_cast(-1) && tgt != 0) + return gameHandler.getEntityManager().getEntity(tgt); + return gameHandler.getTarget(); + }; + + // exists / noexists + if (c == "exists") return effTarget() != nullptr; + if (c == "noexists") return effTarget() == nullptr; + + // dead / nodead + if (c == "dead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() == 0; + } + if (c == "nodead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() > 0; + } + + // help (friendly) / harm (hostile) and their no- variants + auto unitHostile = [&](const std::shared_ptr& t) -> bool { + if (!t) return false; + auto u = std::dynamic_pointer_cast(t); + return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); + }; + if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } + if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } + + // noform / nostance — player is NOT in a shapeshift/stance + if (c == "noform" || c == "nostance") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + // form:0 same as noform + if (c == "form:0" || c == "stance:0") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + + // Unknown → permissive (don't block) + return true; + }; + + for (auto& alt : alts) { + // trim + size_t fs = alt.find_first_not_of(" \t"); + if (fs == std::string::npos) continue; + alt = alt.substr(fs); + size_t ls = alt.find_last_not_of(" \t"); + if (ls != std::string::npos) alt.resize(ls + 1); + + if (!alt.empty() && alt[0] == '[') { + size_t close = alt.find(']'); + if (close == std::string::npos) continue; + std::string condStr = alt.substr(1, close - 1); + std::string argPart = alt.substr(close + 1); + // Trim argPart + size_t as = argPart.find_first_not_of(" \t"); + argPart = (as != std::string::npos) ? argPart.substr(as) : ""; + + // Evaluate comma-separated conditions + uint64_t tgt = static_cast(-1); + bool pass = true; + size_t cp = 0; + while (pass) { + size_t comma = condStr.find(',', cp); + std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); + if (!evalCond(tok, tgt)) { pass = false; break; } + if (comma == std::string::npos) break; + cp = comma + 1; + } + if (pass) { + if (tgt != static_cast(-1)) targetOverride = tgt; + return argPart; + } + } else { + // No condition block — default fallback always matches + return alt; + } + } + return {}; +} + // Execute all non-comment lines of a macro body in sequence. // In WoW, every line executes per click; the server enforces spell-cast limits. void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::string& macroText) { @@ -6175,6 +6342,18 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal + uint64_t castTargetOverride = static_cast(-1); + if (!spellArg.empty() && spellArg.front() == '[') { + spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); + if (spellArg.empty()) { + chatInputBuffer[0] = '\0'; + return; // No conditional matched — skip cast + } + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + } + // Support numeric spell ID: /cast 133 or /cast #133 { std::string numStr = spellArg; @@ -6186,7 +6365,9 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { uint32_t spellId = 0; try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} if (spellId != 0) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); gameHandler.castSpell(spellId, targetGuid); } chatInputBuffer[0] = '\0'; @@ -6246,7 +6427,9 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (bestSpellId) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); gameHandler.castSpell(bestSpellId, targetGuid); } else { game::MessageChatData sysMsg; From d2b2a25393c00b51c9d6dc7672fb71fe5904a0b1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:06:23 -0700 Subject: [PATCH 27/55] feat: extend macro conditionals to /use command --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 67a045e8..9bc77728 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6452,6 +6452,15 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem + if (!useArg.empty() && useArg.front() == '[') { + uint64_t dummy = static_cast(-1); + useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); + if (useArg.empty()) { chatInputBuffer[0] = '\0'; return; } + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + } + // Check for bag/slot notation: two numbers separated by whitespace { std::istringstream iss(useArg); From 7967bfdcb106e5aba827682dc11f010c6a9ca07c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:09:43 -0700 Subject: [PATCH 28/55] feat: implement [target=mouseover] macro conditional via nameplate/raid hover - Adds mouseoverGuid_ to GameHandler (set/cleared each frame by UI) - renderNameplates() sets mouseoverGuid when the cursor is inside a nameplate's hit region; resets to 0 at frame start - Raid frame cells set mouseoverGuid while hovered (IsItemHovered) - evaluateMacroConditionals() resolves @mouseover / target=mouseover to the hover GUID; returns false (skip alternative) when no unit is hovered This enables common healer macros like: /cast [target=mouseover,help,nodead] Renew; Renew --- include/game/game_handler.hpp | 5 +++++ src/ui/game_screen.cpp | 33 +++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 33cd0f85..9090a1fa 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -375,6 +375,10 @@ public: std::shared_ptr getFocus() const; bool hasFocus() const { return focusGuid != 0; } + // Mouseover targeting — set each frame by the nameplate renderer + void setMouseoverGuid(uint64_t guid) { mouseoverGuid_ = guid; } + uint64_t getMouseoverGuid() const { return mouseoverGuid_; } + // Advanced targeting void targetLastTarget(); void targetEnemy(bool reverse = false); @@ -2537,6 +2541,7 @@ private: uint64_t targetGuid = 0; uint64_t focusGuid = 0; // Focus target uint64_t lastTargetGuid = 0; // Previous target + uint64_t mouseoverGuid_ = 0; // Set each frame by nameplate renderer std::vector tabCycleList; int tabCycleIndex = -1; bool tabCycleStale = true; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9bc77728..0fa503bf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5322,21 +5322,30 @@ 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 (mouseover → skip, no tracking) + // @target specifiers: @player, @focus, @mouseover, @target if (!c.empty() && c[0] == '@') { std::string spec = c.substr(1); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - // mouseover: no tracking yet — treat as "use current target" + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } return true; } // target=X specifiers if (c.rfind("target=", 0) == 0) { std::string spec = c.substr(7); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } return true; } @@ -10042,6 +10051,9 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { void GameScreen::renderNameplates(game::GameHandler& gameHandler) { if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + // Reset mouseover each frame; we'll set it below when the cursor is over a nameplate + gameHandler.setMouseoverGuid(0); + auto* appRenderer = core::Application::getInstance().getRenderer(); if (!appRenderer) return; rendering::Camera* camera = appRenderer->getCamera(); @@ -10416,6 +10428,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float nx1 = nameX + textSize.x + 2.0f; float ny1 = sy + barH + 2.0f; if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { + // Track mouseover for [target=mouseover] macro conditionals + gameHandler.setMouseoverGuid(guid); if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { gameHandler.setTarget(guid); } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { @@ -10749,6 +10763,9 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { gameHandler.setTarget(m.guid); } + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(m.guid); + } if (ImGui::BeginPopupContextItem("RaidMemberCtx")) { ImGui::TextDisabled("%s", m.name.c_str()); ImGui::Separator(); From 28d7d3ec00a568a755fc3d3f0e6f28426928f122 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:11:34 -0700 Subject: [PATCH 29/55] feat: track mouseover on party frames; fix /cast !spell; update macro editor hint --- src/ui/game_screen.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0fa503bf..dd1683f9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6363,6 +6363,9 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); } + // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) + if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); + // Support numeric spell ID: /cast 133 or /cast #133 { std::string numStr = spellArg; @@ -8227,7 +8230,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } if (ImGui::BeginPopupModal("Edit Macro###MacroEdit", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { - ImGui::Text("Macro #%u (first line executes on click)", macroEditorId_); + ImGui::Text("Macro #%u (all lines execute; [cond] Spell; Default supported)", macroEditorId_); ImGui::SetNextItemWidth(320.0f); ImGui::InputTextMultiline("##MacroText", macroEditorBuf_, sizeof(macroEditorBuf_), ImVec2(320.0f, 80.0f)); @@ -10876,6 +10879,10 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + // Set mouseover for [target=mouseover] macro conditionals + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(member.guid); + } // Zone tooltip on name hover if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); From e2a484256cd85ab533ef54203e6e37ad49376433 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:16:05 -0700 Subject: [PATCH 30/55] feat: show spell icon on macro buttons via #showtooltip directive - getMacroShowtooltipArg() parses the #showtooltip [SpellName] directive - Action bar macro buttons now display the named spell's icon when #showtooltip SpellName is present at the top of the macro body - For bare #showtooltip (no argument), derives the icon from the first /cast line in the macro (stripping conditionals and rank suffixes) - Falls back to "Macro" text label only when no spell can be resolved --- src/ui/game_screen.cpp | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dd1683f9..16cf6e20 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -262,6 +262,9 @@ static std::vector allMacroCommands(const std::string& macroText); static std::string evaluateMacroConditionals(const std::string& rawArg, game::GameHandler& gameHandler, uint64_t& targetOverride); +// Returns the spell/item name from #showtooltip [Name], or "__auto__" for bare +// #showtooltip (use first /cast target), or "" if no directive is present. +static std::string getMacroShowtooltipArg(const std::string& macroText); void GameScreen::render(game::GameHandler& gameHandler) { // Set up chat bubble callback (once) @@ -5280,6 +5283,36 @@ static std::vector allMacroCommands(const std::string& macroText) { return cmds; } +// Returns the #showtooltip argument from a macro body: +// "#showtooltip Spell" → "Spell" +// "#showtooltip" → "__auto__" (derive from first /cast) +// (none) → "" +static std::string getMacroShowtooltipArg(const std::string& macroText) { + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t fs = line.find_first_not_of(" \t"); + if (fs != std::string::npos) line = line.substr(fs); + if (line.rfind("#showtooltip", 0) == 0 || line.rfind("#show", 0) == 0) { + size_t sp = line.find(' '); + if (sp != std::string::npos) { + std::string arg = line.substr(sp + 1); + size_t as = arg.find_first_not_of(" \t"); + if (as != std::string::npos) arg = arg.substr(as); + size_t ae = arg.find_last_not_of(" \t"); + if (ae != std::string::npos) arg.resize(ae + 1); + if (!arg.empty()) return arg; + } + return "__auto__"; + } + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + // --------------------------------------------------------------------------- // WoW macro conditional evaluator // Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell @@ -7801,6 +7834,59 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); } + // Macro icon: #showtooltip [SpellName] → show that spell's icon on the button + if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !iconTex) { + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + std::string showArg = getMacroShowtooltipArg(macroText); + if (showArg.empty() || showArg == "__auto__") { + // No explicit #showtooltip arg — derive spell from first /cast line + for (const auto& cmdLine : allMacroCommands(macroText)) { + if (cmdLine.size() < 6) continue; + std::string cl = cmdLine; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + if (cl.rfind("/cast ", 0) != 0 && cl != "/cast") continue; + size_t sp2 = cmdLine.find(' '); + if (sp2 == std::string::npos) continue; + showArg = cmdLine.substr(sp2 + 1); + // Strip conditionals [...] + if (!showArg.empty() && showArg.front() == '[') { + size_t ce = showArg.find(']'); + if (ce != std::string::npos) showArg = showArg.substr(ce + 1); + } + // Take first alternative before ';' + size_t semi = showArg.find(';'); + if (semi != std::string::npos) showArg = showArg.substr(0, semi); + // Trim and strip '!' + size_t ss = showArg.find_first_not_of(" \t!"); + if (ss != std::string::npos) showArg = showArg.substr(ss); + size_t se = showArg.find_last_not_of(" \t"); + if (se != std::string::npos) showArg.resize(se + 1); + break; + } + } + // Look up the spell icon by name + if (!showArg.empty() && showArg != "__auto__") { + std::string showLower = showArg; + for (char& c : showLower) c = static_cast(std::tolower(static_cast(c))); + // Also strip "(Rank N)" suffix for matching + size_t rankParen = showLower.find('('); + if (rankParen != std::string::npos) showLower.resize(rankParen); + while (!showLower.empty() && showLower.back() == ' ') showLower.pop_back(); + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl == showLower) { + iconTex = assetMgr ? getSpellIcon(sid, assetMgr) : VK_NULL_HANDLE; + if (iconTex) break; + } + } + } + } + } + // Item-missing check: grey out item slots whose item is not in the player's inventory. const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 && barItemDef == nullptr && !onCooldown); From 6cd3c613efa1fe81319faba85588d0034fbee0fb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:21:27 -0700 Subject: [PATCH 31/55] feat: add macro conditional support to /target and /focus commands /target [target=mouseover], /target [mod:shift] BossName; DefaultMob, /focus [target=mouseover], and /focus PlayerName all now evaluate WoW macro conditionals and resolve named/mouseover targets correctly. --- src/ui/game_screen.cpp | 87 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 16cf6e20..b765f3c1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6651,7 +6651,35 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (cmdLower == "target" && spacePos != std::string::npos) { // Search visible entities for name match (case-insensitive prefix). // Among all matches, pick the nearest living unit to the player. + // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss std::string targetArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t targetCmdOverride = static_cast(-1); + if (!targetArg.empty() && targetArg.front() == '[') { + targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); + if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { + // No condition matched — silently skip (macro fallthrough) + chatInputBuffer[0] = '\0'; + return; + } + while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); + while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); + } + + // If conditionals resolved to a specific GUID, target it directly + if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { + gameHandler.setTarget(targetCmdOverride); + chatInputBuffer[0] = '\0'; + return; + } + + // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently + if (targetArg.empty()) { + chatInputBuffer[0] = '\0'; + return; + } + std::string targetArgLower = targetArg; for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); uint64_t bestGuid = 0; @@ -6726,7 +6754,64 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "focus") { - if (gameHandler.hasTarget()) { + // /focus → set current target as focus + // /focus PlayerName → search for entity by name and set as focus + // /focus [target=X] Name → macro conditional: set focus to resolved target + if (spacePos != std::string::npos) { + std::string focusArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t focusCmdOverride = static_cast(-1); + if (!focusArg.empty() && focusArg.front() == '[') { + focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); + if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { + chatInputBuffer[0] = '\0'; + return; + } + while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); + while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); + } + + if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { + // Conditional resolved to a specific GUID (e.g. [target=mouseover]) + gameHandler.setFocus(focusCmdOverride); + } else if (!focusArg.empty()) { + // Name search — same logic as /target + std::string focusArgLower = focusArg; + for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(focusArgLower) == 0) { + float dx = entity->getX() - pmi.x; + float dy = entity->getY() - pmi.y; + float dz = entity->getZ() - pmi.z; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { bestDist = dist; bestGuid = guid; } + } + } + if (bestGuid) { + gameHandler.setFocus(bestGuid); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + focusArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } + } else if (gameHandler.hasTarget()) { gameHandler.setFocus(gameHandler.getTargetGuid()); } else { game::MessageChatData msg; From 11c07f19cbb6fb9bc8fd3beafa43c0870aca4c07 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:25:34 -0700 Subject: [PATCH 32/55] feat: add macro conditional support to /cleartarget and /startattack /cleartarget [dead] now clears target only when it meets conditions; /startattack [harm,nodead] respects conditionals including target=mouseover. --- src/ui/game_screen.cpp | 44 +++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b765f3c1..086f29af 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6353,14 +6353,29 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "startattack") { - if (gameHandler.hasTarget()) { - gameHandler.startAutoAttack(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You have no target."; - gameHandler.addLocalChatMessage(msg); + // Support macro conditionals: /startattack [harm,nodead] + bool condPass = true; + uint64_t saOverride = static_cast(-1); + if (spacePos != std::string::npos) { + std::string saArg = command.substr(spacePos + 1); + while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); + if (!saArg.empty() && saArg.front() == '[') { + std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); + condPass = !(result.empty() && saOverride == static_cast(-1)); + } + } + if (condPass) { + uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) + ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + if (atkTarget != 0) { + gameHandler.startAutoAttack(atkTarget); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You have no target."; + gameHandler.addLocalChatMessage(msg); + } } chatInputBuffer[0] = '\0'; return; @@ -6643,7 +6658,18 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // Targeting commands if (cmdLower == "cleartarget") { - gameHandler.clearTarget(); + // Support macro conditionals: /cleartarget [dead] clears only if target is dead + bool ctCondPass = true; + if (spacePos != std::string::npos) { + std::string ctArg = command.substr(spacePos + 1); + while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); + if (!ctArg.empty() && ctArg.front() == '[') { + uint64_t ctOver = static_cast(-1); + std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); + ctCondPass = !(res.empty() && ctOver == static_cast(-1)); + } + } + if (ctCondPass) gameHandler.clearTarget(); chatInputBuffer[0] = '\0'; return; } From a151531a2a4ca2d56a5f3bfadb4e005b9befce16 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:28:06 -0700 Subject: [PATCH 33/55] feat: show health bar on target-of-target in target frame The ToT health bar gives healers immediate % health readout of whoever the target is attacking, without needing to click-through to that unit. --- src/ui/game_screen.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 086f29af..af8b5f62 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4388,6 +4388,27 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (ImGui::IsItemClicked()) { gameHandler.setTarget(totGuid); } + + // Compact health bar for the ToT — essential for healers tracking boss target + if (totEnt) { + auto totUnit = std::dynamic_pointer_cast(totEnt); + if (totUnit && totUnit->getMaxHealth() > 0) { + uint32_t totHp = totUnit->getHealth(); + uint32_t totMaxHp = totUnit->getMaxHealth(); + float totPct = static_cast(totHp) / static_cast(totMaxHp); + ImVec4 totBarColor = + totPct > 0.5f ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) : + totPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, totBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char totOverlay[32]; + snprintf(totOverlay, sizeof(totOverlay), "%u%%", + static_cast(totPct * 100.0f + 0.5f)); + ImGui::ProgressBar(totPct, ImVec2(-1, 10), totOverlay); + ImGui::PopStyleColor(2); + } + } } } } From 5d4b0b0f04a3e7374de55f7901947e70a6a2b3ae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:29:48 -0700 Subject: [PATCH 34/55] feat: show target-of-focus with health bar in focus frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Healers and tanks can now see who their focus target is targeting, with a compact percentage health bar — mirrors the ToT in the target frame. --- src/ui/game_screen.cpp | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index af8b5f62..274c0e89 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5247,6 +5247,61 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { } } + // Target-of-Focus: who the focus target is currently targeting + { + uint64_t fofGuid = 0; + const auto& fFields = focus->getFields(); + auto fItLo = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (fItLo != fFields.end()) { + fofGuid = fItLo->second; + auto fItHi = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (fItHi != fFields.end()) + fofGuid |= (static_cast(fItHi->second) << 32); + } + if (fofGuid != 0) { + auto fofEnt = gameHandler.getEntityManager().getEntity(fofGuid); + std::string fofName; + ImVec4 fofColor(0.7f, 0.7f, 0.7f, 1.0f); + if (fofGuid == gameHandler.getPlayerGuid()) { + fofName = "You"; + fofColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (fofEnt) { + fofName = getEntityName(fofEnt); + uint8_t fcid = entityClassId(fofEnt.get()); + if (fcid != 0) fofColor = classColorVec4(fcid); + } + if (!fofName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(fofColor, "%s", fofName.c_str()); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Focus's target: %s\nClick to target", fofName.c_str()); + if (ImGui::IsItemClicked()) + gameHandler.setTarget(fofGuid); + + // Compact health bar for target-of-focus + if (fofEnt) { + auto fofUnit = std::dynamic_pointer_cast(fofEnt); + if (fofUnit && fofUnit->getMaxHealth() > 0) { + float fofPct = static_cast(fofUnit->getHealth()) / + static_cast(fofUnit->getMaxHealth()); + ImVec4 fofBarColor = + fofPct > 0.5f ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) : + fofPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, fofBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char fofOverlay[32]; + snprintf(fofOverlay, sizeof(fofOverlay), "%u%%", + static_cast(fofPct * 100.0f + 0.5f)); + ImGui::ProgressBar(fofPct, ImVec2(-1, 10), fofOverlay); + ImGui::PopStyleColor(2); + } + } + } + } + } + // Distance to focus target { const auto& mv = gameHandler.getMovementInfo(); From df7150503b49e672eb3ecdd132243299537cbf36 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:31:40 -0700 Subject: [PATCH 35/55] feat: /assist now accepts name and macro conditional arguments /assist TankName targets whoever TankName is targeting; /assist [target=focus] assists your focus target. Mirrors /target and /focus conditional support. --- src/ui/game_screen.cpp | 74 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 274c0e89..2e23ac1c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6088,7 +6088,79 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // /assist command if (cmdLower == "assist") { - gameHandler.assistTarget(); + // /assist → assist current target (use their target) + // /assist PlayerName → find PlayerName, target their target + // /assist [target=X] → evaluate conditional, target that entity's target + auto assistEntityTarget = [&](uint64_t srcGuid) { + auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); + if (!srcEnt) { gameHandler.assistTarget(); return; } + uint64_t atkGuid = 0; + const auto& flds = srcEnt->getFields(); + auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (iLo != flds.end()) { + atkGuid = iLo->second; + auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); + } + if (atkGuid != 0) { + gameHandler.setTarget(atkGuid); + } else { + std::string sn = getEntityName(srcEnt); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = (sn.empty() ? "Target" : sn) + " has no target."; + gameHandler.addLocalChatMessage(msg); + } + }; + + if (spacePos != std::string::npos) { + std::string assistArg = command.substr(spacePos + 1); + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + + // Evaluate conditionals if present + uint64_t assistOver = static_cast(-1); + if (!assistArg.empty() && assistArg.front() == '[') { + assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); + if (assistArg.empty() && assistOver == static_cast(-1)) { + chatInputBuffer[0] = '\0'; return; // no condition matched + } + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); + } + + if (assistOver != static_cast(-1) && assistOver != 0) { + assistEntityTarget(assistOver); + } else if (!assistArg.empty()) { + // Name search + std::string argLow = assistArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { + if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; + std::string nm = getEntityName(ent); + std::string nml = nm; + for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); + if (nml.find(argLow) != 0) continue; + float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) + + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); + if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } + } + if (bestGuid) assistEntityTarget(bestGuid); + else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + assistArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } else { + gameHandler.assistTarget(); + } + } else { + gameHandler.assistTarget(); + } chatInputBuffer[0] = '\0'; return; } From 4025e6576cee41287af3ee7e14e93d4261f42ebf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:36:05 -0700 Subject: [PATCH 36/55] feat: implement /castsequence macro command Supports: /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... Cycles through the spell list on successive button presses. State is keyed by spell list so the same sequence shared across macros stays in sync. --- src/ui/game_screen.cpp | 143 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2e23ac1c..22ab3306 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5572,6 +5572,18 @@ void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::str } } +// /castsequence persistent state — shared across all macros using the same spell list. +// Keyed by the normalized (lowercase, comma-joined) spell sequence string. +namespace { +struct CastSeqState { + size_t index = 0; + float lastPressSec = 0.0f; + uint64_t lastTargetGuid = 0; + bool lastInCombat = false; +}; +std::unordered_map s_castSeqStates; +} // namespace + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); @@ -6541,6 +6553,137 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... + // Cycles through the spell list on successive presses; resets per the reset= spec. + if (cmdLower == "castsequence" && spacePos != std::string::npos) { + std::string seqArg = command.substr(spacePos + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + + // Macro conditionals + uint64_t seqTgtOver = static_cast(-1); + if (!seqArg.empty() && seqArg.front() == '[') { + seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); + if (seqArg.empty() && seqTgtOver == static_cast(-1)) { + chatInputBuffer[0] = '\0'; return; + } + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); + } + + // Optional reset= spec (may contain slash-separated conditions: reset=5/target) + std::string resetSpec; + if (seqArg.rfind("reset=", 0) == 0) { + size_t spAfter = seqArg.find(' '); + if (spAfter != std::string::npos) { + resetSpec = seqArg.substr(6, spAfter - 6); + seqArg = seqArg.substr(spAfter + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + } + } + + // Parse comma-separated spell list + std::vector seqSpells; + { + std::string cur; + for (char c : seqArg) { + if (c == ',') { + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + cur.clear(); + } else { cur += c; } + } + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + } + if (seqSpells.empty()) { chatInputBuffer[0] = '\0'; return; } + + // Build stable key from lowercase spell list + std::string seqKey; + for (size_t k = 0; k < seqSpells.size(); ++k) { + if (k) seqKey += ','; + std::string sl = seqSpells[k]; + for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); + seqKey += sl; + } + + auto& seqState = s_castSeqStates[seqKey]; + + // Check reset conditions (slash-separated: e.g. "5/target") + float nowSec = static_cast(ImGui::GetTime()); + bool shouldReset = false; + if (!resetSpec.empty()) { + size_t rpos = 0; + while (rpos <= resetSpec.size()) { + size_t slash = resetSpec.find('/', rpos); + std::string part = (slash != std::string::npos) + ? resetSpec.substr(rpos, slash - rpos) + : resetSpec.substr(rpos); + std::string plow = part; + for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); + bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), + [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (isNum) { + float rSec = 0.0f; + try { rSec = std::stof(plow); } catch (...) {} + if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; + } else if (plow == "target") { + if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; + } else if (plow == "combat") { + if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; + } + if (slash == std::string::npos) break; + rpos = slash + 1; + } + } + if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; + + const std::string& seqSpell = seqSpells[seqState.index]; + seqState.index = (seqState.index + 1) % seqSpells.size(); + seqState.lastPressSec = nowSec; + seqState.lastTargetGuid = gameHandler.getTargetGuid(); + seqState.lastInCombat = gameHandler.isInCombat(); + + // Cast the selected spell — mirrors /cast spell lookup + std::string ssLow = seqSpell; + for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); + if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); + + uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) + ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + + // Numeric ID + if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); + bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (ssNumeric) { + uint32_t ssId = 0; + try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} + if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); + } else { + uint32_t ssBest = 0; int ssBestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl != ssLow) continue; + int sRnk = 0; + const std::string& rk = gameHandler.getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } + } + if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } + } + if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); + } + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "cast" && spacePos != std::string::npos) { std::string spellArg = command.substr(spacePos + 1); // Trim leading/trailing whitespace From 1fd3d5fdc8736772078a63ad87da6ee80adaa5f8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:50:24 -0700 Subject: [PATCH 37/55] feat: display permanent and temporary enchants in item tooltips for equipped items Tracks ITEM_ENCHANTMENT_SLOT 0 (permanent) and 1 (temporary) from item update fields in OnlineItemInfo, then looks up names from SpellItemEnchantment.dbc and renders them in both ItemDef and ItemQueryResponseData tooltip variants. --- include/game/game_handler.hpp | 12 +++++ include/ui/inventory_screen.hpp | 4 +- src/game/game_handler.cpp | 28 +++++++++-- src/ui/inventory_screen.cpp | 82 +++++++++++++++++++++++++++++++-- 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9090a1fa..04d0e78d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2125,6 +2125,16 @@ public: if (index < 0 || index >= static_cast(backpackSlotGuids_.size())) return 0; return backpackSlotGuids_[index]; } + uint64_t getEquipSlotGuid(int slot) const { + if (slot < 0 || slot >= static_cast(equipSlotGuids_.size())) return 0; + return equipSlotGuids_[slot]; + } + // Returns the permanent and temporary enchant IDs for an item by GUID (0 if unknown). + std::pair getItemEnchantIds(uint64_t guid) const { + auto it = onlineItems_.find(guid); + if (it == onlineItems_.end()) return {0, 0}; + return {it->second.permanentEnchantId, it->second.temporaryEnchantId}; + } uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; } /** @@ -2621,6 +2631,8 @@ private: uint32_t stackCount = 1; uint32_t curDurability = 0; uint32_t maxDurability = 0; + uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting) + uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons) }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index b9c30c6c..21ccdc00 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -99,7 +99,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); - void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr); + void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0); private: // Character model preview @@ -161,7 +161,7 @@ private: SlotKind kind, int backpackIndex, game::EquipSlot equipSlot, int bagIndex = -1, int bagSlotIndex = -1); - void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr); + void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0); // Held item helpers void pickupFromBackpack(game::Inventory& inv, int index); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 84010de9..50e45512 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11677,14 +11677,20 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); + const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) + ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; + auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); + auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); if (entryIt != block.fields.end() && entryIt->second != 0) { // Preserve existing info when doing partial updates OnlineItemInfo info = onlineItems_.count(block.guid) ? onlineItems_[block.guid] : OnlineItemInfo{}; info.entry = entryIt->second; - if (stackIt != block.fields.end()) info.stackCount = stackIt->second; - if (durIt != block.fields.end()) info.curDurability = durIt->second; - if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; + if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; + if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); onlineItems_[block.guid] = info; if (isNew) newItemCreated = true; @@ -12269,6 +12275,12 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); + // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset + // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). + // Slot 0 = permanent enchant (field +0), slot 1 = temp enchant (field +3). + const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; + const uint16_t itemPermEnchField = itemEnchBase; + const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; auto it = onlineItems_.find(block.guid); bool isItemInInventory = (it != onlineItems_.end()); @@ -12289,6 +12301,16 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem it->second.maxDurability = val; inventoryChanged = true; } + } else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) { + if (it->second.permanentEnchantId != val) { + it->second.permanentEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) { + if (it->second.temporaryEnchantId != val) { + it->second.temporaryEnchantId = val; + inventoryChanged = true; + } } } // Update container slot GUIDs on bag content changes diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 7b91ef22..931a2b10 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2401,12 +2401,15 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite if (ImGui::IsItemHovered() && !holdingItem) { // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; - renderItemTooltip(item, tooltipInv); + uint64_t slotGuid = 0; + if (kind == SlotKind::EQUIPMENT && gameHandler_) + slotGuid = gameHandler_->getEquipSlotGuid(static_cast(equipSlot)); + renderItemTooltip(item, tooltipInv, slotGuid); } } } -void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory) { +void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory, uint64_t itemGuid) { ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(item.quality); @@ -2915,6 +2918,42 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Weapon/armor enchant display for equipped items (reads from item update fields) + if (itemGuid != 0 && gameHandler_) { + auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); + if (permId != 0 || tempId != 0) { + static std::unordered_map s_enchNamesB; + static bool s_enchNamesLoadedB = false; + if (!s_enchNamesLoadedB && assetManager_) { + s_enchNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchNamesB[eid] = std::move(en); + } + } + } + if (permId != 0) { + auto it2 = s_enchNamesB.find(permId); + const char* ename = (it2 != s_enchNamesB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchNamesB.find(tempId); + const char* ename = (it2 != s_enchNamesB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); + } + } + } + // "Begins a Quest" line (shown in yellow-green like the game) if (item.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); @@ -3067,7 +3106,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // --------------------------------------------------------------------------- // Tooltip overload for ItemQueryResponseData (used by loot window, etc.) // --------------------------------------------------------------------------- -void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) { +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory, uint64_t itemGuid) { ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(static_cast(info.quality)); @@ -3442,6 +3481,43 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } } + // Weapon/armor enchant display for equipped items + if (itemGuid != 0 && gameHandler_) { + auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); + if (permId != 0 || tempId != 0) { + // Lazy-load SpellItemEnchantment.dbc for enchant name lookup + static std::unordered_map s_enchNames; + static bool s_enchNamesLoaded = false; + if (!s_enchNamesLoaded && assetManager_) { + s_enchNamesLoaded = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchNames[eid] = std::move(en); + } + } + } + if (permId != 0) { + auto it2 = s_enchNames.find(permId); + const char* ename = (it2 != s_enchNames.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchNames.find(tempId); + const char* ename = (it2 != s_enchNames.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); + } + } + } + // Item set membership if (info.itemSetId != 0) { // Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds) From 167e710f92a18e6bb75571b316ca421ac7cd9bb7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 03:53:59 -0700 Subject: [PATCH 38/55] feat: add /equipset macro command for saved equipment set switching /equipset equips a saved set by case-insensitive prefix match; /equipset with no argument lists available sets in chat. --- src/ui/game_screen.cpp | 48 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 22ab3306..25b5f594 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5745,7 +5745,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { " /gleader /groster /ginfo /gcreate /gdisband", "Combat: /startattack /stopattack /stopcasting /cast /duel /pvp", " /forfeit /follow /stopfollow /assist", - "Items: /use /equip ", + "Items: /use /equip /equipset [name]", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", @@ -6553,6 +6553,52 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) + // /equipset — list available sets in chat + if (cmdLower == "equipset") { + const auto& sets = gameHandler.getEquipmentSets(); + auto sysSay = [&](const std::string& msg) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = msg; + gameHandler.addLocalChatMessage(m); + }; + if (spacePos == std::string::npos) { + // No argument: list available sets + if (sets.empty()) { + sysSay("[System] No equipment sets saved."); + } else { + sysSay("[System] Equipment sets:"); + for (const auto& es : sets) + sysSay(" " + es.name); + } + } else { + std::string setName = command.substr(spacePos + 1); + while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); + while (!setName.empty() && setName.back() == ' ') setName.pop_back(); + // Case-insensitive prefix match + std::string setLower = setName; + std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); + const game::GameHandler::EquipmentSetInfo* found = nullptr; + for (const auto& es : sets) { + std::string nameLow = es.name; + std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); + if (nameLow == setLower || nameLow.find(setLower) == 0) { + found = &es; + break; + } + } + if (found) { + gameHandler.useEquipmentSet(found->setId); + } else { + sysSay("[System] No equipment set matching '" + setName + "'."); + } + } + chatInputBuffer[0] = '\0'; + return; + } + // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... // Cycles through the spell list on successive presses; resets per the reset= spec. if (cmdLower == "castsequence" && spacePos != std::string::npos) { From d7c377292eb31b44256b123d24174a2834672379 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 04:04:23 -0700 Subject: [PATCH 39/55] feat: show socket gems and consolidate enchant name DBC cache in item tooltips Extends OnlineItemInfo to track gem enchant IDs (socket slots 2-4) from item update fields; socket display now shows inserted gem name inline (e.g. "Red Socket: Bold Scarlet Ruby"). Consolidates redundant SpellItemEnchantment DBC loads into one shared static per tooltip variant. --- include/game/game_handler.hpp | 7 ++ src/game/game_handler.cpp | 44 ++++++-- src/ui/inventory_screen.cpp | 198 ++++++++++++++++------------------ 3 files changed, 133 insertions(+), 116 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 04d0e78d..a9f69c94 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2135,6 +2135,12 @@ public: if (it == onlineItems_.end()) return {0, 0}; return {it->second.permanentEnchantId, it->second.temporaryEnchantId}; } + // Returns the socket gem enchant IDs (3 slots; 0 = empty socket) for an item by GUID. + std::array getItemSocketEnchantIds(uint64_t guid) const { + auto it = onlineItems_.find(guid); + if (it == onlineItems_.end()) return {}; + return it->second.socketEnchantIds; + } uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; } /** @@ -2633,6 +2639,7 @@ private: uint32_t maxDurability = 0; uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting) uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons) + std::array socketEnchantIds{}; // ITEM_ENCHANTMENT_SLOT 2-4 (gems) }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 50e45512..664be8fe 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11679,18 +11679,24 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; - auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); - auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); + auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); + auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); + auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end(); + auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end(); + auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); if (entryIt != block.fields.end() && entryIt->second != 0) { // Preserve existing info when doing partial updates OnlineItemInfo info = onlineItems_.count(block.guid) ? onlineItems_[block.guid] : OnlineItemInfo{}; info.entry = entryIt->second; - if (stackIt != block.fields.end()) info.stackCount = stackIt->second; - if (durIt != block.fields.end()) info.curDurability = durIt->second; - if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; - if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; - if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; + if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; + if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; + if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; + if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; + if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second; bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); onlineItems_[block.guid] = info; if (isNew) newItemCreated = true; @@ -12277,10 +12283,13 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). - // Slot 0 = permanent enchant (field +0), slot 1 = temp enchant (field +3). - const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; + // Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12). + const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; const uint16_t itemPermEnchField = itemEnchBase; - const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; + const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; + const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF; + const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; + const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; auto it = onlineItems_.find(block.guid); bool isItemInInventory = (it != onlineItems_.end()); @@ -12311,6 +12320,21 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem it->second.temporaryEnchantId = val; inventoryChanged = true; } + } else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) { + if (it->second.socketEnchantIds[0] != val) { + it->second.socketEnchantIds[0] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) { + if (it->second.socketEnchantIds[1] != val) { + it->second.socketEnchantIds[1] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) { + if (it->second.socketEnchantIds[2] != val) { + it->second.socketEnchantIds[2] = val; + inventoryChanged = true; + } } } // Update container slot GUIDs on bag content changes diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 931a2b10..23c1ae92 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2410,6 +2410,27 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory, uint64_t itemGuid) { + // Shared SpellItemEnchantment name lookup — used for socket gems, permanent and temp enchants. + static std::unordered_map s_enchLookupB; + static bool s_enchLookupLoadedB = false; + if (!s_enchLookupLoadedB && assetManager_) { + s_enchLookupLoadedB = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchLookupB[eid] = std::move(en); + } + } + } + ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(item.quality); @@ -2794,39 +2815,33 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, }; + // Get socket gem enchant IDs for this item (filled from item update fields) + std::array sockGems{}; + if (itemGuid != 0 && gameHandler_) + sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid); + bool hasSocket = false; for (int i = 0; i < 3; ++i) { if (qi2->socketColor[i] == 0) continue; if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } for (const auto& st : kSocketTypes) { if (qi2->socketColor[i] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); + if (sockGems[i] != 0) { + auto git = s_enchLookupB.find(sockGems[i]); + if (git != s_enchLookupB.end()) + ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str()); + else + ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]); + } else { + ImGui::TextColored(st.col, "%s", st.label); + } break; } } } if (hasSocket && qi2->socketBonus != 0) { - static std::unordered_map s_enchantNamesD; - static bool s_enchantNamesLoadedD = false; - if (!s_enchantNamesLoadedD && assetManager_) { - s_enchantNamesLoadedD = true; - auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNamesD[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNamesD.find(qi2->socketBonus); - if (enchIt != s_enchantNamesD.end()) + auto enchIt = s_enchLookupB.find(qi2->socketBonus); + if (enchIt != s_enchLookupB.end()) ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); else ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", qi2->socketBonus); @@ -2921,36 +2936,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // Weapon/armor enchant display for equipped items (reads from item update fields) if (itemGuid != 0 && gameHandler_) { auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); - if (permId != 0 || tempId != 0) { - static std::unordered_map s_enchNamesB; - static bool s_enchNamesLoadedB = false; - if (!s_enchNamesLoadedB && assetManager_) { - s_enchNamesLoadedB = true; - auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nf = lay ? lay->field("Name") : 8u; - if (nf == 0xFFFFFFFF) nf = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nf >= fc) continue; - std::string en = dbc->getString(r, nf); - if (!en.empty()) s_enchNamesB[eid] = std::move(en); - } - } - } - if (permId != 0) { - auto it2 = s_enchNamesB.find(permId); - const char* ename = (it2 != s_enchNamesB.end()) ? it2->second.c_str() : nullptr; - if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); - } - if (tempId != 0) { - auto it2 = s_enchNamesB.find(tempId); - const char* ename = (it2 != s_enchNamesB.end()) ? it2->second.c_str() : nullptr; - if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); - } + if (permId != 0) { + auto it2 = s_enchLookupB.find(permId); + const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchLookupB.find(tempId); + const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); } } @@ -3107,6 +3101,27 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // Tooltip overload for ItemQueryResponseData (used by loot window, etc.) // --------------------------------------------------------------------------- void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory, uint64_t itemGuid) { + // Shared SpellItemEnchantment name lookup — used for socket gems, socket bonus, and enchants. + static std::unordered_map s_enchLookup; + static bool s_enchLookupLoaded = false; + if (!s_enchLookupLoaded && assetManager_) { + s_enchLookupLoaded = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchLookup[eid] = std::move(en); + } + } + } + ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(static_cast(info.quality)); @@ -3441,40 +3456,33 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, }; + // Get socket gem enchant IDs for this item (filled from item update fields) + std::array sockGems{}; + if (itemGuid != 0 && gameHandler_) + sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid); + bool hasSocket = false; for (int i = 0; i < 3; ++i) { if (info.socketColor[i] == 0) continue; if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } for (const auto& st : kSocketTypes) { if (info.socketColor[i] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); + if (sockGems[i] != 0) { + auto git = s_enchLookup.find(sockGems[i]); + if (git != s_enchLookup.end()) + ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str()); + else + ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]); + } else { + ImGui::TextColored(st.col, "%s", st.label); + } break; } } } if (hasSocket && info.socketBonus != 0) { - // Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc - static std::unordered_map s_enchantNames; - static bool s_enchantNamesLoaded = false; - if (!s_enchantNamesLoaded && assetManager_) { - s_enchantNamesLoaded = true; - auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNames[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNames.find(info.socketBonus); - if (enchIt != s_enchantNames.end()) + auto enchIt = s_enchLookup.find(info.socketBonus); + if (enchIt != s_enchLookup.end()) ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); else ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus); @@ -3484,37 +3492,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, // Weapon/armor enchant display for equipped items if (itemGuid != 0 && gameHandler_) { auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); - if (permId != 0 || tempId != 0) { - // Lazy-load SpellItemEnchantment.dbc for enchant name lookup - static std::unordered_map s_enchNames; - static bool s_enchNamesLoaded = false; - if (!s_enchNamesLoaded && assetManager_) { - s_enchNamesLoaded = true; - auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nf = lay ? lay->field("Name") : 8u; - if (nf == 0xFFFFFFFF) nf = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nf >= fc) continue; - std::string en = dbc->getString(r, nf); - if (!en.empty()) s_enchNames[eid] = std::move(en); - } - } - } - if (permId != 0) { - auto it2 = s_enchNames.find(permId); - const char* ename = (it2 != s_enchNames.end()) ? it2->second.c_str() : nullptr; - if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); - } - if (tempId != 0) { - auto it2 = s_enchNames.find(tempId); - const char* ename = (it2 != s_enchNames.end()) ? it2->second.c_str() : nullptr; - if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); - } + if (permId != 0) { + auto it2 = s_enchLookup.find(permId); + const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchLookup.find(tempId); + const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); } } From 09b0bea981c0adc6f66550c533a06f646455c352 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 04:14:44 -0700 Subject: [PATCH 40/55] feat: add /stopmacro support and low durability warning for equipped items - /stopmacro [conditions] halts remaining macro commands; supports all existing macro conditionals ([combat], [nocombat], [mod:shift], etc.) via the sentinel action trick on evaluateMacroConditionals - macroStopped_ flag in GameScreen; executeMacroText resets and checks it after each command so /stopmacro mid-macro skips all subsequent lines - Emit a "X is about to break!" UI error + system chat when an equipped item's durability drops below 20% via SMSG_UPDATE_OBJECT field delta; warning fires once per threshold crossing (prevDur >= maxDur/5, newDur < maxDur/5) --- include/ui/game_screen.hpp | 3 +++ src/game/game_handler.cpp | 22 ++++++++++++++++++++++ src/ui/game_screen.cpp | 27 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0b958cb2..e7bc42dc 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -55,6 +55,9 @@ private: std::vector chatSentHistory_; int chatHistoryIdx_ = -1; // -1 = not browsing history + // Set to true by /stopmacro; checked in executeMacroText to halt remaining commands. + bool macroStopped_ = false; + // Tab-completion state for slash commands std::string chatTabPrefix_; // prefix captured on first Tab press std::vector chatTabMatches_; // matching command list diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 664be8fe..16393cb0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12302,8 +12302,30 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem } } else if (key == itemDurField && isItemInInventory) { if (it->second.curDurability != val) { + const uint32_t prevDur = it->second.curDurability; it->second.curDurability = val; inventoryChanged = true; + // Warn once when durability drops below 20% for an equipped item. + const uint32_t maxDur = it->second.maxDurability; + if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { + // Check if this item is in an equip slot (not bag inventory). + bool isEquipped = false; + for (uint64_t slotGuid : equipSlotGuids_) { + if (slotGuid == block.guid) { isEquipped = true; break; } + } + if (isEquipped) { + std::string itemName; + const auto* info = getItemInfo(it->second.entry); + if (info) itemName = info->name; + char buf[128]; + if (!itemName.empty()) + std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str()); + else + std::snprintf(buf, sizeof(buf), "An equipped item is about to break!"); + addUIError(buf); + addSystemChatMessage(buf); + } + } } } else if (key == itemMaxDurField && isItemInInventory) { if (it->second.maxDurability != val) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 25b5f594..7338da94 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5564,12 +5564,16 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, // Execute all non-comment lines of a macro body in sequence. // In WoW, every line executes per click; the server enforces spell-cast limits. +// /stopmacro (with optional conditionals) halts the remaining commands early. void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::string& macroText) { + macroStopped_ = false; for (const auto& cmd : allMacroCommands(macroText)) { strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; sendChatMessage(gameHandler); + if (macroStopped_) break; } + macroStopped_ = false; } // /castsequence persistent state — shared across all macros using the same spell list. @@ -5633,6 +5637,29 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /stopmacro [conditions] + // Halts execution of the current macro (remaining lines are skipped). + // With a condition block, only stops if the conditions evaluate to true. + // /stopmacro → always stops + // /stopmacro [combat] → stops only while in combat + // /stopmacro [nocombat] → stops only when not in combat + if (cmdLower == "stopmacro") { + bool shouldStop = true; + if (spacePos != std::string::npos) { + std::string condArg = command.substr(spacePos + 1); + while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); + if (!condArg.empty() && condArg.front() == '[') { + // Append a sentinel action so evaluateMacroConditionals can signal a match. + uint64_t tgtOver = static_cast(-1); + std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); + shouldStop = !hit.empty(); + } + } + if (shouldStop) macroStopped_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /invite command if (cmdLower == "invite" && spacePos != std::string::npos) { std::string targetName = command.substr(spacePos + 1); From c1765b6b39d8e3ddb6e7031d0ed8fada724df0bc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 04:25:37 -0700 Subject: [PATCH 41/55] fix: defer loot item notification until item name is known from server query When SMSG_ITEM_PUSH_RESULT arrives for an item not yet in the cache, store a PendingItemPushNotif and fire the 'Received: [item]' chat message only after SMSG_ITEM_QUERY_SINGLE_RESPONSE resolves the name and quality, so the notification always shows a proper item link instead of 'item #12345'. Notifications that are already cached emit immediately as before; multiple pending notifs for the same item are all flushed on the single response. --- include/game/game_handler.hpp | 8 ++++++ src/game/game_handler.cpp | 49 ++++++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a9f69c94..38579ff5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2644,6 +2644,14 @@ private: std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; std::unordered_set pendingItemQueries_; + + // Deferred SMSG_ITEM_PUSH_RESULT notifications for items whose info wasn't + // cached at arrival time; emitted once the query response arrives. + struct PendingItemPushNotif { + uint32_t itemId = 0; + uint32_t count = 1; + }; + std::vector pendingItemPushNotifs_; std::array equipSlotGuids_{}; std::array backpackSlotGuids_{}; std::array keyringSlotGuids_{}; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 16393cb0..80a50af0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1956,22 +1956,22 @@ void GameHandler::handlePacket(network::Packet& packet) { queryItemInfo(itemId, 0); if (showInChat) { - std::string itemName = "item #" + std::to_string(itemId); - uint32_t quality = 1; // white default if (const ItemQueryResponseData* info = getItemInfo(itemId)) { - if (!info->name.empty()) itemName = info->name; - quality = info->quality; - } - std::string link = buildItemLink(itemId, quality, itemName); - std::string msg = "Received: " + link; - if (count > 1) msg += " x" + std::to_string(count); - addSystemChatMessage(msg); - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* sfx = renderer->getUiSoundManager()) - sfx->playLootItem(); - } - if (itemLootCallback_) { - itemLootCallback_(itemId, count, quality, itemName); + // Item info already cached — emit immediately. + std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name; + uint32_t quality = info->quality; + std::string link = buildItemLink(itemId, quality, itemName); + std::string msg = "Received: " + link; + if (count > 1) msg += " x" + std::to_string(count); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } + if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName); + } else { + // Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE. + pendingItemPushNotifs_.push_back({itemId, count}); } } LOG_INFO("Item push: itemId=", itemId, " count=", count, @@ -14491,6 +14491,25 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { rebuildOnlineInventory(); maybeDetectVisibleItemLayout(); + // Flush any deferred loot notifications waiting on this item's name/quality. + for (auto it = pendingItemPushNotifs_.begin(); it != pendingItemPushNotifs_.end(); ) { + if (it->itemId == data.entry) { + std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name; + std::string link = buildItemLink(data.entry, data.quality, itemName); + std::string msg = "Received: " + link; + if (it->count > 1) msg += " x" + std::to_string(it->count); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } + if (itemLootCallback_) itemLootCallback_(data.entry, it->count, data.quality, itemName); + it = pendingItemPushNotifs_.erase(it); + } else { + ++it; + } + } + // Selectively re-emit only players whose equipment references this item entry const uint32_t resolvedEntry = data.entry; for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) { From 277a26b35105e77edaf622c6453caa91f9e683a2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 04:30:33 -0700 Subject: [PATCH 42/55] feat: flash action bar button red when spell cast fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SpellCastFailedCallback to GameHandler, fired from SMSG_CAST_RESULT when result != 0. GameScreen registers the callback and records each failed spellId in actionFlashEndTimes_ (keyed by spell ID, value = expiry time). During action bar rendering, if a slot's spell has an active flash entry, an AddRectFilled overlay is drawn over the button with alpha proportional to remaining time (1.0→0.0 over 0.5 s), giving the same error-red flash visual feedback as the original WoW client. --- include/game/game_handler.hpp | 5 +++++ include/ui/game_screen.hpp | 6 ++++++ src/game/game_handler.cpp | 1 + src/ui/game_screen.cpp | 29 +++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 38579ff5..1a40d5d8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -939,6 +939,10 @@ public: using SpellCastAnimCallback = std::function; void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } + // Fired when the player's own spell cast fails (spellId of the failed spell). + using SpellCastFailedCallback = std::function; + void setSpellCastFailedCallback(SpellCastFailedCallback cb) { spellCastFailedCallback_ = std::move(cb); } + // Unit animation hint: signal jump (animId=38) for other players/NPCs using UnitAnimHintCallback = std::function; void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); } @@ -3309,6 +3313,7 @@ private: MeleeSwingCallback meleeSwingCallback_; uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing SpellCastAnimCallback spellCastAnimCallback_; + SpellCastFailedCallback spellCastFailedCallback_; UnitAnimHintCallback unitAnimHintCallback_; UnitMoveFlagsCallback unitMoveFlagsCallback_; NpcSwingCallback npcSwingCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e7bc42dc..e76d2e12 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -58,6 +58,10 @@ private: // Set to true by /stopmacro; checked in executeMacroText to halt remaining commands. bool macroStopped_ = false; + // Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends. + // Populated by the SpellCastFailedCallback; queried during action bar button rendering. + std::unordered_map actionFlashEndTimes_; + // Tab-completion state for slash commands std::string chatTabPrefix_; // prefix captured on first Tab press std::vector chatTabMatches_; // matching command list @@ -109,6 +113,8 @@ private: std::vector uiErrors_; bool uiErrorCallbackSet_ = false; static constexpr float kUIErrorLifetime = 2.5f; + bool castFailedCallbackSet_ = false; + static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade // Reputation change toast: brief colored slide-in below minimap struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 80a50af0..f0456c5e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2267,6 +2267,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::string errMsg = reason ? reason : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addUIError(errMsg); + if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7338da94..0e74ab18 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -414,6 +414,16 @@ void GameScreen::render(game::GameHandler& gameHandler) { uiErrorCallbackSet_ = true; } + // Flash the action bar button whose spell just failed (0.5 s red overlay). + if (!castFailedCallbackSet_) { + gameHandler.setSpellCastFailedCallback([this](uint32_t spellId) { + if (spellId == 0) return; + float now = static_cast(ImGui::GetTime()); + actionFlashEndTimes_[spellId] = now + kActionFlashDuration; + }); + castFailedCallbackSet_ = true; + } + // Set up reputation change toast callback (once) if (!repChangeCallbackSet_) { gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) { @@ -8435,6 +8445,25 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Error-flash overlay: red fade on spell cast failure (~0.5 s). + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { + auto flashIt = actionFlashEndTimes_.find(slot.id); + if (flashIt != actionFlashEndTimes_.end()) { + float now = static_cast(ImGui::GetTime()); + float remaining = flashIt->second - now; + if (remaining > 0.0f) { + float alpha = remaining / kActionFlashDuration; // 1→0 + ImVec2 rMin = ImGui::GetItemRectMin(); + ImVec2 rMax = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddRectFilled( + rMin, rMax, + ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.1f, 0.1f, 0.55f * alpha))); + } else { + actionFlashEndTimes_.erase(flashIt); + } + } + } + bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseReleased(ImGuiMouseButton_Left); From 5f3bc79653fada36667add35e55008a91c698e84 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 04:34:36 -0700 Subject: [PATCH 43/55] feat: show queued spell icon in cast bar and expose getQueuedSpellId() When a spell is queued in the 400ms window before the current cast ends, render its icon dimmed (0.8 alpha) to the right of the cast bar progress, with a "Queued: " tooltip. The progress bar shrinks to accommodate the icon when one is present. Also exposes getQueuedSpellId() as a public const accessor on GameHandler so the UI can observe the spell queue state without friend access. --- include/game/game_handler.hpp | 3 +++ src/ui/game_screen.cpp | 25 ++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1a40d5d8..b1d732ef 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -807,6 +807,9 @@ public: int getCraftQueueRemaining() const { return craftQueueRemaining_; } uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; } + // 400ms spell-queue window: next spell to cast when current finishes + uint32_t getQueuedSpellId() const { return queuedSpellId_; } + // Unit cast state (tracked per GUID for target frame + boss frames) struct UnitCastState { bool casting = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0e74ab18..b81405cc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9732,13 +9732,32 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { } } + // Queued spell icon (right edge): the next spell queued to fire within 400ms. + uint32_t queuedId = gameHandler.getQueuedSpellId(); + VkDescriptorSet queuedTex = (queuedId != 0 && assetMgr) + ? getSpellIcon(queuedId, assetMgr) : VK_NULL_HANDLE; + + const float iconSz = 20.0f; + const float reservedRight = (queuedTex) ? (iconSz + 4.0f) : 0.0f; + if (iconTex) { // Spell icon to the left of the progress bar - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(20, 20)); + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(iconSz, iconSz)); ImGui::SameLine(0, 4); - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); } else { - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); + } + // Draw queued-spell icon on the right with a ">" arrow prefix tooltip. + if (queuedTex) { + ImGui::SameLine(0, 4); + ImGui::Image((ImTextureID)(uintptr_t)queuedTex, ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,0.8f), ImVec4(0,0,0,0)); // slightly dimmed + if (ImGui::IsItemHovered()) { + const std::string& qn = gameHandler.getSpellName(queuedId); + ImGui::SetTooltip("Queued: %s", qn.empty() ? "Unknown" : qn.c_str()); + } } ImGui::PopStyleColor(); } From 586408516ba479ee2a3d2f087486761c786b453a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 04:42:21 -0700 Subject: [PATCH 44/55] fix: correct character geoset group ranges for other-player equipment rendering setOnlinePlayerEquipment used wrong geoset ID ranges for boots (402+ instead of 501+), gloves (301+ instead of 401+), and chest/sleeves (501+ instead of 801+), and was missing bare-shin (502), bare-wrist (801), and bare-leg (1301) defaults. This caused other players to render with missing shin/wrist geometry and wrong geosets when wearing equipment (the "shin mesh" gap in status.md). Now mirrors the CharacterPreview::applyEquipment logic exactly: - Group 4 (4xx) forearms/gloves: default 401, equipment 401+gg - Group 5 (5xx) shins/boots: default 502, equipment 501+gg - Group 8 (8xx) wrists/sleeves: default 801, equipment 801+gg - Group 13 (13xx) legs/pants: default 1301, equipment 1301+gg --- src/core/application.cpp | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 22e93abc..4ff3aae1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -6908,6 +6908,10 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, }; // --- Geosets --- + // Mirror the same group-range logic as CharacterPreview::applyEquipment to + // keep other-player rendering consistent with the local character preview. + // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, + // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). std::unordered_set geosets; // Body parts (group 0: IDs 0-99, some models use up to 27) for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); @@ -6915,8 +6919,6 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); geosets.insert(static_cast(100 + hairStyleId + 1)); geosets.insert(static_cast(200 + st.facialFeatures + 1)); - geosets.insert(401); // Body joint patches (knees) - geosets.insert(402); // Body joint patches (elbows) geosets.insert(701); // Ears geosets.insert(902); // Kneepads geosets.insert(2002); // Bare feet mesh @@ -6924,39 +6926,47 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; - // Chest/Shirt/Robe (invType 4,5,20) + // Per-group defaults — overridden below when equipment provides a geoset value. + uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves) + uint16_t geosetBoots = 502; // Bare shins (group 5, no boots) + uint16_t geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves) + uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings) + + // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 { uint32_t did = findDisplayIdByInvType({4, 5, 20}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 501 + gg1 : 501)); - + if (gg1 > 0) geosetSleeves = static_cast(801 + gg1); + // Robe kilt → leg group 13 uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); - if (gg3 > 0) geosets.insert(static_cast(1301 + gg3)); + if (gg3 > 0) geosetPants = static_cast(1301 + gg3); } - // Legs (invType 7) + // Legs (invType 7) → leg group 13 { uint32_t did = findDisplayIdByInvType({7}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { - geosets.insert(static_cast(gg1 > 0 ? 1301 + gg1 : 1301)); - } + if (gg1 > 0) geosetPants = static_cast(1301 + gg1); } - // Feet (invType 8): 401/402 are body patches (always on), 403+ are boot meshes + // Feet/Boots (invType 8) → shin group 5 { uint32_t did = findDisplayIdByInvType({8}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosets.insert(static_cast(402 + gg1)); + if (gg1 > 0) geosetBoots = static_cast(501 + gg1); } - // Hands (invType 10) + // Hands/Gloves (invType 10) → forearm group 4 { uint32_t did = findDisplayIdByInvType({10}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 301 + gg1 : 301)); + if (gg1 > 0) geosetGloves = static_cast(401 + gg1); } + geosets.insert(geosetGloves); + geosets.insert(geosetBoots); + geosets.insert(geosetSleeves); + geosets.insert(geosetPants); // Back/Cloak (invType 16) geosets.insert(hasInvType({16}) ? 1502 : 1501); // Tabard (invType 19) From e7fe35c1f90e0bdd0147380b643ccab25e9273f5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:08:10 -0700 Subject: [PATCH 45/55] feat: add right-click pet spell autocast toggle via CMSG_PET_SPELL_AUTOCAST Right-clicking a castable pet ability (actionId > 6) in the pet action bar now sends CMSG_PET_SPELL_AUTOCAST to toggle the spell's autocast state. The local petAutocastSpells_ set is updated optimistically and the tooltip shows the current state with a right-click hint. --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 18 ++++++++++++++++++ src/ui/game_screen.cpp | 10 ++++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b1d732ef..fc592b4e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -746,6 +746,8 @@ public: } // Send CMSG_PET_ACTION to issue a pet command void sendPetAction(uint32_t action, uint64_t targetGuid = 0); + // Toggle autocast for a pet spell via CMSG_PET_SPELL_AUTOCAST + void togglePetSpellAutocast(uint32_t spellId); const std::unordered_set& getKnownSpells() const { return knownSpells; } // ---- Pet Stable ---- diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f0456c5e..915de132 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18291,6 +18291,24 @@ void GameHandler::dismissPet() { socket->send(packet); } +void GameHandler::togglePetSpellAutocast(uint32_t spellId) { + if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return; + bool currentlyOn = petAutocastSpells_.count(spellId) != 0; + uint8_t newState = currentlyOn ? 0 : 1; + // CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1) + network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST)); + pkt.writeUInt64(petGuid_); + pkt.writeUInt32(spellId); + pkt.writeUInt8(newState); + socket->send(pkt); + // Optimistically update local state; server will confirm via SMSG_PET_SPELLS + if (newState) + petAutocastSpells_.insert(spellId); + else + petAutocastSpells_.erase(spellId); + LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState); +} + void GameHandler::renamePet(const std::string& newName) { if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return; if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b81405cc..0e1dcf1e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3899,6 +3899,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; gameHandler.sendPetAction(slotVal, targetGuid); } + // Right-click toggles autocast for castable pet spells (actionId > 6) + if (actionId > 6 && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + gameHandler.togglePetSpellAutocast(actionId); + } // Tooltip: rich spell info for pet spells, simple label for built-in commands if (ImGui::IsItemHovered()) { @@ -3920,8 +3924,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (nm.empty()) nm = "Spell #" + std::to_string(actionId); ImGui::Text("%s", nm.c_str()); } - if (autocastOn) - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); + ImGui::TextColored(autocastOn + ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "Autocast: %s (right-click to toggle)", autocastOn ? "On" : "Off"); if (petOnCd) { if (petCd >= 60.0f) ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), From a802e05091ca388158c8659f7ea282ca5155393a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:16:14 -0700 Subject: [PATCH 46/55] feat: add /mark slash command for setting raid target icons Adds /mark [icon], /marktarget, and /raidtarget slash commands that set a raid mark on the current target. Accepts icon names (star, circle, diamond, triangle, moon, square, cross, skull), numbers 1-8, or "clear"/"none" to remove the mark. Defaults to skull when no argument is given. --- src/ui/game_screen.cpp | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0e1dcf1e..06827659 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6519,6 +6519,57 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /mark [icon] — set or clear a raid target mark on the current target. + // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull + // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) + // /mark — no arg marks with skull (icon 7) + if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { + if (!gameHandler.hasTarget()) { + game::MessageChatData noTgt; + noTgt.type = game::ChatType::SYSTEM; + noTgt.language = game::ChatLanguage::UNIVERSAL; + noTgt.message = "No target selected."; + gameHandler.addLocalChatMessage(noTgt); + chatInputBuffer[0] = '\0'; + return; + } + static const char* kMarkWords[] = { + "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" + }; + uint8_t icon = 7; // default: skull + if (spacePos != std::string::npos) { + std::string arg = command.substr(spacePos + 1); + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + std::string argLow = arg; + for (auto& c : argLow) c = static_cast(std::tolower(c)); + if (argLow == "clear" || argLow == "0" || argLow == "none") { + gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); + chatInputBuffer[0] = '\0'; + return; + } + bool found = false; + for (int mi = 0; mi < 8; ++mi) { + if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } + } + if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { + icon = static_cast(argLow[0] - '1'); + found = true; + } + if (!found) { + game::MessageChatData badArg; + badArg.type = game::ChatType::SYSTEM; + badArg.language = game::ChatLanguage::UNIVERSAL; + badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; + gameHandler.addLocalChatMessage(badArg); + chatInputBuffer[0] = '\0'; + return; + } + } + gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); + chatInputBuffer[0] = '\0'; + return; + } + // Combat and Trade commands if (cmdLower == "duel") { if (gameHandler.hasTarget()) { From 4e13a344e8ae2ce5d898c99b241f24f3d7e513ba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:20:15 -0700 Subject: [PATCH 47/55] feat: add buff:/nobuff:/debuff:/nodebuff: macro conditionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macro conditions now support checking aura presence: [buff:Power Word: Fortitude] — player has the named buff [nobuff:Frost Armor] — player does NOT have the named buff [debuff:Faerie Fire] — target has the named debuff [nodebuff:Hunter's Mark] — target does NOT have the named debuff Name matching is case-insensitive. When a target override (@target etc.) is active the check uses that unit's aura list instead of the player's. --- src/ui/game_screen.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 06827659..299464a5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5534,6 +5534,41 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, return true; } + // buff:SpellName / nobuff:SpellName — check if the effective target (or player + // if no target specified) has a buff with the given name. + // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). + auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, + bool negate) -> bool { + // Determine which aura list to check: effective target or player + const std::vector* auras = nullptr; + if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { + // Check target's auras + auras = &gameHandler.getTargetAuras(); + } else { + auras = &gameHandler.getPlayerAuras(); + } + std::string nameLow = spellName; + for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); + for (const auto& a : *auras) { + if (a.isEmpty() || a.spellId == 0) continue; + // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type + bool isDebuff = (a.flags & 0x80) != 0; + if (wantDebuff ? !isDebuff : isDebuff) continue; + std::string sn = gameHandler.getSpellName(a.spellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + if (sn == nameLow) return !negate; + } + return negate; + }; + if (c.rfind("buff:", 0) == 0 && c.size() > 5) + return checkAuraByName(c.substr(5), false, false); + if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), false, true); + if (c.rfind("debuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), true, false); + if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) + return checkAuraByName(c.substr(9), true, true); + // Unknown → permissive (don't block) return true; }; From d0f544395e01457669aacfa17a689ecc5e40e8e1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:23:32 -0700 Subject: [PATCH 48/55] feat: add mounted/group/channeling/casting/vehicle macro conditionals Extends evaluateMacroConditionals() with [mounted], [nomounted], [group], [nogroup], [raid], [channeling], [nochanneling], [channeling:SpellName], [casting], [nocasting], [vehicle], [novehicle]. --- src/ui/game_screen.cpp | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 299464a5..bf4920a5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5569,6 +5569,39 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) return checkAuraByName(c.substr(9), true, true); + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // group (any group) / nogroup / raid + if (c == "group") return !gameHandler.getPartyData().isEmpty(); + if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); + if (c == "raid") { + const auto& pd = gameHandler.getPartyData(); + return pd.groupType >= 1; // groupType 1 = raid, 0 = party + } + + // channeling:SpellName — player is currently channeling that spell + if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { + if (!gameHandler.isChanneling()) return false; + std::string want = c.substr(11); + for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); + uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); + std::string sn = gameHandler.getSpellName(castSpellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + return sn == want; + } + if (c == "channeling") return gameHandler.isChanneling(); + if (c == "nochanneling") return !gameHandler.isChanneling(); + + // casting (any active cast or channel) + if (c == "casting") return gameHandler.isCasting(); + if (c == "nocasting") return !gameHandler.isCasting(); + + // vehicle / novehicle (WotLK) + if (c == "vehicle") return gameHandler.getVehicleId() != 0; + if (c == "novehicle") return gameHandler.getVehicleId() == 0; + // Unknown → permissive (don't block) return true; }; From 90843ea98963855626e82ac470c0ca6f176a866e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:35:23 -0700 Subject: [PATCH 49/55] fix: don't set releasedSpirit_ optimistically in releaseSpirit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting releasedSpirit_=true immediately on CMSG_REPOP_REQUEST raced with PLAYER_FLAGS field updates that arrive from the server before it processes the repop: the PLAYER_FLAGS handler saw wasGhost=true / nowGhost=false and fired the 'ghost cleared' path, wiping corpseMapId_ and corpseGuid_ — so the minimap skull marker and the Resurrect from Corpse dialog never appeared. Ghost state is now driven entirely by the server-confirmed PLAYER_FLAGS GHOST bit (and the login-as-ghost path), eliminating the race. --- src/game/game_handler.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 915de132..465e6c5c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14050,7 +14050,11 @@ void GameHandler::releaseSpirit() { } auto packet = RepopRequestPacket::build(); socket->send(packet); - releasedSpirit_ = true; + // Do NOT set releasedSpirit_ = true here. Setting it optimistically races + // with PLAYER_FLAGS field updates that arrive before the server processes + // CMSG_REPOP_REQUEST: the PLAYER_FLAGS handler sees wasGhost=true/nowGhost=false + // and fires the "ghost cleared" path, wiping corpseMapId_/corpseGuid_. + // Let the server drive ghost state via PLAYER_FLAGS_GHOST (field update path). selfResAvailable_ = false; // self-res window closes when spirit is released repopPending_ = true; lastRepopRequestMs_ = static_cast(now); From fabcde42a581603cf3b1dd8d63cef8351d0452a9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:39:42 -0700 Subject: [PATCH 50/55] =?UTF-8?q?fix:=20clarify=20death=20dialog=20?= =?UTF-8?q?=E2=80=94=20auto-release=20label=20and=20resurrection=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Release in X:XX' implied a client-enforced forced release; renamed to 'Auto-release in X:XX' (server-driven) and added 'Or wait for a player to resurrect you.' hint so players know they can stay dead without clicking Release Spirit. --- src/ui/game_screen.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bf4920a5..f4f8cd11 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16588,8 +16588,8 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { // "Release Spirit" dialog centered on screen const bool hasSelfRes = gameHandler.canSelfRes(); float dlgW = 280.0f; - // Extra height when self-res button is available - float dlgH = hasSelfRes ? 170.0f : 130.0f; + // Extra height when self-res button is available; +20 for the "wait for res" hint + float dlgH = hasSelfRes ? 190.0f : 150.0f; ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); @@ -16608,13 +16608,13 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::SetCursorPosX((dlgW - textW) / 2); ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "%s", deathText); - // Respawn timer: show how long until forced release + // Respawn timer: show how long until the server auto-releases the spirit float timeLeft = kForcedReleaseSec - deathElapsed_; if (timeLeft > 0.0f) { int mins = static_cast(timeLeft) / 60; int secs = static_cast(timeLeft) % 60; char timerBuf[48]; - snprintf(timerBuf, sizeof(timerBuf), "Release in %d:%02d", mins, secs); + snprintf(timerBuf, sizeof(timerBuf), "Auto-release in %d:%02d", mins, secs); float tw = ImGui::CalcTextSize(timerBuf).x; ImGui::SetCursorPosX((dlgW - tw) / 2); ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "%s", timerBuf); @@ -16645,6 +16645,12 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { gameHandler.releaseSpirit(); } ImGui::PopStyleColor(2); + + // Hint: player can stay dead and wait for another player to cast Resurrection + const char* resHint = "Or wait for a player to resurrect you."; + float hw = ImGui::CalcTextSize(resHint).x; + ImGui::SetCursorPosX((dlgW - hw) / 2); + ImGui::TextColored(ImVec4(0.5f, 0.6f, 0.5f, 0.85f), "%s", resHint); } ImGui::End(); ImGui::PopStyleColor(2); From 2fb7901cca999bc53c55d925cd41adba4e1c7bfb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 05:44:59 -0700 Subject: [PATCH 51/55] feat: enable water refraction by default The VK_ERROR_DEVICE_LOST crash on AMD/Mali GPUs (barrier srcAccessMask) was fixed in 2026-03-18. Enable refraction for new sessions so players get the improved water visuals without needing to touch Settings. Existing saved configs that explicitly disabled it are preserved. --- docs/status.md | 4 ++-- include/ui/game_screen.hpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/status.md b/docs/status.md index 672c80cf..991d813d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -35,9 +35,9 @@ Implemented (working in normal use): In progress / known gaps: - Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain -- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects) +- Visual edge cases: some M2/WMO rendering gaps (some particle effects) - Lava steam particles: sparse in some areas (tuning opportunity) -- Water refraction: available in Settings; barrier srcAccessMask fix (2026-03-18) removed prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs +- Water refraction: enabled by default; srcAccessMask barrier fix (2026-03-18) resolved prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs ## Where To Look diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e76d2e12..0054bf05 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -179,7 +179,7 @@ private: int pendingResIndex = 0; bool pendingShadows = true; float pendingShadowDistance = 300.0f; - bool pendingWaterRefraction = false; + bool pendingWaterRefraction = true; int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default) int pendingMasterVolume = 100; int pendingMusicVolume = 30; From 25138b56482bf348024bc0b91a51611f637e4489 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:06:29 -0700 Subject: [PATCH 52/55] fix: use CMSG_OPEN_ITEM for locked containers (lockboxes) Right-clicking a locked container (e.g. Dead-Tooth's Strong Box) was sending CMSG_USE_ITEM with spellId=0, which the server rejects. Locked containers (itemClass==1, inventoryType==0) now send CMSG_OPEN_ITEM instead, letting the server auto-check the keyring for the required key. --- include/game/game_handler.hpp | 3 +++ include/game/world_packets.hpp | 6 ++++++ src/game/game_handler.cpp | 20 ++++++++++++++++++++ src/game/world_packets.cpp | 7 +++++++ src/ui/inventory_screen.cpp | 16 ++++++++++++++-- 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index fc592b4e..47997040 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2007,6 +2007,9 @@ public: void autoEquipItemInBag(int bagIndex, int slotIndex); void useItemBySlot(int backpackIndex); void useItemInBag(int bagIndex, int slotIndex); + // CMSG_OPEN_ITEM — for locked containers (lockboxes); server checks keyring automatically + void openItemBySlot(int backpackIndex); + void openItemInBag(int bagIndex, int slotIndex); void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1); void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot); void swapBagSlots(int srcBagIndex, int dstBagIndex); diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c7fc0ef4..c2aa581f 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2027,6 +2027,12 @@ public: static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0); }; +/** CMSG_OPEN_ITEM packet builder (for locked containers / lockboxes) */ +class OpenItemPacket { +public: + static network::Packet build(uint8_t bagIndex, uint8_t slotIndex); +}; + /** CMSG_AUTOEQUIP_ITEM packet builder */ class AutoEquipItemPacket { public: diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 465e6c5c..3591d97a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21165,6 +21165,26 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) { } } +void GameHandler::openItemBySlot(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; + if (inventory.getBackpackSlot(backpackIndex).empty()) return; + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = OpenItemPacket::build(0xFF, static_cast(23 + backpackIndex)); + LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex)); + socket->send(packet); +} + +void GameHandler::openItemInBag(int bagIndex, int slotIndex) { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return; + if (inventory.getBagSlot(bagIndex, slotIndex).empty()) return; + if (state != WorldState::IN_WORLD || !socket) return; + uint8_t wowBag = static_cast(19 + bagIndex); + auto packet = OpenItemPacket::build(wowBag, static_cast(slotIndex)); + LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex); + socket->send(packet); +} + void GameHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; LOG_DEBUG("useItemById: searching for itemId=", itemId); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index aaf18ca2..50c1208a 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -4271,6 +4271,13 @@ network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64 return packet; } +network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) { + network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + return packet; +} + network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM)); packet.writeUInt8(srcBag); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 23c1ae92..b4e2ac89 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2356,7 +2356,14 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemBySlot(backpackIndex); } else { - gameHandler_->useItemBySlot(backpackIndex); + // itemClass==1 (Container) with inventoryType==0 means a lockbox; + // use CMSG_OPEN_ITEM so the server checks keyring automatically. + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info && info->valid && info->itemClass == 1) { + gameHandler_->openItemBySlot(backpackIndex); + } else { + gameHandler_->useItemBySlot(backpackIndex); + } } } else if (kind == SlotKind::BACKPACK && isBagSlot) { LOG_INFO("Right-click bag item: name='", item.name, @@ -2369,7 +2376,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex); } else { - gameHandler_->useItemInBag(bagIndex, bagSlotIndex); + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info && info->valid && info->itemClass == 1) { + gameHandler_->openItemInBag(bagIndex, bagSlotIndex); + } else { + gameHandler_->useItemInBag(bagIndex, bagSlotIndex); + } } } } From 702155ff4f13d7b98ad0e3bf9aa68a916e04273b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:20:18 -0700 Subject: [PATCH 53/55] fix: correct SMSG_SPELL_GO REFLECT miss payload size (WotLK/TBC) WotLK and TBC parsers were reading uint32+uint8 (5 bytes) for SPELL_MISS_REFLECT entries, but the server only sends uint8 reflectResult (1 byte). This caused a 4-byte misalignment after every reflected spell, corrupting subsequent miss entries and SpellCastTargets parsing. Classic parser was already correct. --- src/game/packet_parsers_tbc.cpp | 7 +++---- src/game/world_packets.cpp | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 8e8fbd25..c1397460 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1403,15 +1403,14 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) SpellGoMissEntry m; m.targetGuid = packet.readUInt64(); // full GUID in TBC m.missType = packet.readUInt8(); - if (m.missType == 11) { - if (packet.getReadPos() + 5 > packet.getSize()) { + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (packet.getReadPos() + 1 > packet.getSize()) { LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); truncatedTargets = true; break; } - (void)packet.readUInt32(); - (void)packet.readUInt8(); + (void)packet.readUInt8(); // reflectResult } if (i < storedMissLimit) { data.missTargets.push_back(m); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 50c1208a..a4562067 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3912,14 +3912,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { break; } m.missType = packet.readUInt8(); - if (m.missType == 11) { - if (packet.getSize() - packet.getReadPos() < 5) { + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (packet.getSize() - packet.getReadPos() < 1) { LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); truncatedTargets = true; break; } - (void)packet.readUInt32(); - (void)packet.readUInt8(); + (void)packet.readUInt8(); // reflectResult } if (i < storedMissLimit) { data.missTargets.push_back(m); From 379ca116d1564e3279e1bf081f3fe9512482cffd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:20:24 -0700 Subject: [PATCH 54/55] fix: eliminate full spatial index rebuild on M2 instance removal M2Renderer::removeInstance() was calling rebuildSpatialIndex() for every single removal, causing 25-90ms frame hitches during entity despawns. Now uses O(1) lookup via instanceIndexById, incremental spatial grid cell removal, and swap-remove from the instance vector. The auxiliary index vectors are rebuilt cheaply since they're small. --- src/rendering/m2_renderer.cpp | 65 +++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index ea815963..390ee2c5 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -4008,14 +4008,67 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran } void M2Renderer::removeInstance(uint32_t instanceId) { - for (auto it = instances.begin(); it != instances.end(); ++it) { - if (it->id == instanceId) { - destroyInstanceBones(*it); - instances.erase(it); - rebuildSpatialIndex(); - return; + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + size_t idx = idxIt->second; + if (idx >= instances.size()) return; + + auto& inst = instances[idx]; + + // Remove from spatial grid incrementally (same pattern as the move-update path) + GridCell minCell = toCell(inst.worldBoundsMin); + GridCell maxCell = toCell(inst.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + auto gIt = spatialGrid.find(GridCell{x, y, z}); + if (gIt != spatialGrid.end()) { + auto& vec = gIt->second; + vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); + } + } } } + + // Remove from dedup map + if (!inst.cachedIsGroundDetail) { + DedupKey dk{inst.modelId, + static_cast(std::round(inst.position.x * 10.0f)), + static_cast(std::round(inst.position.y * 10.0f)), + static_cast(std::round(inst.position.z * 10.0f))}; + instanceDedupMap_.erase(dk); + } + + destroyInstanceBones(inst); + + // Swap-remove: move last element to the hole and pop_back to avoid O(n) shift + instanceIndexById.erase(instanceId); + if (idx < instances.size() - 1) { + uint32_t movedId = instances.back().id; + instances[idx] = std::move(instances.back()); + instances.pop_back(); + instanceIndexById[movedId] = idx; + } else { + instances.pop_back(); + } + + // Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.) + // These are small vectors of indices that are rebuilt cheaply. + smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); + animatedInstanceIndices_.clear(); + particleOnlyInstanceIndices_.clear(); + particleInstanceIndices_.clear(); + for (size_t i = 0; i < instances.size(); i++) { + auto& ri = instances[i]; + if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i); + if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i); + if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i); + if (ri.cachedHasAnimation && !ri.cachedDisableAnimation) + animatedInstanceIndices_.push_back(i); + else if (ri.cachedHasParticleEmitters) + particleOnlyInstanceIndices_.push_back(i); + } } void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) { From e0346c85df4f532cd7c1bdb9ad48c7824cbcb6e3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:23:03 -0700 Subject: [PATCH 55/55] fix: salvage spell-go hit data when miss targets are truncated SMSG_SPELL_GO packets with unreasonably high miss counts (48, 118, 241) were causing the entire packet to be discarded, losing all combat hit data. Now salvage the successfully-parsed hit targets (needed for combat text, health bars, animations) instead of discarding everything. Also add spellId/hitCount to truncation warnings for easier diagnosis. --- src/game/world_packets.cpp | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index a4562067..e6f6d872 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3891,23 +3891,27 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { const uint8_t rawMissCount = packet.readUInt8(); if (rawMissCount > 128) { - LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ")"); + LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, + ") spell=", data.spellId, " hits=", (int)data.hitCount, + " remaining=", packet.getSize() - packet.getReadPos()); } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); for (uint16_t i = 0; i < rawMissCount; ++i) { // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). - // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. + // REFLECT additionally appends uint8 reflectResult. if (!hasFullPackedGuid(packet)) { - LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount); + LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount, + " spell=", data.spellId, " hits=", (int)data.hitCount); truncatedTargets = true; break; } SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK if (packet.getSize() - packet.getReadPos() < 1) { - LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount); + LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount, + " spell=", data.spellId); truncatedTargets = true; break; } @@ -3924,12 +3928,17 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.push_back(m); } } - if (truncatedTargets) { - packet.setReadPos(startPos); - return false; - } data.missCount = static_cast(data.missTargets.size()); + // If miss targets were truncated, salvage the successfully-parsed hit data + // rather than discarding the entire spell. The server already applied effects; + // we just need the hit list for UI feedback (combat text, health bars). + if (truncatedTargets) { + LOG_DEBUG("Spell go: salvaging ", (int)data.hitCount, " hits despite miss truncation"); + packet.setReadPos(packet.getSize()); // consume remaining bytes + return true; + } + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that // any trailing fields after the target section are not misaligned for // ground-targeted or AoE spells. Same layout as SpellStartParser.