From e033efc998dfd768353e656e7aeb053cff63dd63 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 15:31:41 -0700 Subject: [PATCH 01/54] feat: add bid status indicators to auction house UI Show [Winning] (green) or [Outbid] (red) labels on the Bids tab based on bidderGuid vs player GUID comparison. Show [Bid] (gold) indicator on the seller's Auctions tab when someone has placed a bid on their listing. Improves auction house usability by making bid status visible at a glance. --- src/ui/game_screen.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 21e9a0b2..1d36fb26 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -22382,6 +22382,15 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); } } + // High bidder indicator + bool isHighBidder = (a.bidderGuid != 0 && a.bidderGuid == gameHandler.getPlayerGuid()); + if (isHighBidder) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "[Winning]"); + ImGui::SameLine(); + } else if (a.bidderGuid != 0) { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[Outbid]"); + ImGui::SameLine(); + } ImGui::TextColored(bqc, "%s", name.c_str()); // Tooltip and shift-click if (ImGui::IsItemHovered() && info && info->valid) @@ -22457,6 +22466,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); } } + // Bid activity indicator for seller + if (a.bidderGuid != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "[Bid]"); + ImGui::SameLine(); + } ImGui::TextColored(oqc, "%s", name.c_str()); if (ImGui::IsItemHovered() && info && info->valid) inventoryScreen.renderItemTooltip(*info); From 0dd1b08504e547cea98aff1ae0a094362c1c2540 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 15:37:33 -0700 Subject: [PATCH 02/54] feat: fire spellcast channel and interrupt events for Lua addons Add UNIT_SPELLCAST_CHANNEL_START (MSG_CHANNEL_START), UNIT_SPELLCAST_CHANNEL_STOP (MSG_CHANNEL_UPDATE with 0ms remaining), UNIT_SPELLCAST_FAILED (SMSG_CAST_RESULT with error), and UNIT_SPELLCAST_INTERRUPTED (SMSG_SPELL_FAILURE) events. These enable addons to track channeled spells and cast interruptions for all units. --- src/game/game_handler.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a7bbab69..50c75051 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2281,6 +2281,8 @@ void GameHandler::handlePacket(network::Packet& packet) { : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addUIError(errMsg); if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId); + if (addonEventCallback_) + addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)}); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; @@ -3381,6 +3383,15 @@ void GameHandler::handlePacket(network::Packet& packet) { } } } + // Fire UNIT_SPELLCAST_INTERRUPTED for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (failGuid == playerGuid || failGuid == 0) unitId = "player"; + else if (failGuid == targetGuid) unitId = "target"; + else if (failGuid == focusGuid) unitId = "focus"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId}); + } if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed — clear gather-node loot target so the // next timed cast doesn't try to loot a stale interrupted gather node. @@ -7302,6 +7313,15 @@ void GameHandler::handlePacket(network::Packet& packet) { } LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, " spell=", chanSpellId, " total=", chanTotalMs, "ms"); + // Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (chanCaster == playerGuid) unitId = "player"; + else if (chanCaster == targetGuid) unitId = "target"; + else if (chanCaster == focusGuid) unitId = "focus"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)}); + } } break; } @@ -7329,6 +7349,15 @@ void GameHandler::handlePacket(network::Packet& packet) { } LOG_DEBUG("MSG_CHANNEL_UPDATE: caster=0x", std::hex, chanCaster2, std::dec, " remaining=", chanRemainMs, "ms"); + // Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends + if (chanRemainMs == 0 && addonEventCallback_) { + std::string unitId; + if (chanCaster2 == playerGuid) unitId = "player"; + else if (chanCaster2 == targetGuid) unitId = "target"; + else if (chanCaster2 == focusGuid) unitId = "focus"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_CHANNEL_STOP", {unitId}); + } break; } From 4b6ed04926878b89037cf61994be256d378f07d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 15:44:25 -0700 Subject: [PATCH 03/54] feat: add GetZoneText, GetSubZoneText, and GetMinimapZoneText Lua APIs Add zone name query functions using worldStateZoneId + getAreaName lookup. GetRealZoneText is aliased to GetZoneText. These are heavily used by boss mod addons (DBM) for zone detection and by quest tracking addons. --- src/addons/lua_engine.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 8686ef3c..1e676fad 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -641,6 +641,29 @@ static int lua_GetCurrentMapAreaID(lua_State* L) { return 1; } +// GetZoneText() / GetRealZoneText() → current zone name +static int lua_GetZoneText(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, ""); return 1; } + uint32_t zoneId = gh->getWorldStateZoneId(); + if (zoneId != 0) { + std::string name = gh->getWhoAreaName(zoneId); + if (!name.empty()) { lua_pushstring(L, name.c_str()); return 1; } + } + lua_pushstring(L, ""); + return 1; +} + +// GetSubZoneText() → subzone name (same as zone for now — server doesn't always send subzone) +static int lua_GetSubZoneText(lua_State* L) { + return lua_GetZoneText(L); // Best-effort: zone and subzone often overlap +} + +// GetMinimapZoneText() → zone name displayed near minimap +static int lua_GetMinimapZoneText(lua_State* L) { + return lua_GetZoneText(L); +} + // --- Player State API --- // These replace the hardcoded "return false" Lua stubs with real game state. @@ -1183,6 +1206,10 @@ void LuaEngine::registerCoreAPI() { {"GetLocale", lua_GetLocale}, {"GetBuildInfo", lua_GetBuildInfo}, {"GetCurrentMapAreaID", lua_GetCurrentMapAreaID}, + {"GetZoneText", lua_GetZoneText}, + {"GetRealZoneText", lua_GetZoneText}, + {"GetSubZoneText", lua_GetSubZoneText}, + {"GetMinimapZoneText", lua_GetMinimapZoneText}, // Player state (replaces hardcoded stubs) {"IsMounted", lua_IsMounted}, {"IsFlying", lua_IsFlying}, From d1bcd2f8441fe04c4adeb7c7712cfabcdede49cb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 15:53:43 -0700 Subject: [PATCH 04/54] fix: resolve compiler warnings in lua_engine and game_screen Remove unused getPlayerUnit() helper in lua_engine.cpp (-Wunused-function). Increase countStr buffer from 8 to 16 bytes in action bar item count display to eliminate -Wformat-truncation warning for %d with int32_t. Build is now warning-free. --- src/addons/lua_engine.cpp | 9 --------- src/ui/game_screen.cpp | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 1e676fad..76149dfd 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -59,15 +59,6 @@ static int lua_wow_message(lua_State* L) { return lua_wow_print(L); } -// Helper: get player Unit from game handler -static game::Unit* getPlayerUnit(lua_State* L) { - auto* gh = getGameHandler(L); - if (!gh) return nullptr; - auto entity = gh->getEntityManager().getEntity(gh->getPlayerGuid()); - if (!entity) return nullptr; - return dynamic_cast(entity.get()); -} - // Helper: resolve WoW unit IDs to GUID static uint64_t resolveUnitGuid(game::GameHandler* gh, const std::string& uid) { if (uid == "player") return gh->getPlayerGuid(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1d36fb26..f17188e9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9412,7 +9412,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } if (totalCount > 0) { - char countStr[8]; + char countStr[16]; snprintf(countStr, sizeof(countStr), "%d", totalCount); ImVec2 btnMax = ImGui::GetItemRectMax(); ImVec2 tsz = ImGui::CalcTextSize(countStr); From df7feed648d9842edddaf610dd44f1e6737cf73f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 15:56:58 -0700 Subject: [PATCH 05/54] feat: add distinct STORM weather type with wind-driven particles Add Weather::Type::STORM enum value and wire it from SMSG_WEATHER type 3. Storm particles are faster (70 units/s vs rain's 50), wind-angled at 15+ units lateral velocity with gusty turbulence, darker blue-grey tint, and shorter lifetime. Previously storms rendered identically to rain. --- include/rendering/weather.hpp | 3 ++- src/rendering/renderer.cpp | 2 +- src/rendering/weather.cpp | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/include/rendering/weather.hpp b/include/rendering/weather.hpp index b92c963d..3349526f 100644 --- a/include/rendering/weather.hpp +++ b/include/rendering/weather.hpp @@ -28,7 +28,8 @@ public: enum class Type { NONE, RAIN, - SNOW + SNOW, + STORM }; Weather(); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 11c37bab..7199273d 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -3192,7 +3192,7 @@ void Renderer::update(float deltaTime) { // Server-driven weather (SMSG_WEATHER) — authoritative if (wType == 1) weather->setWeatherType(Weather::Type::RAIN); else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW); - else if (wType == 3) weather->setWeatherType(Weather::Type::RAIN); // thunderstorm — use rain particles + else if (wType == 3) weather->setWeatherType(Weather::Type::STORM); else weather->setWeatherType(Weather::Type::NONE); weather->setIntensity(wInt); } else { diff --git a/src/rendering/weather.cpp b/src/rendering/weather.cpp index fed604dc..5dc525da 100644 --- a/src/rendering/weather.cpp +++ b/src/rendering/weather.cpp @@ -198,6 +198,10 @@ void Weather::update(const Camera& camera, float deltaTime) { if (weatherType == Type::RAIN) { p.velocity = glm::vec3(0.0f, -50.0f, 0.0f); // Fast downward p.maxLifetime = 5.0f; + } else if (weatherType == Type::STORM) { + // Storm: faster, angled rain with wind + p.velocity = glm::vec3(15.0f, -70.0f, 8.0f); + p.maxLifetime = 3.5f; } else { // SNOW p.velocity = glm::vec3(0.0f, -5.0f, 0.0f); // Slow downward p.maxLifetime = 10.0f; @@ -245,6 +249,12 @@ void Weather::updateParticle(Particle& particle, const Camera& camera, float del particle.velocity.x = windX; particle.velocity.z = windZ; } + // Storm: gusty, turbulent wind with varying direction + if (weatherType == Type::STORM) { + float gust = std::sin(particle.lifetime * 1.5f + particle.position.x * 0.1f) * 5.0f; + particle.velocity.x = 15.0f + gust; + particle.velocity.z = 8.0f + std::cos(particle.lifetime * 2.0f) * 3.0f; + } // Update position particle.position += particle.velocity * deltaTime; @@ -275,6 +285,9 @@ void Weather::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (weatherType == Type::RAIN) { push.particleSize = 3.0f; push.particleColor = glm::vec4(0.7f, 0.8f, 0.9f, 0.6f); + } else if (weatherType == Type::STORM) { + push.particleSize = 3.5f; + push.particleColor = glm::vec4(0.6f, 0.65f, 0.75f, 0.7f); // Darker, more opaque } else { // SNOW push.particleSize = 8.0f; push.particleColor = glm::vec4(1.0f, 1.0f, 1.0f, 0.9f); From 23ebfc7e859789b097e28a85f43aca3f20a17eba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:10:29 -0700 Subject: [PATCH 06/54] feat: add LFG role check confirmation popup with CMSG_LFG_SET_ROLES When the dungeon finder initiates a role check (SMSG_LFG_ROLE_CHECK_UPDATE state=2), show a centered popup with Tank/Healer/DPS checkboxes and Accept/Leave Queue buttons. Accept sends CMSG_LFG_SET_ROLES with the selected role mask. Previously only showed passive "Role check in progress" text with no way to respond. --- include/game/game_handler.hpp | 1 + include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 11 ++++++ src/ui/game_screen.cpp | 66 +++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1c2907fd..9873e22e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1442,6 +1442,7 @@ public: // roles bitmask: 0x02=tank, 0x04=healer, 0x08=dps; pass LFGDungeonEntry ID void lfgJoin(uint32_t dungeonId, uint8_t roles); void lfgLeave(); + void lfgSetRoles(uint8_t roles); void lfgAcceptProposal(uint32_t proposalId, bool accept); void lfgSetBootVote(bool vote); void lfgTeleport(bool toLfgDungeon = true); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cd200126..5391978f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -388,6 +388,7 @@ private: void renderBgInvitePopup(game::GameHandler& gameHandler); void renderBfMgrInvitePopup(game::GameHandler& gameHandler); void renderLfgProposalPopup(game::GameHandler& gameHandler); + void renderLfgRoleCheckPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 50c75051..862857f1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17172,6 +17172,17 @@ void GameHandler::lfgLeave() { LOG_INFO("Sent CMSG_LFG_LEAVE"); } +void GameHandler::lfgSetRoles(uint8_t roles) { + if (state != WorldState::IN_WORLD || !socket) return; + const uint32_t wire = wireOpcode(Opcode::CMSG_LFG_SET_ROLES); + if (wire == 0xFFFF) return; + + network::Packet pkt(static_cast(wire)); + pkt.writeUInt8(roles); + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_SET_ROLES: roles=", static_cast(roles)); +} + void GameHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) { if (!socket) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f17188e9..19f50309 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -720,6 +720,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBgInvitePopup(gameHandler); renderBfMgrInvitePopup(gameHandler); renderLfgProposalPopup(gameHandler); + renderLfgRoleCheckPopup(gameHandler); renderGuildRoster(gameHandler); renderSocialFrame(gameHandler); renderBuffBar(gameHandler); @@ -14201,6 +14202,71 @@ void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { ImGui::PopStyleColor(3); } +void GameScreen::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::RoleCheck) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 160.0f, screenH / 2.0f - 80.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(320.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.1f, 0.3f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Role Check##LfgRoleCheck", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Confirm your role:"); + ImGui::Spacing(); + + // Role checkboxes + bool isTank = (lfgRoles_ & 0x02) != 0; + bool isHealer = (lfgRoles_ & 0x04) != 0; + bool isDps = (lfgRoles_ & 0x08) != 0; + + if (ImGui::Checkbox("Tank", &isTank)) lfgRoles_ = (lfgRoles_ & ~0x02) | (isTank ? 0x02 : 0); + ImGui::SameLine(120.0f); + if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); + ImGui::SameLine(220.0f); + if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + bool hasRole = (lfgRoles_ & 0x0E) != 0; + if (!hasRole) ImGui::BeginDisabled(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.4f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); + if (ImGui::Button("Accept", ImVec2(140.0f, 28.0f))) { + gameHandler.lfgSetRoles(lfgRoles_); + } + ImGui::PopStyleColor(2); + + if (!hasRole) ImGui::EndDisabled(); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.4f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.6f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Leave Queue", ImVec2(140.0f, 28.0f))) { + gameHandler.lfgLeave(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Guild Roster toggle (customizable keybind) if (!chatInputActive && !ImGui::GetIO().WantTextInput && From 21ead2aa4b6c116ab8e967119467bd62fb34d158 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:17:04 -0700 Subject: [PATCH 07/54] feat: add /reload command to re-initialize addon system Add AddonManager::reload() which saves all SavedVariables, shuts down the Lua VM, re-initializes it, rescans .toc files, and reloads all addons. Wire /reload, /reloadui, /rl slash commands that call reload() and fire VARIABLES_LOADED + PLAYER_LOGIN + PLAYER_ENTERING_WORLD lifecycle events. Essential for addon development and troubleshooting. --- include/addons/addon_manager.hpp | 5 +++++ src/addons/addon_manager.cpp | 22 ++++++++++++++++++++++ src/ui/game_screen.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/include/addons/addon_manager.hpp b/include/addons/addon_manager.hpp index be4a6a89..681d3822 100644 --- a/include/addons/addon_manager.hpp +++ b/include/addons/addon_manager.hpp @@ -27,9 +27,14 @@ public: void saveAllSavedVariables(); + /// Re-initialize the Lua VM and reload all addons (used by /reload). + bool reload(); + private: LuaEngine luaEngine_; std::vector addons_; + game::GameHandler* gameHandler_ = nullptr; + std::string addonsPath_; bool loadAddon(const TocFile& addon); std::string getSavedVariablesPath(const TocFile& addon) const; diff --git a/src/addons/addon_manager.cpp b/src/addons/addon_manager.cpp index 60593792..e826097f 100644 --- a/src/addons/addon_manager.cpp +++ b/src/addons/addon_manager.cpp @@ -11,12 +11,14 @@ AddonManager::AddonManager() = default; AddonManager::~AddonManager() { shutdown(); } bool AddonManager::initialize(game::GameHandler* gameHandler) { + gameHandler_ = gameHandler; if (!luaEngine_.initialize()) return false; luaEngine_.setGameHandler(gameHandler); return true; } void AddonManager::scanAddons(const std::string& addonsPath) { + addonsPath_ = addonsPath; addons_.clear(); std::error_code ec; @@ -121,6 +123,26 @@ void AddonManager::saveAllSavedVariables() { } } +bool AddonManager::reload() { + LOG_INFO("AddonManager: reloading all addons..."); + saveAllSavedVariables(); + addons_.clear(); + luaEngine_.shutdown(); + + if (!luaEngine_.initialize()) { + LOG_ERROR("AddonManager: failed to reinitialize Lua VM during reload"); + return false; + } + luaEngine_.setGameHandler(gameHandler_); + + if (!addonsPath_.empty()) { + scanAddons(addonsPath_); + loadAllAddons(); + } + LOG_INFO("AddonManager: reload complete"); + return true; +} + void AddonManager::shutdown() { saveAllSavedVariables(); addons_.clear(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 19f50309..edad0a48 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6035,6 +6035,30 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) + if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { + auto* am = core::Application::getInstance().getAddonManager(); + if (am) { + am->reload(); + am->fireEvent("VARIABLES_LOADED"); + am->fireEvent("PLAYER_LOGIN"); + am->fireEvent("PLAYER_ENTERING_WORLD"); + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Interface reloaded."; + gameHandler.addLocalChatMessage(rlMsg); + } else { + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Addon system not available."; + gameHandler.addLocalChatMessage(rlMsg); + } + chatInputBuffer[0] = '\0'; + 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. From 00201c12325d333ee4ec31129067ace5bf94692c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:21:52 -0700 Subject: [PATCH 08/54] feat: show enchant name and XP source creature in chat messages SMSG_ENCHANTMENTLOG now resolves spell name and shows "You enchant with [name]" or "[Caster] enchants your item with [name]" instead of silent debug log. SMSG_LOG_XPGAIN now shows creature name: "Wolf dies, you gain 45 experience" instead of generic "You gain 45 experience" for kill XP. --- src/game/game_handler.cpp | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 862857f1..99b8edc3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5076,12 +5076,27 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ENCHANTMENTLOG: { // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType if (packet.getSize() - packet.getReadPos() >= 28) { - /*uint64_t targetGuid =*/ packet.readUInt64(); - /*uint64_t casterGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); + uint64_t enchTargetGuid = packet.readUInt64(); + uint64_t enchCasterGuid = packet.readUInt64(); + uint32_t enchSpellId = packet.readUInt32(); /*uint32_t displayId =*/ packet.readUInt32(); /*uint32_t animType =*/ packet.readUInt32(); - LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", spellId); + LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", enchSpellId); + // Show enchant message if the player is involved + if (enchTargetGuid == playerGuid || enchCasterGuid == playerGuid) { + const std::string& enchName = getSpellName(enchSpellId); + std::string casterName = lookupName(enchCasterGuid); + if (!enchName.empty()) { + std::string msg; + if (enchCasterGuid == playerGuid) + msg = "You enchant with " + enchName + "."; + else if (!casterName.empty()) + msg = casterName + " enchants your item with " + enchName + "."; + else + msg = "Your item has been enchanted with " + enchName + "."; + addSystemChatMessage(msg); + } + } } break; } @@ -22795,7 +22810,18 @@ void GameHandler::handleXpGain(network::Packet& packet) { // but we can show combat text for XP gains addCombatText(CombatTextEntry::XP_GAIN, static_cast(data.totalXp), 0, true); - std::string msg = "You gain " + std::to_string(data.totalXp) + " experience."; + // Build XP message with source creature name when available + std::string msg; + if (data.victimGuid != 0 && data.type == 0) { + // Kill XP — resolve creature name + std::string victimName = lookupName(data.victimGuid); + if (!victimName.empty()) + msg = victimName + " dies, you gain " + std::to_string(data.totalXp) + " experience."; + else + msg = "You gain " + std::to_string(data.totalXp) + " experience."; + } else { + msg = "You gain " + std::to_string(data.totalXp) + " experience."; + } if (data.groupBonus > 0) { msg += " (+" + std::to_string(data.groupBonus) + " group bonus)"; } From bf62061a3153eb840b30d8a663f4df35384df5da Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:29:32 -0700 Subject: [PATCH 09/54] feat: expand slash command autocomplete with 30+ missing commands Add /reload, /reloadui, /rl, /ready, /notready, /readycheck, /cancellogout, /clearmainassist, /clearmaintank, /mainassist, /maintank, /cloak, /gdemote, /gkick, /gleader, /gmotd, /gpromote, /gquit, /groster, /leaveparty, /removefriend, /score, /script, /targetenemy, /targetfriend, /targetlast, /ticket, and more to the tab-completion list. Alphabetically sorted. --- src/ui/game_screen.cpp | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index edad0a48..265445f8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2617,24 +2617,32 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { static const std::vector kCmds = { "/afk", "/assist", "/away", - "/cancelaura", "/cancelform", "/cancelshapeshift", - "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", "/cleartarget", + "/cancelaura", "/cancelform", "/cancellogout", "/cancelshapeshift", + "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", + "/clearmainassist", "/clearmaintank", "/cleartarget", "/cloak", "/combatlog", "/dance", "/dismount", "/dnd", "/do", "/duel", "/e", "/emote", "/equip", "/equipset", "/focus", "/follow", "/forfeit", "/friend", - "/g", "/ginvite", "/gmticket", "/grouploot", "/guild", "/guildinfo", + "/g", "/gdemote", "/ginvite", "/gkick", "/gleader", "/gmotd", + "/gmticket", "/gpromote", "/gquit", "/grouploot", "/groster", + "/guild", "/guildinfo", "/helm", "/help", "/i", "/ignore", "/inspect", "/instance", "/invite", "/j", "/join", "/kick", "/kneel", - "/l", "/leave", "/loc", "/local", "/logout", - "/macrohelp", "/mark", "/me", + "/l", "/leave", "/leaveparty", "/loc", "/local", "/logout", + "/macrohelp", "/mainassist", "/maintank", "/mark", "/me", + "/notready", "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", "/played", "/pvp", - "/r", "/raid", "/raidinfo", "/raidwarning", "/random", "/reply", "/roll", "/run", - "/s", "/say", "/screenshot", "/setloot", "/shout", "/sit", "/stand", + "/r", "/raid", "/raidinfo", "/raidwarning", "/random", "/ready", + "/readycheck", "/reload", "/reloadui", "/removefriend", + "/reply", "/rl", "/roll", "/run", + "/s", "/say", "/score", "/screenshot", "/script", "/setloot", + "/shout", "/sit", "/stand", "/startattack", "/stopattack", "/stopcasting", "/stopfollow", "/stopmacro", - "/t", "/target", "/threat", "/time", "/trade", + "/t", "/target", "/targetenemy", "/targetfriend", "/targetlast", + "/threat", "/ticket", "/time", "/trade", "/unignore", "/uninvite", "/unstuck", "/use", "/w", "/whisper", "/who", "/wts", "/wtb", "/y", "/yell", "/zone" From ae18d25996c8111c05592949ec23df121cff48dc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:34:11 -0700 Subject: [PATCH 10/54] feat: add sun height attenuation and warm sunset tint to lens flare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce flare intensity when sun is near the horizon via smoothstep on sunDir.z (0→0.25 range). Apply amber/orange color shift to flare elements at sunrise/sunset for a warm golden glow. Prevents overly bright flares at low sun angles while enhancing atmospheric mood. --- src/rendering/lens_flare.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/rendering/lens_flare.cpp b/src/rendering/lens_flare.cpp index 820641af..3dd6b734 100644 --- a/src/rendering/lens_flare.cpp +++ b/src/rendering/lens_flare.cpp @@ -313,8 +313,12 @@ void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec return; } + // Sun height attenuation — flare weakens when sun is near horizon (sunrise/sunset) + float sunHeight = sunDir.z; // z = up in render space; 0 = horizon, 1 = zenith + float heightFactor = glm::smoothstep(-0.05f, 0.25f, sunHeight); + // Atmospheric attenuation — fog, clouds, and weather reduce lens flare - float atmosphericFactor = 1.0f; + float atmosphericFactor = heightFactor; atmosphericFactor *= (1.0f - glm::clamp(fogDensity * 0.8f, 0.0f, 0.9f)); // Heavy fog nearly kills flare atmosphericFactor *= (1.0f - glm::clamp(cloudDensity * 0.6f, 0.0f, 0.7f)); // Clouds attenuate atmosphericFactor *= (1.0f - glm::clamp(weatherIntensity * 0.9f, 0.0f, 0.95f)); // Rain/snow heavily attenuates @@ -339,6 +343,9 @@ void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset); + // Warm tint at sunrise/sunset — shift flare color toward orange/amber when sun is low + float warmTint = 1.0f - glm::smoothstep(0.05f, 0.35f, sunHeight); + // Render each flare element for (const auto& element : flareElements) { // Calculate position along sun-to-center axis @@ -347,12 +354,19 @@ void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec // Apply visibility, intensity, and atmospheric attenuation float brightness = element.brightness * visibility * intensityMultiplier * atmosphericFactor; + // Apply warm sunset/sunrise color shift + glm::vec3 tintedColor = element.color; + if (warmTint > 0.01f) { + glm::vec3 warmColor(1.0f, 0.6f, 0.25f); // amber/orange + tintedColor = glm::mix(tintedColor, warmColor, warmTint * 0.5f); + } + // Set push constants FlarePushConstants push{}; push.position = position; push.size = element.size; push.aspectRatio = aspectRatio; - push.colorBrightness = glm::vec4(element.color, brightness); + push.colorBrightness = glm::vec4(tintedColor, brightness); vkCmdPushConstants(cmd, pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, From 4cdccb7430550ae31ae3d0774133eb84b8a52f5e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:38:57 -0700 Subject: [PATCH 11/54] feat: fire BAG_UPDATE and PLAYER_EQUIPMENT_CHANGED events for addons Fire BAG_UPDATE and UNIT_INVENTORY_CHANGED when item stack/durability fields change in UPDATE_OBJECT VALUES path. Fire PLAYER_EQUIPMENT_CHANGED when equipment slot fields change. Enables bag addons (Bagnon, OneBag) and gear tracking addons to react to inventory changes. --- src/game/game_handler.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 99b8edc3..eb6208fb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12648,7 +12648,11 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem // Do not auto-create quests from VALUES quest-log slot fields for the // same reason as CREATE_OBJECT2 above (can be misaligned per realm). if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); + if (slotsChanged) { + rebuildOnlineInventory(); + if (addonEventCallback_) + addonEventCallback_("PLAYER_EQUIPMENT_CHANGED", {}); + } extractSkillFields(lastPlayerFields_); extractExploredZoneFields(lastPlayerFields_); applyQuestStateFromFields(lastPlayerFields_); @@ -12753,6 +12757,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem } if (inventoryChanged) { rebuildOnlineInventory(); + if (addonEventCallback_) { + addonEventCallback_("BAG_UPDATE", {}); + addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"}); + } } } if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { From d7d68198554ed6798dae65471d1455e7dd9ecb72 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:42:06 -0700 Subject: [PATCH 12/54] feat: add generic UnitAura(unit, index, filter) Lua API function Add UnitAura() that accepts WoW-compatible filter strings: "HELPFUL" for buffs, "HARMFUL" for debuffs. Delegates to existing UnitBuff/UnitDebuff logic. Many addons (WeakAuras, Grid, etc.) use UnitAura with filter strings rather than separate UnitBuff/UnitDebuff calls. --- src/addons/lua_engine.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 76149dfd..99872925 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -396,6 +396,17 @@ static int lua_UnitAura(lua_State* L, bool wantBuff) { static int lua_UnitBuff(lua_State* L) { return lua_UnitAura(L, true); } static int lua_UnitDebuff(lua_State* L) { return lua_UnitAura(L, false); } +// UnitAura(unit, index, filter) — generic aura query with filter string +// filter: "HELPFUL" = buffs, "HARMFUL" = debuffs, "PLAYER" = cast by player, +// "HELPFUL|PLAYER" = buffs cast by player, etc. +static int lua_UnitAuraGeneric(lua_State* L) { + const char* filter = luaL_optstring(L, 3, "HELPFUL"); + std::string f(filter ? filter : "HELPFUL"); + for (char& c : f) c = static_cast(std::toupper(static_cast(c))); + bool wantBuff = (f.find("HARMFUL") == std::string::npos); + return lua_UnitAura(L, wantBuff); +} + // --- Action API --- static int lua_SendChatMessage(lua_State* L) { @@ -1189,6 +1200,7 @@ void LuaEngine::registerCoreAPI() { {"InCombatLockdown", lua_InCombatLockdown}, {"UnitBuff", lua_UnitBuff}, {"UnitDebuff", lua_UnitDebuff}, + {"UnitAura", lua_UnitAuraGeneric}, {"GetNumAddOns", lua_GetNumAddOns}, {"GetAddOnInfo", lua_GetAddOnInfo}, {"GetSpellInfo", lua_GetSpellInfo}, From b3f406c6d340da2f8ea1180c579278be79d78af1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 16:50:32 -0700 Subject: [PATCH 13/54] fix: sync cloud density with weather intensity and DBC cloud coverage Cloud renderer's density was hardcoded at 0.35 and never updated from the DBC-driven cloudDensity parameter. Now setDensity() is called each frame with the lighting manager's cloud coverage value. Active weather (rain/ snow/storm) additionally boosts cloud density by up to 0.4 so clouds visibly thicken during storms. --- src/rendering/sky_system.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rendering/sky_system.cpp b/src/rendering/sky_system.cpp index 9509cdc2..98e27621 100644 --- a/src/rendering/sky_system.cpp +++ b/src/rendering/sky_system.cpp @@ -135,6 +135,14 @@ void SkySystem::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, // --- Clouds (DBC-driven colors + sun lighting) --- if (clouds_) { + // Sync cloud density with weather/DBC-driven cloud coverage. + // Active weather (rain/snow/storm) increases cloud density for visual consistency. + float effectiveDensity = params.cloudDensity; + if (params.weatherIntensity > 0.05f) { + float weatherBoost = params.weatherIntensity * 0.4f; // storms add up to 0.4 density + effectiveDensity = glm::min(1.0f, effectiveDensity + weatherBoost); + } + clouds_->setDensity(effectiveDensity); clouds_->render(cmd, perFrameSet, params); } From e6fbdfcc02957e16a5eede79cb10e96bc0946e0c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:05:48 -0700 Subject: [PATCH 14/54] feat: add /dump command for Lua expression evaluation and debugging /dump evaluates a Lua expression and prints the result to chat. For tables, iterates key-value pairs and displays them. Aliases: /print. Useful for addon development and debugging game state queries like "/dump GetSpellInfo(133)" or "/dump UnitHealth('player')". --- src/ui/game_screen.cpp | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 265445f8..cab36b9f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2620,7 +2620,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { "/cancelaura", "/cancelform", "/cancellogout", "/cancelshapeshift", "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", "/clearmainassist", "/clearmaintank", "/cleartarget", "/cloak", - "/combatlog", "/dance", "/dismount", "/dnd", "/do", "/duel", + "/combatlog", "/dance", "/dismount", "/dnd", "/do", "/duel", "/dump", "/e", "/emote", "/equip", "/equipset", "/focus", "/follow", "/forfeit", "/friend", "/g", "/gdemote", "/ginvite", "/gkick", "/gleader", "/gmotd", @@ -6016,6 +6016,30 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /dump — evaluate Lua expression and print result + if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { + std::string expr = command.substr(spacePos + 1); + auto* am = core::Application::getInstance().getAddonManager(); + if (am && am->isInitialized()) { + // Wrap expression in print(tostring(...)) to display the value + std::string wrapped = "local __v = " + expr + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + gameHandler.addLocalChatMessage(errMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // Check addon slash commands (SlashCmdList) before built-in commands { auto* am = core::Application::getInstance().getAddonManager(); From 0f480f5ada1fe7366bc090ec877214168887c8b0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:14:07 -0700 Subject: [PATCH 15/54] feat: add container/bag Lua API for bag addon support Add GetContainerNumSlots(bag), GetContainerItemInfo(bag, slot), GetContainerItemLink(bag, slot), and GetContainerNumFreeSlots(bag). Container 0 = backpack (16 slots), containers 1-4 = equipped bags. Returns item count, quality, and WoW-format item links with quality colors. Enables bag management addons (Bagnon, OneBag, AdiBags). --- src/addons/lua_engine.cpp | 122 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 99872925..2e44ce2d 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -734,6 +734,123 @@ static int lua_GetUnitSpeed(lua_State* L) { return 1; } +// --- Container/Bag API --- +// WoW bags: container 0 = backpack (16 slots), containers 1-4 = equipped bags + +static int lua_GetContainerNumSlots(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + if (!gh) { lua_pushnumber(L, 0); return 1; } + const auto& inv = gh->getInventory(); + if (container == 0) { + lua_pushnumber(L, inv.getBackpackSize()); + } else if (container >= 1 && container <= 4) { + lua_pushnumber(L, inv.getBagSize(container - 1)); + } else { + lua_pushnumber(L, 0); + } + return 1; +} + +// GetContainerItemInfo(container, slot) → texture, count, locked, quality, readable, lootable, link +static int lua_GetContainerItemInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + int slot = static_cast(luaL_checknumber(L, 2)); + if (!gh) { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const game::ItemSlot* itemSlot = nullptr; + + if (container == 0 && slot >= 1 && slot <= inv.getBackpackSize()) { + itemSlot = &inv.getBackpackSlot(slot - 1); // WoW uses 1-based + } else if (container >= 1 && container <= 4) { + int bagIdx = container - 1; + int bagSize = inv.getBagSize(bagIdx); + if (slot >= 1 && slot <= bagSize) + itemSlot = &inv.getBagSlot(bagIdx, slot - 1); + } + + if (!itemSlot || itemSlot->empty()) { lua_pushnil(L); return 1; } + + // Get item info for quality/icon + const auto* info = gh->getItemInfo(itemSlot->item.itemId); + + lua_pushnil(L); // texture (icon path — would need ItemDisplayInfo icon resolver) + lua_pushnumber(L, itemSlot->item.stackCount); // count + lua_pushboolean(L, 0); // locked + lua_pushnumber(L, info ? info->quality : 0); // quality + lua_pushboolean(L, 0); // readable + lua_pushboolean(L, 0); // lootable + // Build item link with quality color + std::string name = info ? info->name : ("Item #" + std::to_string(itemSlot->item.itemId)); + uint32_t q = info ? info->quality : 0; + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = q < 8 ? q : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], itemSlot->item.itemId, name.c_str()); + lua_pushstring(L, link); // link + return 7; +} + +// GetContainerItemLink(container, slot) → item link string +static int lua_GetContainerItemLink(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + int slot = static_cast(luaL_checknumber(L, 2)); + if (!gh) { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const game::ItemSlot* itemSlot = nullptr; + + if (container == 0 && slot >= 1 && slot <= inv.getBackpackSize()) { + itemSlot = &inv.getBackpackSlot(slot - 1); + } else if (container >= 1 && container <= 4) { + int bagIdx = container - 1; + int bagSize = inv.getBagSize(bagIdx); + if (slot >= 1 && slot <= bagSize) + itemSlot = &inv.getBagSlot(bagIdx, slot - 1); + } + + if (!itemSlot || itemSlot->empty()) { lua_pushnil(L); return 1; } + const auto* info = gh->getItemInfo(itemSlot->item.itemId); + std::string name = info ? info->name : ("Item #" + std::to_string(itemSlot->item.itemId)); + uint32_t q = info ? info->quality : 0; + char link[256]; + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = q < 8 ? q : 1u; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], itemSlot->item.itemId, name.c_str()); + lua_pushstring(L, link); + return 1; +} + +// GetContainerNumFreeSlots(container) → numFreeSlots, bagType +static int lua_GetContainerNumFreeSlots(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + + const auto& inv = gh->getInventory(); + int freeSlots = 0; + int totalSlots = 0; + + if (container == 0) { + totalSlots = inv.getBackpackSize(); + for (int i = 0; i < totalSlots; ++i) + if (inv.getBackpackSlot(i).empty()) ++freeSlots; + } else if (container >= 1 && container <= 4) { + totalSlots = inv.getBagSize(container - 1); + for (int i = 0; i < totalSlots; ++i) + if (inv.getBagSlot(container - 1, i).empty()) ++freeSlots; + } + + lua_pushnumber(L, freeSlots); + lua_pushnumber(L, 0); // bagType (0 = normal) + return 2; +} + // --- Additional WoW API --- static int lua_UnitAffectingCombat(lua_State* L) { @@ -1231,6 +1348,11 @@ void LuaEngine::registerCoreAPI() { {"UnitIsFriend", lua_UnitIsFriend}, {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, + // Container/bag API + {"GetContainerNumSlots", lua_GetContainerNumSlots}, + {"GetContainerItemInfo", lua_GetContainerItemInfo}, + {"GetContainerItemLink", lua_GetContainerItemLink}, + {"GetContainerNumFreeSlots", lua_GetContainerNumFreeSlots}, // Utilities {"strsplit", lua_strsplit}, {"strtrim", lua_strtrim}, From 8761ad9301a3715fecd7abf61fe95097f00bc894 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:19:18 -0700 Subject: [PATCH 16/54] fix: clean up combat text, cast bars, and aura cache on entity destroy When SMSG_DESTROY_OBJECT removes an entity, now also purge combat text entries targeting that GUID (prevents floating damage numbers on despawned mobs), erase unit cast state (prevents stale cast bars), and clear cached auras (prevents stale buff/debuff data for destroyed units). --- src/game/game_handler.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index eb6208fb..0d1253d4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13018,8 +13018,21 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { // Clean up quest giver status npcQuestStatus_.erase(data.guid); + // Remove combat text entries referencing the destroyed entity so floating + // damage numbers don't linger after the source/target despawns. + combatText.erase( + std::remove_if(combatText.begin(), combatText.end(), + [&data](const CombatTextEntry& e) { + return e.dstGuid == data.guid; + }), + combatText.end()); + + // Clean up unit cast state (cast bar) for the destroyed unit + unitCastStates_.erase(data.guid); + // Clean up cached auras + unitAurasCache_.erase(data.guid); + tabCycleStale = true; - // Entity count logging disabled } void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) { From 14007c81dfac405deaafb78ab0e8fc01a2703305 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:24:16 -0700 Subject: [PATCH 17/54] feat: add /cancelqueuedspell command to clear spell queue Add cancelQueuedSpell() method that clears queuedSpellId_ and queuedSpellTarget_. Wire /cancelqueuedspell and /stopspellqueue slash commands. Useful for combat macros that need to prevent queued spells from firing after a current cast. --- include/game/game_handler.hpp | 1 + src/ui/game_screen.cpp | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9873e22e..5da300cc 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -868,6 +868,7 @@ public: // 400ms spell-queue window: next spell to cast when current finishes uint32_t getQueuedSpellId() const { return queuedSpellId_; } + void cancelQueuedSpell() { queuedSpellId_ = 0; queuedSpellTarget_ = 0; } // Unit cast state (tracked per GUID for target frame + boss frames) struct UnitCastState { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cab36b9f..473e46a0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7140,6 +7140,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { + gameHandler.cancelQueuedSpell(); + chatInputBuffer[0] = '\0'; + return; + } + // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) // /equipset — list available sets in chat if (cmdLower == "equipset") { From c44e1bde0aeee86197ba6c91dfcd070a893e4c65 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:28:28 -0700 Subject: [PATCH 18/54] feat: fire UPDATE_FACTION, QUEST_ACCEPTED, and QUEST_LOG_UPDATE events Fire UPDATE_FACTION when reputation standings change (SMSG_SET_FACTION_STANDING). Fire QUEST_ACCEPTED with quest ID when a new quest is added to the log. Fire QUEST_LOG_UPDATE on both quest acceptance and quest completion. Enables reputation tracking and quest log addons. --- src/game/game_handler.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0d1253d4..47e07e77 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4156,6 +4156,8 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage(buf); watchedFactionId_ = factionId; if (repChangeCallback_) repChangeCallback_(name, delta, standing); + if (addonEventCallback_) + addonEventCallback_("UPDATE_FACTION", {}); } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } @@ -5289,6 +5291,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } } } + if (addonEventCallback_) addonEventCallback_("QUEST_LOG_UPDATE", {}); // Re-query all nearby quest giver NPCs so markers refresh if (socket) { for (const auto& [guid, entity] : entityManager.getEntities()) { @@ -21052,6 +21055,10 @@ void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::strin entry.title = title.empty() ? ("Quest #" + std::to_string(questId)) : title; entry.objectives = objectives; questLog_.push_back(std::move(entry)); + if (addonEventCallback_) { + addonEventCallback_("QUEST_ACCEPTED", {std::to_string(questId)}); + addonEventCallback_("QUEST_LOG_UPDATE", {}); + } } bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { From ee59c37b83a905a4cc7f486495174238c2f33fc8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:33:34 -0700 Subject: [PATCH 19/54] feat: add loot method change notifications and CRITERIA_UPDATE event Show "Loot method changed to Master Looter/Round Robin/etc." in chat when group loot method changes via SMSG_GROUP_LIST. Fire CRITERIA_UPDATE addon event with criteria ID and progress when achievement criteria progress changes, enabling achievement tracking addons. --- 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 47e07e77..a2a0cc51 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4993,8 +4993,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t progress = packet.readUInt64(); packet.readUInt32(); // elapsedTime packet.readUInt32(); // creationTime + uint64_t oldProgress = 0; + auto cpit = criteriaProgress_.find(criteriaId); + if (cpit != criteriaProgress_.end()) oldProgress = cpit->second; criteriaProgress_[criteriaId] = progress; LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); + // Fire addon event for achievement tracking addons + if (addonEventCallback_ && progress != oldProgress) + addonEventCallback_("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)}); } break; } @@ -19780,6 +19786,7 @@ void GameHandler::handleGroupList(network::Packet& packet) { const bool hasRoles = isActiveExpansion("wotlk"); // Snapshot state before reset so we can detect transitions. const uint32_t prevCount = partyData.memberCount; + const uint8_t prevLootMethod = partyData.lootMethod; const bool wasInGroup = !partyData.isEmpty(); // Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta. // Without this, repeated GROUP_LIST packets push duplicate members. @@ -19796,6 +19803,14 @@ void GameHandler::handleGroupList(network::Packet& packet) { } else if (nowInGroup && partyData.memberCount != prevCount) { LOG_INFO("Group updated: ", partyData.memberCount, " members"); } + // Loot method change notification + if (wasInGroup && nowInGroup && partyData.lootMethod != prevLootMethod) { + static const char* kLootMethods[] = { + "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" + }; + const char* methodName = (partyData.lootMethod < 5) ? kLootMethods[partyData.lootMethod] : "Unknown"; + addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); + } // Fire GROUP_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED for Lua addons if (addonEventCallback_) { addonEventCallback_("GROUP_ROSTER_UPDATE", {}); From f712d3de946a7b7ca20390f30131841af5e8ac6d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:37:35 -0700 Subject: [PATCH 20/54] feat: add quest log Lua API for quest tracking addons Add GetNumQuestLogEntries(), GetQuestLogTitle(index), GetQuestLogQuestText(index), and IsQuestComplete(questID). GetQuestLogTitle returns WoW-compatible 8 values including title, isComplete flag, and questID. Enables quest tracking addons like Questie and QuestHelper to access the player's quest log. --- src/addons/lua_engine.cpp | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 2e44ce2d..71a916e8 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -851,6 +851,64 @@ static int lua_GetContainerNumFreeSlots(lua_State* L) { return 2; } +// --- Quest Log API --- + +static int lua_GetNumQuestLogEntries(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + const auto& ql = gh->getQuestLog(); + lua_pushnumber(L, ql.size()); // numEntries + lua_pushnumber(L, 0); // numQuests (headers not tracked) + return 2; +} + +// GetQuestLogTitle(index) → title, level, suggestedGroup, isHeader, isCollapsed, isComplete, frequency, questID +static int lua_GetQuestLogTitle(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + if (index > static_cast(ql.size())) { lua_pushnil(L); return 1; } + const auto& q = ql[index - 1]; // 1-based + lua_pushstring(L, q.title.c_str()); // title + lua_pushnumber(L, 0); // level (not tracked) + lua_pushnumber(L, 0); // suggestedGroup + lua_pushboolean(L, 0); // isHeader + lua_pushboolean(L, 0); // isCollapsed + lua_pushboolean(L, q.complete); // isComplete + lua_pushnumber(L, 0); // frequency + lua_pushnumber(L, q.questId); // questID + return 8; +} + +// GetQuestLogQuestText(index) → description, objectives +static int lua_GetQuestLogQuestText(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + if (index > static_cast(ql.size())) { lua_pushnil(L); return 1; } + const auto& q = ql[index - 1]; + lua_pushstring(L, ""); // description (not stored) + lua_pushstring(L, q.objectives.c_str()); // objectives + return 2; +} + +// IsQuestComplete(questID) → boolean +static int lua_IsQuestComplete(lua_State* L) { + auto* gh = getGameHandler(L); + uint32_t questId = static_cast(luaL_checknumber(L, 1)); + if (!gh) { lua_pushboolean(L, 0); return 1; } + for (const auto& q : gh->getQuestLog()) { + if (q.questId == questId) { + lua_pushboolean(L, q.complete); + return 1; + } + } + lua_pushboolean(L, 0); + return 1; +} + // --- Additional WoW API --- static int lua_UnitAffectingCombat(lua_State* L) { @@ -1353,6 +1411,11 @@ void LuaEngine::registerCoreAPI() { {"GetContainerItemInfo", lua_GetContainerItemInfo}, {"GetContainerItemLink", lua_GetContainerItemLink}, {"GetContainerNumFreeSlots", lua_GetContainerNumFreeSlots}, + // Quest log API + {"GetNumQuestLogEntries", lua_GetNumQuestLogEntries}, + {"GetQuestLogTitle", lua_GetQuestLogTitle}, + {"GetQuestLogQuestText", lua_GetQuestLogQuestText}, + {"IsQuestComplete", lua_IsQuestComplete}, // Utilities {"strsplit", lua_strsplit}, {"strtrim", lua_strtrim}, From 7c5bec50ef9d5e7b1112da56fe521e700427b8b2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:49:49 -0700 Subject: [PATCH 21/54] fix: increase world packet size limit from 16KB to 32KB The 0x4000 (16384) limit was too conservative and could disconnect the client when the server sends large packets such as SMSG_GUILD_ROSTER with 500+ members (~30KB) or SMSG_AUCTION_LIST with many results. Increase to 0x8000 (32768) which covers all normal gameplay while still protecting against framing desync from encryption errors. --- src/network/world_socket.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 271fc0e9..7fe18709 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -668,7 +668,7 @@ void WorldSocket::tryParsePackets() { closeSocketNoJoin(); return; } - constexpr uint16_t kMaxWorldPacketSize = 0x4000; + constexpr uint16_t kMaxWorldPacketSize = 0x8000; // 32KB — allows large guild rosters, auction lists if (size > kMaxWorldPacketSize) { LOG_ERROR("World packet framing desync: oversized packet size=", size, " rawHdr=", std::hex, From 3f0b152fe9d64b150bc6876c3069b986b597f209 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:53:01 -0700 Subject: [PATCH 22/54] fix: return debuff type string from UnitBuff/UnitDebuff/UnitAura The debuffType field (5th return value) was always nil. Now resolves dispel type from Spell.dbc via getSpellDispelType(): returns "Magic", "Curse", "Disease", or "Poison" for debuffs. Enables dispel-focused addons like Decursive and Grid to detect debuff categories. --- src/addons/lua_engine.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 71a916e8..0c40b927 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -379,7 +379,17 @@ static int lua_UnitAura(lua_State* L, bool wantBuff) { if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); else lua_pushnil(L); // icon texture path lua_pushnumber(L, aura.charges); // count - lua_pushnil(L); // debuffType + // debuffType: resolve from Spell.dbc dispel type + { + uint8_t dt = gh->getSpellDispelType(aura.spellId); + switch (dt) { + case 1: lua_pushstring(L, "Magic"); break; + case 2: lua_pushstring(L, "Curse"); break; + case 3: lua_pushstring(L, "Disease"); break; + case 4: lua_pushstring(L, "Poison"); break; + default: lua_pushnil(L); break; + } + } lua_pushnumber(L, aura.maxDurationMs > 0 ? aura.maxDurationMs / 1000.0 : 0); // duration lua_pushnumber(L, 0); // expirationTime (would need absolute time) lua_pushnil(L); // caster From ffe16f5cf2c7865def5640e346f1309f10be9893 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:56:20 -0700 Subject: [PATCH 23/54] feat: add equipment slot Lua API for gear inspection addons Add GetInventoryItemLink(unit, slotId), GetInventoryItemID(unit, slotId), and GetInventoryItemTexture(unit, slotId) for WoW inventory slots 1-19 (Head through Tabard). Returns quality-colored item links with WoW format. Enables gear inspection and item level calculation addons. --- src/addons/lua_engine.cpp | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 0c40b927..9bf25ed8 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -861,6 +861,69 @@ static int lua_GetContainerNumFreeSlots(lua_State* L) { return 2; } +// --- Equipment Slot API --- +// WoW inventory slot IDs: 1=Head,2=Neck,3=Shoulders,4=Shirt,5=Chest, +// 6=Waist,7=Legs,8=Feet,9=Wrists,10=Hands,11=Ring1,12=Ring2, +// 13=Trinket1,14=Trinket2,15=Back,16=MainHand,17=OffHand,18=Ranged,19=Tabard + +static int lua_GetInventoryItemLink(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + int slotId = static_cast(luaL_checknumber(L, 2)); + if (!gh || slotId < 1 || slotId > 19) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const auto& slot = inv.getEquipSlot(static_cast(slotId - 1)); + if (slot.empty()) { lua_pushnil(L); return 1; } + + const auto* info = gh->getItemInfo(slot.item.itemId); + std::string name = info ? info->name : slot.item.name; + uint32_t q = info ? info->quality : static_cast(slot.item.quality); + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = q < 8 ? q : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], slot.item.itemId, name.c_str()); + lua_pushstring(L, link); + return 1; +} + +static int lua_GetInventoryItemID(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + int slotId = static_cast(luaL_checknumber(L, 2)); + if (!gh || slotId < 1 || slotId > 19) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const auto& slot = inv.getEquipSlot(static_cast(slotId - 1)); + if (slot.empty()) { lua_pushnil(L); return 1; } + lua_pushnumber(L, slot.item.itemId); + return 1; +} + +static int lua_GetInventoryItemTexture(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + int slotId = static_cast(luaL_checknumber(L, 2)); + if (!gh || slotId < 1 || slotId > 19) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const auto& slot = inv.getEquipSlot(static_cast(slotId - 1)); + if (slot.empty()) { lua_pushnil(L); return 1; } + // Return spell icon path for the item's on-use spell, or nil + lua_pushnil(L); + return 1; +} + // --- Quest Log API --- static int lua_GetNumQuestLogEntries(lua_State* L) { @@ -1421,6 +1484,10 @@ void LuaEngine::registerCoreAPI() { {"GetContainerItemInfo", lua_GetContainerItemInfo}, {"GetContainerItemLink", lua_GetContainerItemLink}, {"GetContainerNumFreeSlots", lua_GetContainerNumFreeSlots}, + // Equipment slot API + {"GetInventoryItemLink", lua_GetInventoryItemLink}, + {"GetInventoryItemID", lua_GetInventoryItemID}, + {"GetInventoryItemTexture", lua_GetInventoryItemTexture}, // Quest log API {"GetNumQuestLogEntries", lua_GetNumQuestLogEntries}, {"GetQuestLogTitle", lua_GetQuestLogTitle}, From 5adb9370d204fd2b7e42d51da929878f4d46cf30 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 17:58:53 -0700 Subject: [PATCH 24/54] fix: return caster unit ID from UnitBuff/UnitDebuff/UnitAura The caster field (8th return value) was always nil. Now returns the caster's unit ID ("player", "target", "focus", "pet") or hex GUID string for other units. Enables addons to identify who applied a buff/debuff for filtering and tracking purposes. --- src/addons/lua_engine.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 9bf25ed8..d13b4333 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -392,7 +392,24 @@ static int lua_UnitAura(lua_State* L, bool wantBuff) { } lua_pushnumber(L, aura.maxDurationMs > 0 ? aura.maxDurationMs / 1000.0 : 0); // duration lua_pushnumber(L, 0); // expirationTime (would need absolute time) - lua_pushnil(L); // caster + // caster: return unit ID string if caster is known + if (aura.casterGuid != 0) { + if (aura.casterGuid == gh->getPlayerGuid()) + lua_pushstring(L, "player"); + else if (aura.casterGuid == gh->getTargetGuid()) + lua_pushstring(L, "target"); + else if (aura.casterGuid == gh->getFocusGuid()) + lua_pushstring(L, "focus"); + else if (aura.casterGuid == gh->getPetGuid()) + lua_pushstring(L, "pet"); + else { + char cBuf[32]; + snprintf(cBuf, sizeof(cBuf), "0x%016llX", (unsigned long long)aura.casterGuid); + lua_pushstring(L, cBuf); + } + } else { + lua_pushnil(L); + } lua_pushboolean(L, 0); // isStealable lua_pushboolean(L, 0); // shouldConsolidate lua_pushnumber(L, aura.spellId); // spellId From 1d7eaaf2a0e44357be20aee2329ca48cbeba88bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:00:57 -0700 Subject: [PATCH 25/54] fix: compute aura expirationTime for addon countdown timers The expirationTime field (7th return value of UnitBuff/UnitDebuff/UnitAura) was hardcoded to 0. Now returns GetTime() + remaining seconds, matching WoW's convention where addons compute remaining = expirationTime - GetTime(). Enables buff/debuff timer addons like OmniCC and WeakAuras. --- src/addons/lua_engine.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index d13b4333..915dfac9 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -391,7 +391,20 @@ static int lua_UnitAura(lua_State* L, bool wantBuff) { } } lua_pushnumber(L, aura.maxDurationMs > 0 ? aura.maxDurationMs / 1000.0 : 0); // duration - lua_pushnumber(L, 0); // expirationTime (would need absolute time) + // expirationTime: GetTime() + remaining seconds (so addons can compute countdown) + if (aura.durationMs > 0) { + uint64_t auraNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remMs = aura.getRemainingMs(auraNowMs); + // GetTime epoch = steady_clock relative to engine start + static auto sStart = std::chrono::steady_clock::now(); + double nowSec = std::chrono::duration( + std::chrono::steady_clock::now() - sStart).count(); + lua_pushnumber(L, nowSec + remMs / 1000.0); + } else { + lua_pushnumber(L, 0); // permanent aura + } // caster: return unit ID string if caster is known if (aura.casterGuid != 0) { if (aura.casterGuid == gh->getPlayerGuid()) From 922177abe03fb020ec001c6ca875f39fb921dd45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:05:09 -0700 Subject: [PATCH 26/54] fix: invoke despawn callbacks during zone transitions to release renderer resources handleNewWorld() previously called entityManager.clear() directly without notifying the renderer, leaving stale M2 instances and character models allocated. Now iterates all entities and fires creatureDespawnCallback, playerDespawnCallback, and gameObjectDespawnCallback before clearing. Also clears player caches (visible items, cast states, aura cache, combat text) to prevent state leaking between zones. --- src/game/game_handler.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a2a0cc51..a0a84fad 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -23063,7 +23063,24 @@ void GameHandler::handleNewWorld(network::Packet& packet) { mountCallback_(0); } - // Clear world state for the new map + // Invoke despawn callbacks for all entities before clearing, so the renderer + // can release M2 instances, character models, and associated resources. + for (const auto& [guid, entity] : entityManager.getEntities()) { + if (guid == playerGuid) continue; // skip self + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { + gameObjectDespawnCallback_(guid); + } + } + otherPlayerVisibleItemEntries_.clear(); + otherPlayerVisibleDirty_.clear(); + otherPlayerMoveTimeMs_.clear(); + unitCastStates_.clear(); + unitAurasCache_.clear(); + combatText.clear(); entityManager.clear(); hostileAttackers_.clear(); worldStates_.clear(); From ff1840415efeff9d750f61c06619a50067cea96d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:07:00 -0700 Subject: [PATCH 27/54] fix: invoke despawn callbacks on disconnect to prevent renderer leaks Mirror the zone-transition cleanup in disconnect(): fire despawn callbacks for all entities before clearing the entity manager. Prevents M2 instances and character models from leaking when the player disconnects and reconnects quickly (e.g., server kick, network recovery). --- src/game/game_handler.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a0a84fad..65ed32ff 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -784,7 +784,22 @@ void GameHandler::disconnect() { wardenLoadedModule_.reset(); pendingIncomingPackets_.clear(); pendingUpdateObjectWork_.clear(); - // Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects. + // Fire despawn callbacks so the renderer releases M2/character model resources. + for (const auto& [guid, entity] : entityManager.getEntities()) { + if (guid == playerGuid) continue; + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) + creatureDespawnCallback_(guid); + else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) + playerDespawnCallback_(guid); + else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) + gameObjectDespawnCallback_(guid); + } + otherPlayerVisibleItemEntries_.clear(); + otherPlayerVisibleDirty_.clear(); + otherPlayerMoveTimeMs_.clear(); + unitCastStates_.clear(); + unitAurasCache_.clear(); + combatText.clear(); entityManager.clear(); setState(WorldState::DISCONNECTED); LOG_INFO("Disconnected from world server"); From 71837ade19e3adbafcfea78ad02a009d9c0ff090 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:12:23 -0700 Subject: [PATCH 28/54] feat: show zone name on loading screen during world transitions Add setZoneName() to LoadingScreen and display the map name from Map.dbc as large gold text with drop shadow above the progress bar. Shown in both render() and renderOverlay() paths. Zone name is resolved from gameHandler's getMapName(mapId) during world load. Improves feedback during zone transitions. --- include/rendering/loading_screen.hpp | 2 ++ src/core/application.cpp | 9 +++++++++ src/rendering/loading_screen.cpp | 30 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/include/rendering/loading_screen.hpp b/include/rendering/loading_screen.hpp index afd134b9..a0ed13a5 100644 --- a/include/rendering/loading_screen.hpp +++ b/include/rendering/loading_screen.hpp @@ -30,6 +30,7 @@ public: void setProgress(float progress) { loadProgress = progress; } void setStatus(const std::string& status) { statusText = status; } + void setZoneName(const std::string& name) { zoneName = name; } // Must be set before initialize() for Vulkan texture upload void setVkContext(VkContext* ctx) { vkCtx = ctx; } @@ -53,6 +54,7 @@ private: float loadProgress = 0.0f; std::string statusText = "Loading..."; + std::string zoneName; int imageWidth = 0; int imageHeight = 0; diff --git a/src/core/application.cpp b/src/core/application.cpp index ce3883db..a0c8e1a7 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4286,6 +4286,15 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float window->swapBuffers(); }; + // Set zone name on loading screen from Map.dbc + if (gameHandler) { + std::string mapDisplayName = gameHandler->getMapName(mapId); + if (!mapDisplayName.empty()) + loadingScreen.setZoneName(mapDisplayName); + else + loadingScreen.setZoneName("Loading..."); + } + showProgress("Entering world...", 0.0f); // --- Clean up previous map's state on map change --- diff --git a/src/rendering/loading_screen.cpp b/src/rendering/loading_screen.cpp index a2e83a2b..92c1fe1c 100644 --- a/src/rendering/loading_screen.cpp +++ b/src/rendering/loading_screen.cpp @@ -261,6 +261,20 @@ void LoadingScreen::renderOverlay() { ImVec2(0, 0), ImVec2(screenW, screenH)); } + // Zone name header + if (!zoneName.empty()) { + ImFont* font = ImGui::GetFont(); + float zoneTextSize = 24.0f; + ImVec2 zoneSize = font->CalcTextSizeA(zoneTextSize, FLT_MAX, 0.0f, zoneName.c_str()); + float zoneX = (screenW - zoneSize.x) * 0.5f; + float zoneY = screenH * 0.06f - 44.0f; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText(font, zoneTextSize, ImVec2(zoneX + 2.0f, zoneY + 2.0f), + IM_COL32(0, 0, 0, 200), zoneName.c_str()); + dl->AddText(font, zoneTextSize, ImVec2(zoneX, zoneY), + IM_COL32(255, 220, 120, 255), zoneName.c_str()); + } + // Progress bar { const float barWidthFrac = 0.6f; @@ -332,6 +346,22 @@ void LoadingScreen::render() { ImVec2(0, 0), ImVec2(screenW, screenH)); } + // Zone name header (large text centered above progress bar) + if (!zoneName.empty()) { + ImFont* font = ImGui::GetFont(); + float zoneTextSize = 24.0f; + ImVec2 zoneSize = font->CalcTextSizeA(zoneTextSize, FLT_MAX, 0.0f, zoneName.c_str()); + float zoneX = (screenW - zoneSize.x) * 0.5f; + float zoneY = screenH * 0.06f - 44.0f; // above percentage text + ImDrawList* dl = ImGui::GetWindowDrawList(); + // Drop shadow + dl->AddText(font, zoneTextSize, ImVec2(zoneX + 2.0f, zoneY + 2.0f), + IM_COL32(0, 0, 0, 200), zoneName.c_str()); + // Gold text + dl->AddText(font, zoneTextSize, ImVec2(zoneX, zoneY), + IM_COL32(255, 220, 120, 255), zoneName.c_str()); + } + // Progress bar (top of screen) { const float barWidthFrac = 0.6f; From f03ed8551b8a0dd9a721f974bd4f0c665547463f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:16:12 -0700 Subject: [PATCH 29/54] feat: add GetGameTime, GetServerTime, UnitXP, and UnitXPMax Lua APIs GetGameTime() returns server game hours and minutes from the day/night cycle. GetServerTime() returns Unix timestamp. UnitXP("player") and UnitXPMax("player") return current and next-level XP values. Used by XP tracking addons and time-based conditionals. --- src/addons/lua_engine.cpp | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 915dfac9..9cd5b743 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -954,6 +954,55 @@ static int lua_GetInventoryItemTexture(lua_State* L) { return 1; } +// --- Time & XP API --- + +static int lua_GetGameTime(lua_State* L) { + // Returns server game time as hours, minutes + auto* gh = getGameHandler(L); + if (gh) { + float gt = gh->getGameTime(); + int hours = static_cast(gt) % 24; + int mins = static_cast((gt - static_cast(gt)) * 60.0f); + lua_pushnumber(L, hours); + lua_pushnumber(L, mins); + } else { + lua_pushnumber(L, 12); + lua_pushnumber(L, 0); + } + return 2; +} + +static int lua_GetServerTime(lua_State* L) { + lua_pushnumber(L, static_cast(std::time(nullptr))); + return 1; +} + +static int lua_UnitXP(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + std::string u(uid); + for (char& c : u) c = static_cast(std::tolower(static_cast(c))); + if (u == "player") lua_pushnumber(L, gh->getPlayerXp()); + else lua_pushnumber(L, 0); + return 1; +} + +static int lua_UnitXPMax(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 1); return 1; } + std::string u(uid); + for (char& c : u) c = static_cast(std::tolower(static_cast(c))); + if (u == "player") { + uint32_t nlxp = gh->getPlayerNextLevelXp(); + lua_pushnumber(L, nlxp > 0 ? nlxp : 1); + } else { + lua_pushnumber(L, 1); + } + return 1; +} + // --- Quest Log API --- static int lua_GetNumQuestLogEntries(lua_State* L) { @@ -1518,6 +1567,11 @@ void LuaEngine::registerCoreAPI() { {"GetInventoryItemLink", lua_GetInventoryItemLink}, {"GetInventoryItemID", lua_GetInventoryItemID}, {"GetInventoryItemTexture", lua_GetInventoryItemTexture}, + // Time/XP API + {"GetGameTime", lua_GetGameTime}, + {"GetServerTime", lua_GetServerTime}, + {"UnitXP", lua_UnitXP}, + {"UnitXPMax", lua_UnitXPMax}, // Quest log API {"GetNumQuestLogEntries", lua_GetNumQuestLogEntries}, {"GetQuestLogTitle", lua_GetQuestLogTitle}, From 180990b9f113e1ebc79e8db97dd07223605378a6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:21:34 -0700 Subject: [PATCH 30/54] feat: play minimap ping sound when party members ping the map Add playMinimapPing() to UiSoundManager with MapPing.wav (falls back to target select sound). Play the ping sound in MSG_MINIMAP_PING handler when the sender is not the local player. Provides audio feedback for party member map pings, matching WoW behavior. --- include/audio/ui_sound_manager.hpp | 4 ++++ src/audio/ui_sound_manager.cpp | 9 +++++++++ src/game/game_handler.cpp | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/include/audio/ui_sound_manager.hpp b/include/audio/ui_sound_manager.hpp index 6423d460..7a9a66b8 100644 --- a/include/audio/ui_sound_manager.hpp +++ b/include/audio/ui_sound_manager.hpp @@ -78,6 +78,9 @@ public: // Chat notifications void playWhisperReceived(); + // Minimap ping + void playMinimapPing(); + private: struct UISample { std::string path; @@ -126,6 +129,7 @@ private: std::vector selectTargetSounds_; std::vector deselectTargetSounds_; std::vector whisperSounds_; + std::vector minimapPingSounds_; // State tracking float volumeScale_ = 1.0f; diff --git a/src/audio/ui_sound_manager.cpp b/src/audio/ui_sound_manager.cpp index 8ef800f0..6518259e 100644 --- a/src/audio/ui_sound_manager.cpp +++ b/src/audio/ui_sound_manager.cpp @@ -130,6 +130,12 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { } } + // Minimap ping sound + minimapPingSounds_.resize(1); + if (!loadSound("Sound\\Interface\\MapPing.wav", minimapPingSounds_[0], assets)) { + minimapPingSounds_ = selectTargetSounds_; // fallback to target select sound + } + LOG_INFO("UISoundManager: Window sounds - Bag: ", (bagOpenLoaded && bagCloseLoaded) ? "YES" : "NO", ", QuestLog: ", (questLogOpenLoaded && questLogCloseLoaded) ? "YES" : "NO", ", CharSheet: ", (charSheetOpenLoaded && charSheetCloseLoaded) ? "YES" : "NO"); @@ -236,5 +242,8 @@ void UiSoundManager::playTargetDeselect() { playSound(deselectTargetSounds_); } // Chat notifications void UiSoundManager::playWhisperReceived() { playSound(whisperSounds_); } +// Minimap ping +void UiSoundManager::playMinimapPing() { playSound(minimapPingSounds_); } + } // namespace audio } // namespace wowee diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 65ed32ff..ea5e0904 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3544,6 +3544,13 @@ void GameHandler::handlePacket(network::Packet& packet) { ping.wowY = pingX; // canonical WoW Y = west = server's posX ping.age = 0.0f; minimapPings_.push_back(ping); + // Play ping sound for other players' pings (not our own) + if (senderGuid != playerGuid) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playMinimapPing(); + } + } break; } case Opcode::SMSG_ZONE_UNDER_ATTACK: { From 2a9a7fe04e884faab7d11bb144c8726304c1de01 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:25:39 -0700 Subject: [PATCH 31/54] feat: add UnitClassification Lua API for nameplate and boss mod addons Returns WoW-standard classification strings: "normal", "elite", "rareelite", "worldboss", or "rare" based on creature rank from CreatureCache. Used by nameplate addons (Plater, TidyPlates) and boss mods (DBM) to detect elite/ boss/rare mobs for special handling. --- src/addons/lua_engine.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 9cd5b743..d63357de 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1194,6 +1194,33 @@ static int lua_UnitCreatureType(lua_State* L) { return 1; } +// UnitClassification(unit) → "normal", "elite", "rareelite", "worldboss", "rare" +static int lua_UnitClassification(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "normal"); return 1; } + const char* uid = luaL_optstring(L, 1, "target"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushstring(L, "normal"); return 1; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity || entity->getType() == game::ObjectType::PLAYER) { + lua_pushstring(L, "normal"); + return 1; + } + auto unit = std::dynamic_pointer_cast(entity); + if (!unit) { lua_pushstring(L, "normal"); return 1; } + int rank = gh->getCreatureRank(unit->getEntry()); + switch (rank) { + case 1: lua_pushstring(L, "elite"); break; + case 2: lua_pushstring(L, "rareelite"); break; + case 3: lua_pushstring(L, "worldboss"); break; + case 4: lua_pushstring(L, "rare"); break; + default: lua_pushstring(L, "normal"); break; + } + return 1; +} + // --- Frame System --- // Minimal WoW-compatible frame objects with RegisterEvent/SetScript/GetScript. // Frames are Lua tables with a metatable that provides methods. @@ -1558,6 +1585,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsFriend", lua_UnitIsFriend}, {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, + {"UnitClassification", lua_UnitClassification}, // Container/bag API {"GetContainerNumSlots", lua_GetContainerNumSlots}, {"GetContainerItemInfo", lua_GetContainerItemInfo}, From ce128990d29715b77ac97d549fb6d6d499973d05 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:33:44 -0700 Subject: [PATCH 32/54] feat: add IsInInstance, GetInstanceInfo, and GetInstanceDifficulty Lua APIs IsInInstance() returns whether player is in an instance and the type. GetInstanceInfo() returns map name, instance type, difficulty index/name, and max players. GetInstanceDifficulty() returns 1-based difficulty index. Critical for raid/dungeon addons like DBM for instance detection. --- src/addons/lua_engine.cpp | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index d63357de..2b1b4b03 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1194,6 +1194,42 @@ static int lua_UnitCreatureType(lua_State* L) { return 1; } +// IsInInstance() → isInstance, instanceType +static int lua_IsInInstance(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); lua_pushstring(L, "none"); return 2; } + bool inInstance = gh->isInInstance(); + lua_pushboolean(L, inInstance); + lua_pushstring(L, inInstance ? "party" : "none"); // simplified: "none", "party", "raid", "pvp", "arena" + return 2; +} + +// GetInstanceInfo() → name, type, difficultyIndex, difficultyName, maxPlayers, ... +static int lua_GetInstanceInfo(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { + lua_pushstring(L, ""); lua_pushstring(L, "none"); lua_pushnumber(L, 0); + lua_pushstring(L, "Normal"); lua_pushnumber(L, 0); + return 5; + } + std::string mapName = gh->getMapName(gh->getCurrentMapId()); + lua_pushstring(L, mapName.c_str()); // 1: name + lua_pushstring(L, gh->isInInstance() ? "party" : "none"); // 2: instanceType + lua_pushnumber(L, gh->getInstanceDifficulty()); // 3: difficultyIndex + static const char* kDiff[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; + uint32_t diff = gh->getInstanceDifficulty(); + lua_pushstring(L, (diff < 4) ? kDiff[diff] : "Normal"); // 4: difficultyName + lua_pushnumber(L, 5); // 5: maxPlayers (default 5-man) + return 5; +} + +// GetInstanceDifficulty() → difficulty (1=normal, 2=heroic, 3=25normal, 4=25heroic) +static int lua_GetInstanceDifficulty(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? (gh->getInstanceDifficulty() + 1) : 1); // WoW returns 1-based + return 1; +} + // UnitClassification(unit) → "normal", "elite", "rareelite", "worldboss", "rare" static int lua_UnitClassification(lua_State* L) { auto* gh = getGameHandler(L); @@ -1586,6 +1622,9 @@ void LuaEngine::registerCoreAPI() { {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, {"UnitClassification", lua_UnitClassification}, + {"IsInInstance", lua_IsInInstance}, + {"GetInstanceInfo", lua_GetInstanceInfo}, + {"GetInstanceDifficulty", lua_GetInstanceDifficulty}, // Container/bag API {"GetContainerNumSlots", lua_GetContainerNumSlots}, {"GetContainerItemInfo", lua_GetContainerItemInfo}, From 4bd237b654b2c59a9721b13ed97b8096ae50e608 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:42:33 -0700 Subject: [PATCH 33/54] feat: add IsUsableSpell Lua API for spell usability checks Returns (usable, noMana) tuple. Checks if the spell is known and not on cooldown. Accepts spell ID or name. Used by action bar addons and WeakAuras for conditional spell display (greyed out when unusable). --- src/addons/lua_engine.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 2b1b4b03..157dd509 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1194,6 +1194,41 @@ static int lua_UnitCreatureType(lua_State* L) { return 1; } +// IsUsableSpell(spellIdOrName) → usable, noMana +static int lua_IsUsableSpell(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); lua_pushboolean(L, 0); return 2; } + + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + if (!name || !*name) { lua_pushboolean(L, 0); lua_pushboolean(L, 0); return 2; } + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + + if (spellId == 0 || !gh->getKnownSpells().count(spellId)) { + lua_pushboolean(L, 0); + lua_pushboolean(L, 0); + return 2; + } + + // Check if on cooldown + float cd = gh->getSpellCooldown(spellId); + bool onCooldown = (cd > 0.1f); + + lua_pushboolean(L, onCooldown ? 0 : 1); // usable (not on cooldown) + lua_pushboolean(L, 0); // noMana (can't determine without spell cost data) + return 2; +} + // IsInInstance() → isInstance, instanceType static int lua_IsInInstance(lua_State* L) { auto* gh = getGameHandler(L); @@ -1622,6 +1657,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, {"UnitClassification", lua_UnitClassification}, + {"IsUsableSpell", lua_IsUsableSpell}, {"IsInInstance", lua_IsInInstance}, {"GetInstanceInfo", lua_GetInstanceInfo}, {"GetInstanceDifficulty", lua_GetInstanceDifficulty}, From 2b99011cd8ac6d51a7fd89dd63bddcff45d22b1d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:51:05 -0700 Subject: [PATCH 34/54] fix: cap gossipPois_ vector growth and add soft frame rate limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap gossipPois_ at 200 entries (both gossip POI and quest POI paths) to prevent unbounded memory growth from rapid gossip/quest queries. Add soft 240 FPS frame rate limiter when vsync is off to prevent 100% CPU usage — sleeps for remaining frame budget when frame completes in under 4ms. --- src/core/application.cpp | 9 +++++++++ src/game/game_handler.cpp | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index a0c8e1a7..2826470a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -646,6 +646,15 @@ void Application::run() { LOG_ERROR("GPU device lost — exiting application"); window->setShouldClose(true); } + + // Soft frame rate cap when vsync is off to prevent 100% CPU usage. + // Target ~240 FPS max (~4.2ms per frame); vsync handles its own pacing. + if (!window->isVsyncEnabled() && deltaTime < 0.004f) { + float sleepMs = (0.004f - deltaTime) * 1000.0f; + if (sleepMs > 0.5f) + std::this_thread::sleep_for(std::chrono::microseconds( + static_cast(sleepMs * 900.0f))); // 90% of target to account for sleep overshoot + } } } catch (...) { watchdogRunning.store(false, std::memory_order_release); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ea5e0904..e42704e3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2539,6 +2539,8 @@ void GameHandler::handlePacket(network::Packet& packet) { poi.icon = icon; poi.data = data; poi.name = std::move(name); + // Cap POI count to prevent unbounded growth from rapid gossip queries + if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin()); gossipPois_.push_back(std::move(poi)); LOG_DEBUG("SMSG_GOSSIP_POI: x=", poiX, " y=", poiY, " icon=", icon); break; @@ -21031,6 +21033,7 @@ void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { poi.name = questTitle.empty() ? "Quest objective" : questTitle; LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId, " centroid=(", poi.x, ",", poi.y, ") title=", poi.name); + if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin()); gossipPois_.push_back(std::move(poi)); } } From 3a4d59d584e4ea105735c9f7d30add5098181336 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 18:53:56 -0700 Subject: [PATCH 35/54] feat: add GetXPExhaustion and GetRestState Lua APIs for rested XP tracking GetXPExhaustion() returns rested XP pool remaining (nil if none). GetRestState() returns 1 (normal) or 2 (rested) based on inn/city state. Used by XP bar addons like Titan Panel and XP tracking WeakAuras. --- src/addons/lua_engine.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 157dd509..f9821783 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1003,6 +1003,23 @@ static int lua_UnitXPMax(lua_State* L) { return 1; } +// GetXPExhaustion() → rested XP pool remaining (nil if none) +static int lua_GetXPExhaustion(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + uint32_t rested = gh->getPlayerRestedXp(); + if (rested > 0) lua_pushnumber(L, rested); + else lua_pushnil(L); + return 1; +} + +// GetRestState() → 1 = normal, 2 = rested +static int lua_GetRestState(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, (gh && gh->isPlayerResting()) ? 2 : 1); + return 1; +} + // --- Quest Log API --- static int lua_GetNumQuestLogEntries(lua_State* L) { @@ -1675,6 +1692,8 @@ void LuaEngine::registerCoreAPI() { {"GetServerTime", lua_GetServerTime}, {"UnitXP", lua_UnitXP}, {"UnitXPMax", lua_UnitXPMax}, + {"GetXPExhaustion", lua_GetXPExhaustion}, + {"GetRestState", lua_GetRestState}, // Quest log API {"GetNumQuestLogEntries", lua_GetNumQuestLogEntries}, {"GetQuestLogTitle", lua_GetQuestLogTitle}, From 23a7d3718c5e0c2379d0fc6508672e547fd23e16 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 19:03:34 -0700 Subject: [PATCH 36/54] fix: return WoW-standard (start, duration, enabled) from GetSpellCooldown Previously returned (0, remaining) which broke addons computing remaining time as start + duration - GetTime(). Now returns (GetTime(), remaining, 1) when on cooldown and (0, 0, 1) when off cooldown, plus the third 'enabled' value that WoW always returns. Fixes cooldown display in OmniCC and similar. --- src/addons/lua_engine.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index f9821783..58a7381b 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -534,9 +534,20 @@ static int lua_GetSpellCooldown(lua_State* L) { } } float cd = gh->getSpellCooldown(spellId); - lua_pushnumber(L, 0); // start time (not tracked precisely, return 0) - lua_pushnumber(L, cd); // duration remaining - return 2; + // WoW returns (start, duration, enabled) where remaining = start + duration - GetTime() + // Compute start = GetTime() - elapsed, duration = total cooldown + static auto sStart = std::chrono::steady_clock::now(); + double nowSec = std::chrono::duration( + std::chrono::steady_clock::now() - sStart).count(); + if (cd > 0.01f) { + lua_pushnumber(L, nowSec); // start (approximate — we don't track exact start) + lua_pushnumber(L, cd); // duration (remaining, used as total for simplicity) + } else { + lua_pushnumber(L, 0); // not on cooldown + lua_pushnumber(L, 0); + } + lua_pushnumber(L, 1); // enabled (always 1 — spell is usable) + return 3; } static int lua_HasTarget(lua_State* L) { From 4b3e377addadfc84df4f5f23236cf4cdb1a8e53e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 19:18:30 -0700 Subject: [PATCH 37/54] feat: resolve random property/suffix names for item display Load ItemRandomProperties.dbc and ItemRandomSuffix.dbc lazily to resolve suffix names like "of the Eagle", "of the Monkey" etc. Add getRandomPropertyName(id) callback on GameHandler wired through Application. Append suffix to item names in SMSG_ITEM_PUSH_RESULT loot notifications so items display as "Leggings of the Eagle" instead of just "Leggings". --- include/game/game_handler.hpp | 9 +++++++++ src/core/application.cpp | 32 ++++++++++++++++++++++++++++++++ src/game/game_handler.cpp | 7 ++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5da300cc..7c4e0918 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -294,6 +294,14 @@ public: return spellIconPathResolver_ ? spellIconPathResolver_(spellId) : std::string{}; } + // Random property/suffix name resolver: randomPropertyId -> suffix name (e.g., "of the Eagle") + // Positive IDs → ItemRandomProperties.dbc; negative IDs → ItemRandomSuffix.dbc (abs value) + using RandomPropertyNameResolver = std::function; + void setRandomPropertyNameResolver(RandomPropertyNameResolver r) { randomPropertyNameResolver_ = std::move(r); } + std::string getRandomPropertyName(int32_t id) const { + return randomPropertyNameResolver_ ? randomPropertyNameResolver_(id) : std::string{}; + } + // Emote animation callback: (entityGuid, animationId) using EmoteAnimCallback = std::function; void setEmoteAnimCallback(EmoteAnimCallback cb) { emoteAnimCallback_ = std::move(cb); } @@ -2654,6 +2662,7 @@ private: AddonChatCallback addonChatCallback_; AddonEventCallback addonEventCallback_; SpellIconPathResolver spellIconPathResolver_; + RandomPropertyNameResolver randomPropertyNameResolver_; EmoteAnimCallback emoteAnimCallback_; // Targeting diff --git a/src/core/application.cpp b/src/core/application.cpp index 2826470a..8b4aeeb0 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -413,6 +413,38 @@ bool Application::initialize() { return pit->second; }); } + // Wire random property/suffix name resolver for item display + { + auto propNames = std::make_shared>(); + auto propLoaded = std::make_shared(false); + auto* amPtr = assetManager.get(); + gameHandler->setRandomPropertyNameResolver([propNames, propLoaded, amPtr](int32_t id) -> std::string { + if (!amPtr || id == 0) return {}; + if (!*propLoaded) { + *propLoaded = true; + // ItemRandomProperties.dbc: ID=0, Name=4 (string) + if (auto dbc = amPtr->loadDBC("ItemRandomProperties.dbc"); dbc && dbc->isLoaded()) { + uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + int32_t rid = static_cast(dbc->getUInt32(r, 0)); + std::string name = dbc->getString(r, nameField); + if (!name.empty() && rid > 0) (*propNames)[rid] = name; + } + } + // ItemRandomSuffix.dbc: ID=0, Name=4 (string) — stored as negative IDs + if (auto dbc = amPtr->loadDBC("ItemRandomSuffix.dbc"); dbc && dbc->isLoaded()) { + uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + int32_t rid = static_cast(dbc->getUInt32(r, 0)); + std::string name = dbc->getString(r, nameField); + if (!name.empty() && rid > 0) (*propNames)[-rid] = name; + } + } + } + auto it = propNames->find(id); + return (it != propNames->end()) ? it->second : std::string{}; + }); + } LOG_INFO("Addon system initialized, found ", addonManager_->getAddons().size(), " addon(s)"); } else { LOG_WARNING("Failed to initialize addon system"); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e42704e3..ce4098e1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1978,7 +1978,7 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t itemSlot =*/ packet.readUInt32(); uint32_t itemId = packet.readUInt32(); /*uint32_t suffixFactor =*/ packet.readUInt32(); - /*int32_t randomProp =*/ static_cast(packet.readUInt32()); + int32_t randomProp = static_cast(packet.readUInt32()); uint32_t count = packet.readUInt32(); /*uint32_t totalCount =*/ packet.readUInt32(); @@ -1987,6 +1987,11 @@ void GameHandler::handlePacket(network::Packet& packet) { if (const ItemQueryResponseData* info = getItemInfo(itemId)) { // Item info already cached — emit immediately. std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name; + // Append random suffix name (e.g., "of the Eagle") if present + if (randomProp != 0) { + std::string suffix = getRandomPropertyName(randomProp); + if (!suffix.empty()) itemName += " " + suffix; + } uint32_t quality = info->quality; std::string link = buildItemLink(itemId, quality, itemName); std::string msg = "Received: " + link; From 99f4ded3b58b6517f007b58a6f53047db4218735 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 19:22:59 -0700 Subject: [PATCH 38/54] feat: show random suffix names in loot roll popup and roll-won messages Apply getRandomPropertyName() to SMSG_LOOT_START_ROLL and SMSG_LOOT_ROLL_WON handlers so items with random suffixes display correctly in group loot contexts (e.g., "Leggings of the Eagle" in the Need/Greed popup and "Player wins Leggings of the Eagle (Need 85)" in chat). --- src/game/game_handler.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ce4098e1..26bef831 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2372,9 +2372,10 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t mapId =*/ packet.readUInt32(); uint32_t slot = packet.readUInt32(); uint32_t itemId = packet.readUInt32(); + int32_t rollRandProp = 0; if (isWotLK) { /*uint32_t randSuffix =*/ packet.readUInt32(); - /*uint32_t randProp =*/ packet.readUInt32(); + rollRandProp = static_cast(packet.readUInt32()); } uint32_t countdown = packet.readUInt32(); uint8_t voteMask = packet.readUInt8(); @@ -2384,11 +2385,14 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; // Ensure item info is queried so the roll popup can show the name/icon. - // The popup re-reads getItemInfo() live, so the name will populate once - // SMSG_ITEM_QUERY_SINGLE_RESPONSE arrives (usually within ~100 ms). queryItemInfo(itemId, 0); auto* info = getItemInfo(itemId); - pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); + std::string rollItemName = info ? info->name : std::to_string(itemId); + if (rollRandProp != 0) { + std::string suffix = getRandomPropertyName(rollRandProp); + if (!suffix.empty()) rollItemName += " " + suffix; + } + pendingLootRoll_.itemName = rollItemName; pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000; pendingLootRoll_.voteMask = voteMask; @@ -25854,9 +25858,10 @@ void GameHandler::handleLootRollWon(network::Packet& packet) { /*uint32_t slot =*/ packet.readUInt32(); uint64_t winnerGuid = packet.readUInt64(); uint32_t itemId = packet.readUInt32(); + int32_t wonRandProp = 0; if (isWotLK) { /*uint32_t randSuffix =*/ packet.readUInt32(); - /*uint32_t randProp =*/ packet.readUInt32(); + wonRandProp = static_cast(packet.readUInt32()); } uint8_t rollNum = packet.readUInt8(); uint8_t rollType = packet.readUInt8(); @@ -25875,6 +25880,10 @@ void GameHandler::handleLootRollWon(network::Packet& packet) { auto* info = getItemInfo(itemId); std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId); + if (wonRandProp != 0) { + std::string suffix = getRandomPropertyName(wonRandProp); + if (!suffix.empty()) iName += " " + suffix; + } uint32_t wonItemQuality = info ? info->quality : 1u; std::string wonItemLink = buildItemLink(itemId, wonItemQuality, iName); From a13dfff9a184b7089e32a7086231fa87de8dc519 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 19:33:01 -0700 Subject: [PATCH 39/54] feat: show random suffix names in auction house item listings Append suffix name from getRandomPropertyName() to auction browse results so items display as "Leggings of the Eagle" instead of just "Leggings" in the auction house search table. Uses the randomPropertyId field from the SMSG_AUCTION_LIST_RESULT packet data. --- 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 473e46a0..bfe0c63f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -22305,6 +22305,12 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { const auto& auction = results.auctions[i]; auto* info = gameHandler.getItemInfo(auction.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(auction.itemEntry)); + // Append random suffix name (e.g., "of the Eagle") if present + if (auction.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(auction.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; ImVec4 qc = InventoryScreen::getQualityColor(quality); From bc4ff501e2f2aa6aa06e0126a8df9aae4afeae45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 19:37:17 -0700 Subject: [PATCH 40/54] feat: show random suffix names in AH bids and seller auction tabs Extend random property name resolution to the Bids tab and Your Auctions (seller) tab. All three auction house tabs now display items with their full suffix names (e.g., "Gloves of the Monkey" instead of "Gloves"). --- src/ui/game_screen.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bfe0c63f..18d0a6ec 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -22504,6 +22504,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { const auto& a = results.auctions[bi]; auto* info = gameHandler.getItemInfo(a.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); + if (a.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(a.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; ImVec4 bqc = InventoryScreen::getQualityColor(quality); @@ -22588,6 +22593,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { const auto& a = results.auctions[i]; auto* info = gameHandler.getItemInfo(a.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); + if (a.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(a.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; ImGui::TableNextRow(); From 6d2a94a84404fa1702cf98595c776b1ed2cf0f0b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 19:57:13 -0700 Subject: [PATCH 41/54] feat: add GetSpellLink Lua API for clickable spell links in chat Returns WoW-format spell link string "|cff71d5ff|Hspell:ID|h[Name]|h|r" for a spell ID or name. Used by damage meters, chat addons, and WeakAuras to create clickable spell references in chat messages. --- src/addons/lua_engine.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 58a7381b..b5d569af 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1222,6 +1222,34 @@ static int lua_UnitCreatureType(lua_State* L) { return 1; } +// GetSpellLink(spellIdOrName) → "|cFFxxxxxx|Hspell:ID|h[Name]|h|r" +static int lua_GetSpellLink(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + if (!name || !*name) { lua_pushnil(L); return 1; } + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + if (spellId == 0) { lua_pushnil(L); return 1; } + std::string name = gh->getSpellName(spellId); + if (name.empty()) { lua_pushnil(L); return 1; } + char link[256]; + snprintf(link, sizeof(link), "|cff71d5ff|Hspell:%u|h[%s]|h|r", spellId, name.c_str()); + lua_pushstring(L, link); + return 1; +} + // IsUsableSpell(spellIdOrName) → usable, noMana static int lua_IsUsableSpell(lua_State* L) { auto* gh = getGameHandler(L); @@ -1685,6 +1713,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, {"UnitClassification", lua_UnitClassification}, + {"GetSpellLink", lua_GetSpellLink}, {"IsUsableSpell", lua_IsUsableSpell}, {"IsInInstance", lua_IsInInstance}, {"GetInstanceInfo", lua_GetInstanceInfo}, From 8be8d31b85f52e4dcf2c6d54769b6f22655436a7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 20:07:45 -0700 Subject: [PATCH 42/54] feat: add GetItemLink Lua API for clickable item links from item IDs Returns WoW-format quality-colored item link for any item ID from the item info cache. Used by loot addons, tooltip addons, and chat formatting to create clickable item references. --- src/addons/lua_engine.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index b5d569af..37c15935 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1222,6 +1222,23 @@ static int lua_UnitCreatureType(lua_State* L) { return 1; } +// GetItemLink(itemId) → "|cFFxxxxxx|Hitem:ID:...|h[Name]|h|r" +static int lua_GetItemLink(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + uint32_t itemId = static_cast(luaL_checknumber(L, 1)); + if (itemId == 0) { lua_pushnil(L); return 1; } + const auto* info = gh->getItemInfo(itemId); + if (!info || info->name.empty()) { lua_pushnil(L); return 1; } + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = info->quality < 8 ? info->quality : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], itemId, info->name.c_str()); + lua_pushstring(L, link); + return 1; +} + // GetSpellLink(spellIdOrName) → "|cFFxxxxxx|Hspell:ID|h[Name]|h|r" static int lua_GetSpellLink(lua_State* L) { auto* gh = getGameHandler(L); @@ -1713,6 +1730,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, {"UnitClassification", lua_UnitClassification}, + {"GetItemLink", lua_GetItemLink}, {"GetSpellLink", lua_GetSpellLink}, {"IsUsableSpell", lua_IsUsableSpell}, {"IsInInstance", lua_IsInInstance}, From b659ab9caf95ceee4f412228908c2767546e3416 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 20:22:15 -0700 Subject: [PATCH 43/54] feat: add GetPlayerInfoByGUID Lua API for damage meter player identification Returns (class, englishClass, race, englishRace, sex, name, realm) for a GUID string. Resolves player name from entity cache. Returns class/race info for the local player. Used by Details!, Recount, and Skada to identify players in COMBAT_LOG_EVENT_UNFILTERED data. --- src/addons/lua_engine.cpp | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 37c15935..9ee23afb 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1222,6 +1222,55 @@ static int lua_UnitCreatureType(lua_State* L) { return 1; } +// GetPlayerInfoByGUID(guid) → localizedClass, englishClass, localizedRace, englishRace, sex, name, realm +static int lua_GetPlayerInfoByGUID(lua_State* L) { + auto* gh = getGameHandler(L); + const char* guidStr = luaL_checkstring(L, 1); + if (!gh || !guidStr) { + for (int i = 0; i < 7; i++) lua_pushnil(L); + return 7; + } + // Parse hex GUID string "0x0000000000000001" + uint64_t guid = 0; + if (guidStr[0] == '0' && (guidStr[1] == 'x' || guidStr[1] == 'X')) + guid = strtoull(guidStr + 2, nullptr, 16); + else + guid = strtoull(guidStr, nullptr, 16); + + if (guid == 0) { for (int i = 0; i < 7; i++) lua_pushnil(L); return 7; } + + // Look up entity name + std::string name = gh->lookupName(guid); + if (name.empty() && guid == gh->getPlayerGuid()) { + const auto& chars = gh->getCharacters(); + for (const auto& c : chars) + if (c.guid == guid) { name = c.name; break; } + } + + // For player GUID, return class/race if it's the local player + const char* className = "Unknown"; + const char* raceName = "Unknown"; + if (guid == gh->getPlayerGuid()) { + static const char* kClasses[] = {"","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + static const char* kRaces[] = {"","Human","Orc","Dwarf","Night Elf","Undead", + "Tauren","Gnome","Troll","","Blood Elf","Draenei"}; + uint8_t cid = gh->getPlayerClass(); + uint8_t rid = gh->getPlayerRace(); + if (cid < 12) className = kClasses[cid]; + if (rid < 12) raceName = kRaces[rid]; + } + + lua_pushstring(L, className); // 1: localizedClass + lua_pushstring(L, className); // 2: englishClass + lua_pushstring(L, raceName); // 3: localizedRace + lua_pushstring(L, raceName); // 4: englishRace + lua_pushnumber(L, 0); // 5: sex (0=unknown) + lua_pushstring(L, name.c_str()); // 6: name + lua_pushstring(L, ""); // 7: realm + return 7; +} + // GetItemLink(itemId) → "|cFFxxxxxx|Hitem:ID:...|h[Name]|h|r" static int lua_GetItemLink(lua_State* L) { auto* gh = getGameHandler(L); @@ -1730,6 +1779,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsEnemy", lua_UnitIsEnemy}, {"UnitCreatureType", lua_UnitCreatureType}, {"UnitClassification", lua_UnitClassification}, + {"GetPlayerInfoByGUID", lua_GetPlayerInfoByGUID}, {"GetItemLink", lua_GetItemLink}, {"GetSpellLink", lua_GetSpellLink}, {"IsUsableSpell", lua_IsUsableSpell}, From df55242c50ada4c8aad045dd0b613bccf8395a54 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 20:44:59 -0700 Subject: [PATCH 44/54] feat: add GetCoinTextureString/GetCoinText Lua money formatting utility Formats copper amounts into "Xg Ys Zc" strings for addon display. GetCoinText is aliased to GetCoinTextureString. Used by money display addons (Titan Panel, MoneyFu) and auction/vendor price formatting. --- src/addons/lua_engine.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 9ee23afb..e75d5359 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1988,6 +1988,20 @@ void LuaEngine::registerCoreAPI() { " SHAMAN={r=0.0,g=0.44,b=0.87}, MAGE={r=0.41,g=0.80,b=0.94},\n" " WARLOCK={r=0.58,g=0.51,b=0.79}, DRUID={r=1.0,g=0.49,b=0.04},\n" "}\n" + // Money formatting utility + "function GetCoinTextureString(copper)\n" + " if not copper or copper == 0 then return '0c' end\n" + " copper = math.floor(copper)\n" + " local g = math.floor(copper / 10000)\n" + " local s = math.floor(math.fmod(copper, 10000) / 100)\n" + " local c = math.fmod(copper, 100)\n" + " local r = ''\n" + " if g > 0 then r = r .. g .. 'g ' end\n" + " if s > 0 then r = r .. s .. 's ' end\n" + " if c > 0 or r == '' then r = r .. c .. 'c' end\n" + " return r\n" + "end\n" + "GetCoinText = GetCoinTextureString\n" ); } From 9f49f543f6ae4876983cfbc06ec44e379d70fbf1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 20:57:23 -0700 Subject: [PATCH 45/54] feat: show random suffix names in auction outbid and expired notifications Apply getRandomPropertyName() to SMSG_AUCTION_BIDDER_NOTIFICATION and SMSG_AUCTION_REMOVED_NOTIFICATION so outbid/expired messages show full item names like "You have been outbid on Leggings of the Eagle" instead of just "Leggings". Completes suffix name resolution across all AH contexts. --- src/game/game_handler.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 26bef831..050f9380 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6228,14 +6228,21 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: { - // auctionId(u32) + itemEntry(u32) + ... + // auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32) if (packet.getSize() - packet.getReadPos() >= 8) { - uint32_t auctionId = packet.readUInt32(); + /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - (void)auctionId; + int32_t bidRandProp = 0; + // Try to read randomPropertyId if enough data remains + if (packet.getSize() - packet.getReadPos() >= 4) + bidRandProp = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string rawName2 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + if (bidRandProp != 0) { + std::string suffix = getRandomPropertyName(bidRandProp); + if (!suffix.empty()) rawName2 += " " + suffix; + } uint32_t bidQuality = info ? info->quality : 1u; std::string bidLink = buildItemLink(itemEntry, bidQuality, rawName2); addSystemChatMessage("You have been outbid on " + bidLink + "."); @@ -6248,10 +6255,14 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 12) { /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - /*uint32_t itemRandom =*/ packet.readUInt32(); + int32_t itemRandom = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string rawName3 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + if (itemRandom != 0) { + std::string suffix = getRandomPropertyName(itemRandom); + if (!suffix.empty()) rawName3 += " " + suffix; + } uint32_t remQuality = info ? info->quality : 1u; std::string remLink = buildItemLink(itemEntry, remQuality, rawName3); addSystemChatMessage("Your auction of " + remLink + " has expired."); From 3dcd489e8102e4c3f429dd28a1b2e46d258a1008 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 21:02:12 -0700 Subject: [PATCH 46/54] feat: show random suffix names in auction owner sold/expired notifications Parse randomPropertyId from SMSG_AUCTION_OWNER_NOTIFICATION to display full item names in sold/bid/expired messages like "Your auction of Gloves of the Monkey has sold!" Completes suffix resolution across all 9 item display contexts. --- src/game/game_handler.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 050f9380..751eb945 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6205,16 +6205,23 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAuctionCommandResult(packet); break; case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: { - // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ... + // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ... // action: 0=sold/won, 1=expired, 2=bid placed on your auction if (packet.getSize() - packet.getReadPos() >= 16) { /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t action = packet.readUInt32(); /*uint32_t error =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); + int32_t ownerRandProp = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + ownerRandProp = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string rawName = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + if (ownerRandProp != 0) { + std::string suffix = getRandomPropertyName(ownerRandProp); + if (!suffix.empty()) rawName += " " + suffix; + } uint32_t aucQuality = info ? info->quality : 1u; std::string itemLink = buildItemLink(itemEntry, aucQuality, rawName); if (action == 1) From 0885f885e859556ab299e3fa10ff34cb3bfb2f6c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 21:18:25 -0700 Subject: [PATCH 47/54] feat: fire PLAYER_FLAGS_CHANGED event when player flags update Fires when AFK/DND status, PvP flag, ghost state, or other player flags change via PLAYER_FLAGS update field. Enables addons that track player status changes (FlagRSP, TRP3, etc.). --- 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 751eb945..df46fc16 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12678,6 +12678,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem if (addonEventCallback_) addonEventCallback_("PLAYER_ALIVE", {}); if (ghostStateCallback_) ghostStateCallback_(false); } + if (addonEventCallback_) + addonEventCallback_("PLAYER_FLAGS_CHANGED", {}); } else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } From 44d2b80998f065a9b00df5c71c5aa096837a58cf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 21:27:04 -0700 Subject: [PATCH 48/54] feat: fire CHAT_MSG_LOOT event when items are looted Fire CHAT_MSG_LOOT addon event from SMSG_ITEM_PUSH_RESULT with the loot message text, item ID, and count. Used by loot tracking addons (AutoLootPlus, Loot Appraiser) and damage meters that track loot distribution. --- src/game/game_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index df46fc16..ad397e24 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2002,6 +2002,9 @@ void GameHandler::handlePacket(network::Packet& packet) { sfx->playLootItem(); } if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName); + // Fire CHAT_MSG_LOOT for loot tracking addons + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_LOOT", {msg, "", std::to_string(itemId), std::to_string(count)}); } else { // Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE. pendingItemPushNotifs_.push_back({itemId, count}); From d68ef2ceb62c9339dcb3e02fbce2f2babdbd1b3c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 21:47:39 -0700 Subject: [PATCH 49/54] feat: fire CHAT_MSG_MONEY and CHAT_MSG_COMBAT_XP_GAIN events Fire CHAT_MSG_MONEY when gold is looted (used by gold tracking addons like MoneyFu, Titan Panel). Fire CHAT_MSG_COMBAT_XP_GAIN when XP is earned (used by XP tracking addons and leveling speed calculators). --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ad397e24..b81d27c3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -22926,6 +22926,8 @@ void GameHandler::handleXpGain(network::Packet& packet) { msg += " (+" + std::to_string(data.groupBonus) + " group bonus)"; } addSystemChatMessage(msg); + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(data.totalXp)}); } @@ -22940,6 +22942,8 @@ void GameHandler::addMoneyCopper(uint32_t amount) { msg += std::to_string(silver) + "s "; msg += std::to_string(copper) + "c."; addSystemChatMessage(msg); + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_MONEY", {msg}); } void GameHandler::addSystemChatMessage(const std::string& message) { From fc182f8653cc3a17b56b94db894fbc63e4b38f4a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 21:57:27 -0700 Subject: [PATCH 50/54] feat: fire SKILL_LINES_CHANGED event when player skills update Detect changes in player skill values after extractSkillFields() and fire SKILL_LINES_CHANGED when any skill value changes. Used by profession tracking addons and skill bar displays. --- src/game/game_handler.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b81d27c3..0c5073fb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -24442,7 +24442,19 @@ void GameHandler::extractSkillFields(const std::map& fields) } } + bool skillsChanged = (newSkills.size() != playerSkills_.size()); + if (!skillsChanged) { + for (const auto& [id, sk] : newSkills) { + auto it = playerSkills_.find(id); + if (it == playerSkills_.end() || it->second.value != sk.value) { + skillsChanged = true; + break; + } + } + } playerSkills_ = std::move(newSkills); + if (skillsChanged && addonEventCallback_) + addonEventCallback_("SKILL_LINES_CHANGED", {}); } void GameHandler::extractExploredZoneFields(const std::map& fields) { From 37a5b4c9d9d40b686815c82e2c487f5759afb4b5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 22:13:57 -0700 Subject: [PATCH 51/54] feat: fire ACTIONBAR_SLOT_CHANGED event on action bar updates Fire when SMSG_ACTION_BUTTONS populates the action bar on login and when SMSG_SUPERCEDED_SPELL upgrades spell ranks on the bar. Used by action bar addons (Bartender, Dominos) to refresh their displays. --- 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 0c5073fb..dafb4316 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4592,6 +4592,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } } LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); + if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); packet.setReadPos(packet.getSize()); break; } @@ -19575,7 +19576,10 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId); } } - if (barChanged) saveCharacterConfig(); + if (barChanged) { + saveCharacterConfig(); + if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); + } // Show "Upgraded to X" only when the new spell wasn't already announced by the // trainer-buy handler. For non-trainer supersedes (e.g. quest rewards), the new From 8cc90a69e865fb259ff9a5ac050da9120626f318 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 22:22:36 -0700 Subject: [PATCH 52/54] feat: fire MERCHANT_SHOW and MERCHANT_CLOSED events for vendor addons Fire MERCHANT_SHOW when vendor window opens (SMSG_LIST_INVENTORY) and MERCHANT_CLOSED when vendor is closed. Used by vendor price addons and auto-sell addons that need to detect vendor interaction state. --- src/game/game_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index dafb4316..8ab02830 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21643,6 +21643,7 @@ void GameHandler::openVendor(uint64_t npcGuid) { } void GameHandler::closeVendor() { + bool wasOpen = vendorWindowOpen; vendorWindowOpen = false; currentVendorItems = ListInventoryData{}; buybackItems_.clear(); @@ -21651,6 +21652,7 @@ void GameHandler::closeVendor() { pendingBuybackWireSlot_ = 0; pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; + if (wasOpen && addonEventCallback_) addonEventCallback_("MERCHANT_CLOSED", {}); } void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { @@ -22372,6 +22374,7 @@ void GameHandler::handleListInventory(network::Packet& packet) { currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens + if (addonEventCallback_) addonEventCallback_("MERCHANT_SHOW", {}); // Auto-sell grey items if enabled if (autoSellGrey_ && currentVendorItems.vendorGuid != 0) { From 395d6cdcbaab29a2e9f3fb2e9643558cebd5329a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 22:32:21 -0700 Subject: [PATCH 53/54] feat: fire BANKFRAME_OPENED and BANKFRAME_CLOSED events for bank addons Fire BANKFRAME_OPENED when bank window opens and BANKFRAME_CLOSED when it closes. Used by bank management addons (Bagnon, BankItems) to detect when the player is interacting with their bank. --- src/game/game_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8ab02830..37c004e6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -25132,8 +25132,10 @@ void GameHandler::openBank(uint64_t guid) { } void GameHandler::closeBank() { + bool wasOpen = bankOpen_; bankOpen_ = false; bankerGuid_ = 0; + if (wasOpen && addonEventCallback_) addonEventCallback_("BANKFRAME_CLOSED", {}); } void GameHandler::buyBankSlot() { @@ -25164,6 +25166,7 @@ void GameHandler::handleShowBank(network::Packet& packet) { bankerGuid_ = packet.readUInt64(); bankOpen_ = true; gossipWindowOpen = false; // Close gossip when bank opens + if (addonEventCallback_) addonEventCallback_("BANKFRAME_OPENED", {}); // Bank items are already tracked via update fields (bank slot GUIDs) // Trigger rebuild to populate bank slots in inventory rebuildOnlineInventory(); From 22798d1c76f881dac6d172fa2e3af8fd10ffe7a4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Mar 2026 22:43:29 -0700 Subject: [PATCH 54/54] feat: fire MAIL_SHOW/CLOSED and AUCTION_HOUSE_SHOW/CLOSED events Fire MAIL_SHOW when mailbox opens (SMSG_SHOW_MAILBOX) and MAIL_CLOSED when it closes. Fire AUCTION_HOUSE_SHOW when AH opens and AUCTION_HOUSE_CLOSED when it closes. Used by mail addons (Postal) and AH addons (Auctionator). --- src/game/game_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 37c004e6..47f0756f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -24793,11 +24793,13 @@ void GameHandler::updateAttachedTransportChildren(float /*deltaTime*/) { // ============================================================ void GameHandler::closeMailbox() { + bool wasOpen = mailboxOpen_; mailboxOpen_ = false; mailboxGuid_ = 0; mailInbox_.clear(); selectedMailIndex_ = -1; showMailCompose_ = false; + if (wasOpen && addonEventCallback_) addonEventCallback_("MAIL_CLOSED", {}); } void GameHandler::refreshMailList() { @@ -24974,6 +24976,7 @@ void GameHandler::handleShowMailbox(network::Packet& packet) { hasNewMail_ = false; selectedMailIndex_ = -1; showMailCompose_ = false; + if (addonEventCallback_) addonEventCallback_("MAIL_SHOW", {}); // Request inbox contents refreshMailList(); } @@ -25285,8 +25288,10 @@ void GameHandler::openAuctionHouse(uint64_t guid) { } void GameHandler::closeAuctionHouse() { + bool wasOpen = auctionOpen_; auctionOpen_ = false; auctioneerGuid_ = 0; + if (wasOpen && addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_CLOSED", {}); } void GameHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8_t levelMax, @@ -25367,6 +25372,7 @@ void GameHandler::handleAuctionHello(network::Packet& packet) { auctionHouseId_ = data.auctionHouseId; auctionOpen_ = true; gossipWindowOpen = false; // Close gossip when auction house opens + if (addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_SHOW", {}); auctionActiveTab_ = 0; auctionBrowseResults_ = AuctionListResult{}; auctionOwnerResults_ = AuctionListResult{};