From 32a51aa93d3c8b4ddc4a6e74f4d1244a9825ac2e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:26:37 -0700 Subject: [PATCH 01/74] feat: add mouseover unit ID support and fire UPDATE_MOUSEOVER_UNIT/PLAYER_FOCUS_CHANGED events Add "mouseover" as a valid unit ID in resolveUnitGuid so Lua API functions like UnitName("mouseover"), UnitHealth("mouseover") etc. work for addons. Fire UPDATE_MOUSEOVER_UNIT event when the mouseover target changes, and PLAYER_FOCUS_CHANGED event when focus is set or cleared. --- include/game/game_handler.hpp | 2 +- src/addons/lua_engine.cpp | 3 ++- src/game/game_handler.cpp | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7c4e0918..97dd3103 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -403,7 +403,7 @@ public: bool hasFocus() const { return focusGuid != 0; } // Mouseover targeting — set each frame by the nameplate renderer - void setMouseoverGuid(uint64_t guid) { mouseoverGuid_ = guid; } + void setMouseoverGuid(uint64_t guid); uint64_t getMouseoverGuid() const { return mouseoverGuid_; } // Advanced targeting diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index e75d5359..546ffcc6 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -64,6 +64,7 @@ static uint64_t resolveUnitGuid(game::GameHandler* gh, const std::string& uid) { if (uid == "player") return gh->getPlayerGuid(); if (uid == "target") return gh->getTargetGuid(); if (uid == "focus") return gh->getFocusGuid(); + if (uid == "mouseover") return gh->getMouseoverGuid(); if (uid == "pet") return gh->getPetGuid(); // party1-party4, raid1-raid40 if (uid.rfind("party", 0) == 0 && uid.size() > 5) { @@ -91,7 +92,7 @@ static uint64_t resolveUnitGuid(game::GameHandler* gh, const std::string& uid) { return 0; } -// Helper: resolve "player", "target", "focus", "pet", "partyN", "raidN" unit IDs to entity +// Helper: resolve "player", "target", "focus", "mouseover", "pet", "partyN", "raidN" unit IDs to entity static game::Unit* resolveUnit(lua_State* L, const char* unitId) { auto* gh = getGameHandler(L); if (!gh || !unitId) return nullptr; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 47f0756f..c4b2030b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13534,6 +13534,7 @@ std::shared_ptr GameHandler::getTarget() const { void GameHandler::setFocus(uint64_t guid) { focusGuid = guid; + if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {}); if (guid != 0) { auto entity = entityManager.getEntity(guid); if (entity) { @@ -13559,6 +13560,14 @@ void GameHandler::clearFocus() { LOG_INFO("Focus cleared"); } focusGuid = 0; + if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {}); +} + +void GameHandler::setMouseoverGuid(uint64_t guid) { + if (mouseoverGuid_ != guid) { + mouseoverGuid_ = guid; + if (addonEventCallback_) addonEventCallback_("UPDATE_MOUSEOVER_UNIT", {}); + } } std::shared_ptr GameHandler::getFocus() const { From fe8950bd4b510d3a634346a3404cc09aa2b6cfb1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:31:34 -0700 Subject: [PATCH 02/74] feat: add action bar, combo points, reaction, and connection Lua API functions Implement 10 new WoW Lua API functions for addon compatibility: - GetComboPoints, UnitReaction, UnitIsConnected for unit frames/raid addons - HasAction, GetActionTexture, IsCurrentAction, IsUsableAction, GetActionCooldown for action bar addons (Bartender, Dominos, etc.) - UnitMana/UnitManaMax as Classic-era aliases for UnitPower/UnitPowerMax --- src/addons/lua_engine.cpp | 171 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 546ffcc6..80c26e8e 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1415,6 +1415,165 @@ static int lua_UnitClassification(lua_State* L) { return 1; } +// GetComboPoints("player"|"vehicle", "target") → number +static int lua_GetComboPoints(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? gh->getComboPoints() : 0); + return 1; +} + +// UnitReaction(unit, otherUnit) → 1-8 (hostile to exalted) +// Simplified: hostile=2, neutral=4, friendly=5 +static int lua_UnitReaction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* uid1 = luaL_checkstring(L, 1); + const char* uid2 = luaL_checkstring(L, 2); + auto* unit2 = resolveUnit(L, uid2); + if (!unit2) { lua_pushnil(L); return 1; } + // If unit2 is the player, always friendly to self + std::string u1(uid1); + for (char& c : u1) c = static_cast(std::tolower(static_cast(c))); + std::string u2(uid2); + for (char& c : u2) c = static_cast(std::tolower(static_cast(c))); + uint64_t g1 = resolveUnitGuid(gh, u1); + uint64_t g2 = resolveUnitGuid(gh, u2); + if (g1 == g2) { lua_pushnumber(L, 5); return 1; } // same unit = friendly + if (unit2->isHostile()) { + lua_pushnumber(L, 2); // hostile + } else { + lua_pushnumber(L, 5); // friendly + } + return 1; +} + +// UnitIsConnected(unit) → boolean +static int lua_UnitIsConnected(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid = luaL_optstring(L, 1, "player"); + 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_pushboolean(L, 0); return 1; } + // Player is always connected + if (guid == gh->getPlayerGuid()) { lua_pushboolean(L, 1); return 1; } + // Check party/raid member online status + const auto& pd = gh->getPartyData(); + for (const auto& m : pd.members) { + if (m.guid == guid) { + lua_pushboolean(L, m.isOnline ? 1 : 0); + return 1; + } + } + // Non-party entities that exist are considered connected + auto entity = gh->getEntityManager().getEntity(guid); + lua_pushboolean(L, entity ? 1 : 0); + return 1; +} + +// HasAction(slot) → boolean (1-indexed slot) +static int lua_HasAction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; // WoW uses 1-indexed slots + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size())) { + lua_pushboolean(L, 0); + return 1; + } + lua_pushboolean(L, !bar[slot].isEmpty()); + return 1; +} + +// GetActionTexture(slot) → texturePath or nil +static int lua_GetActionTexture(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushnil(L); + return 1; + } + const auto& action = bar[slot]; + if (action.type == game::ActionBarSlot::SPELL) { + std::string icon = gh->getSpellIconPath(action.id); + if (!icon.empty()) { + lua_pushstring(L, icon.c_str()); + return 1; + } + } + // For items we don't have icon resolution yet (needs ItemDisplayInfo DBC) + lua_pushnil(L); + return 1; +} + +// IsCurrentAction(slot) → boolean +static int lua_IsCurrentAction(lua_State* L) { + // Currently no "active action" tracking; return false + (void)L; + lua_pushboolean(L, 0); + return 1; +} + +// IsUsableAction(slot) → usable, notEnoughMana +static int lua_IsUsableAction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); lua_pushboolean(L, 0); return 2; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushboolean(L, 0); + lua_pushboolean(L, 0); + return 2; + } + const auto& action = bar[slot]; + bool usable = action.isReady(); + if (action.type == game::ActionBarSlot::SPELL) { + usable = usable && gh->getKnownSpells().count(action.id); + } + lua_pushboolean(L, usable ? 1 : 0); + lua_pushboolean(L, 0); // notEnoughMana (can't determine without cost data) + return 2; +} + +// GetActionCooldown(slot) → start, duration, enable +static int lua_GetActionCooldown(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 1); return 3; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushnumber(L, 0); + lua_pushnumber(L, 0); + lua_pushnumber(L, 1); + return 3; + } + const auto& action = bar[slot]; + if (action.cooldownRemaining > 0.0f) { + // WoW returns GetTime()-based start time; approximate + double now = 0; + lua_getglobal(L, "GetTime"); + if (lua_isfunction(L, -1)) { + lua_call(L, 0, 1); + now = lua_tonumber(L, -1); + lua_pop(L, 1); + } else { + lua_pop(L, 1); + } + double start = now - (action.cooldownTotal - action.cooldownRemaining); + lua_pushnumber(L, start); + lua_pushnumber(L, action.cooldownTotal); + lua_pushnumber(L, 1); + } else { + lua_pushnumber(L, 0); + lua_pushnumber(L, 0); + lua_pushnumber(L, 1); + } + return 3; +} + // --- Frame System --- // Minimal WoW-compatible frame objects with RegisterEvent/SetScript/GetScript. // Frames are Lua tables with a metatable that provides methods. @@ -1727,6 +1886,8 @@ void LuaEngine::registerCoreAPI() { {"UnitHealthMax", lua_UnitHealthMax}, {"UnitPower", lua_UnitPower}, {"UnitPowerMax", lua_UnitPowerMax}, + {"UnitMana", lua_UnitPower}, + {"UnitManaMax", lua_UnitPowerMax}, {"UnitLevel", lua_UnitLevel}, {"UnitExists", lua_UnitExists}, {"UnitIsDead", lua_UnitIsDead}, @@ -1808,6 +1969,16 @@ void LuaEngine::registerCoreAPI() { {"GetQuestLogTitle", lua_GetQuestLogTitle}, {"GetQuestLogQuestText", lua_GetQuestLogQuestText}, {"IsQuestComplete", lua_IsQuestComplete}, + // Reaction/connection queries + {"UnitReaction", lua_UnitReaction}, + {"UnitIsConnected", lua_UnitIsConnected}, + {"GetComboPoints", lua_GetComboPoints}, + // Action bar API + {"HasAction", lua_HasAction}, + {"GetActionTexture", lua_GetActionTexture}, + {"IsCurrentAction", lua_IsCurrentAction}, + {"IsUsableAction", lua_IsUsableAction}, + {"GetActionCooldown", lua_GetActionCooldown}, // Utilities {"strsplit", lua_strsplit}, {"strtrim", lua_strtrim}, From 74125b73400c8054ddddbdb108b91f868f365c86 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:35:18 -0700 Subject: [PATCH 03/74] feat: fire LOOT/GOSSIP/QUEST/TRAINER addon events on window open/close Fire the following events for addon compatibility: - LOOT_OPENED, LOOT_CLOSED on loot window open/close - GOSSIP_SHOW, GOSSIP_CLOSED on gossip/quest-list window open/close - QUEST_DETAIL when quest details are shown to the player - QUEST_COMPLETE when quest offer reward dialog opens - TRAINER_SHOW, TRAINER_CLOSED on trainer window open/close --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c4b2030b..01abd3dc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21110,6 +21110,7 @@ void GameHandler::handleQuestDetails(network::Packet& packet) { // Delay opening the window slightly to allow item queries to complete questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("QUEST_DETAIL", {}); } bool GameHandler::hasQuestInLog(uint32_t questId) const { @@ -21555,6 +21556,7 @@ void GameHandler::handleQuestOfferReward(network::Packet& packet) { gossipWindowOpen = false; questDetailsOpen = false; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; + if (addonEventCallback_) addonEventCallback_("QUEST_COMPLETE", {}); // Query item names for reward items for (const auto& item : data.choiceRewards) @@ -21613,6 +21615,7 @@ void GameHandler::closeQuestOfferReward() { void GameHandler::closeGossip() { gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {}); currentGossip = GossipMessageData{}; } @@ -22121,6 +22124,7 @@ void GameHandler::handleLootResponse(network::Packet& packet) { return; } lootWindowOpen = true; + if (addonEventCallback_) addonEventCallback_("LOOT_OPENED", {}); lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo pendingGameObjectLootOpens_.erase( std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), @@ -22165,6 +22169,7 @@ void GameHandler::handleLootReleaseResponse(network::Packet& packet) { (void)packet; localLootState_.erase(currentLoot.lootGuid); lootWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {}); currentLoot = LootResponseData{}; } @@ -22198,6 +22203,7 @@ void GameHandler::handleGossipMessage(network::Packet& packet) { if (!ok) return; if (questDetailsOpen) return; // Don't reopen gossip while viewing quest gossipWindowOpen = true; + if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {}); vendorWindowOpen = false; // Close vendor if gossip opens // Update known quest-log entries based on gossip quests. @@ -22311,6 +22317,7 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) { currentGossip = std::move(data); gossipWindowOpen = true; + if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {}); vendorWindowOpen = false; bool hasAvailableQuest = false; @@ -22361,6 +22368,7 @@ void GameHandler::handleGossipComplete(network::Packet& packet) { } gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {}); currentGossip = GossipMessageData{}; } @@ -22489,6 +22497,7 @@ void GameHandler::handleTrainerList(network::Packet& packet) { if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return; trainerWindowOpen_ = true; gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("TRAINER_SHOW", {}); LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells"); LOG_DEBUG("Known spells count: ", knownSpells.size()); @@ -22546,6 +22555,7 @@ void GameHandler::trainSpell(uint32_t spellId) { void GameHandler::closeTrainer() { trainerWindowOpen_ = false; + if (addonEventCallback_) addonEventCallback_("TRAINER_CLOSED", {}); currentTrainerList_ = TrainerListData{}; trainerTabs_.clear(); } From 7459f2777175279551b4c341c9dece5e0e879443 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:37:44 -0700 Subject: [PATCH 04/74] feat: add targettarget, focustarget, pettarget, mouseovertarget unit IDs Support compound unit IDs that resolve an entity's current target via UNIT_FIELD_TARGET_LO/HI update fields. This enables addons to query target-of-target info (e.g., UnitName("targettarget"), UnitHealth("focustarget")) which is essential for threat meters and unit frame addons. --- src/addons/lua_engine.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 80c26e8e..0d1c8d54 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2,6 +2,7 @@ #include "addons/toc_parser.hpp" #include "game/game_handler.hpp" #include "game/entity.hpp" +#include "game/update_field_table.hpp" #include "core/logger.hpp" #include #include @@ -60,12 +61,34 @@ static int lua_wow_message(lua_State* L) { } // Helper: resolve WoW unit IDs to GUID +// Read UNIT_FIELD_TARGET_LO/HI from an entity's update fields to get what it's targeting +static uint64_t getEntityTargetGuid(game::GameHandler* gh, uint64_t guid) { + if (guid == 0) return 0; + // If asking for the player's target, use direct accessor + if (guid == gh->getPlayerGuid()) return gh->getTargetGuid(); + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity) return 0; + const auto& fields = entity->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt == fields.end()) return 0; + uint64_t targetGuid = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + targetGuid |= (static_cast(hiIt->second) << 32); + return targetGuid; +} + static uint64_t resolveUnitGuid(game::GameHandler* gh, const std::string& uid) { if (uid == "player") return gh->getPlayerGuid(); if (uid == "target") return gh->getTargetGuid(); if (uid == "focus") return gh->getFocusGuid(); if (uid == "mouseover") return gh->getMouseoverGuid(); if (uid == "pet") return gh->getPetGuid(); + // Compound unit IDs: targettarget, focustarget, pettarget, mouseovertarget + if (uid == "targettarget") return getEntityTargetGuid(gh, gh->getTargetGuid()); + if (uid == "focustarget") return getEntityTargetGuid(gh, gh->getFocusGuid()); + if (uid == "pettarget") return getEntityTargetGuid(gh, gh->getPetGuid()); + if (uid == "mouseovertarget") return getEntityTargetGuid(gh, gh->getMouseoverGuid()); // party1-party4, raid1-raid40 if (uid.rfind("party", 0) == 0 && uid.size() > 5) { int idx = 0; @@ -92,7 +115,7 @@ static uint64_t resolveUnitGuid(game::GameHandler* gh, const std::string& uid) { return 0; } -// Helper: resolve "player", "target", "focus", "mouseover", "pet", "partyN", "raidN" unit IDs to entity +// Helper: resolve unit IDs (player, target, focus, mouseover, pet, targettarget, focustarget, etc.) to entity static game::Unit* resolveUnit(lua_State* L, const char* unitId) { auto* gh = getGameHandler(L); if (!gh || !unitId) return nullptr; From 8555c80aa2043b552760438b0027cd9a3ea1952d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:42:03 -0700 Subject: [PATCH 05/74] feat: add loot window Lua API for addon compatibility Implement 6 loot-related WoW Lua API functions: - GetNumLootItems() returns count of items in loot window - GetLootSlotInfo(slot) returns texture, name, quantity, quality, locked - GetLootSlotLink(slot) returns item link string - LootSlot(slot) takes an item from loot - CloseLoot() closes the loot window - GetLootMethod() returns current group loot method These pair with the LOOT_OPENED/LOOT_CLOSED events to enable loot addons (AutoLoot, loot filters, master loot helpers). --- src/addons/lua_engine.cpp | 108 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 0d1c8d54..3bf2d70c 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1113,6 +1113,107 @@ static int lua_IsQuestComplete(lua_State* L) { return 1; } +// --- Loot API --- + +// GetNumLootItems() → count +static int lua_GetNumLootItems(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh || !gh->isLootWindowOpen()) { lua_pushnumber(L, 0); return 1; } + lua_pushnumber(L, gh->getCurrentLoot().items.size()); + return 1; +} + +// GetLootSlotInfo(slot) → texture, name, quantity, quality, locked +static int lua_GetLootSlotInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); // 1-indexed + if (!gh || !gh->isLootWindowOpen()) { + lua_pushnil(L); return 1; + } + const auto& loot = gh->getCurrentLoot(); + if (slot < 1 || slot > static_cast(loot.items.size())) { + lua_pushnil(L); return 1; + } + const auto& item = loot.items[slot - 1]; + const auto* info = gh->getItemInfo(item.itemId); + + // texture (icon path — nil if not available) + std::string icon; + if (info) { + // Try spell icon resolver as fallback for item icon + icon = gh->getSpellIconPath(item.itemId); + } + if (!icon.empty()) lua_pushstring(L, icon.c_str()); + else lua_pushnil(L); + + // name + if (info && !info->name.empty()) lua_pushstring(L, info->name.c_str()); + else lua_pushstring(L, ("Item #" + std::to_string(item.itemId)).c_str()); + + lua_pushnumber(L, item.count); // quantity + lua_pushnumber(L, info ? info->quality : 1); // quality + lua_pushboolean(L, 0); // locked (not tracked) + return 5; +} + +// GetLootSlotLink(slot) → itemLink +static int lua_GetLootSlotLink(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); + if (!gh || !gh->isLootWindowOpen()) { lua_pushnil(L); return 1; } + const auto& loot = gh->getCurrentLoot(); + if (slot < 1 || slot > static_cast(loot.items.size())) { + lua_pushnil(L); return 1; + } + const auto& item = loot.items[slot - 1]; + const auto* info = gh->getItemInfo(item.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], item.itemId, info->name.c_str()); + lua_pushstring(L, link); + return 1; +} + +// LootSlot(slot) — take item from loot +static int lua_LootSlot(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); + if (!gh || !gh->isLootWindowOpen()) return 0; + const auto& loot = gh->getCurrentLoot(); + if (slot < 1 || slot > static_cast(loot.items.size())) return 0; + gh->lootItem(loot.items[slot - 1].slotIndex); + return 0; +} + +// CloseLoot() — close loot window +static int lua_CloseLoot(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->closeLoot(); + return 0; +} + +// GetLootMethod() → "freeforall"|"roundrobin"|"master"|"group"|"needbeforegreed", partyLoot, raidLoot +static int lua_GetLootMethod(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "freeforall"); lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 3; } + const auto& pd = gh->getPartyData(); + const char* method = "freeforall"; + switch (pd.lootMethod) { + case 0: method = "freeforall"; break; + case 1: method = "roundrobin"; break; + case 2: method = "master"; break; + case 3: method = "group"; break; + case 4: method = "needbeforegreed"; break; + } + lua_pushstring(L, method); + lua_pushnumber(L, 0); // partyLootMaster (index) + lua_pushnumber(L, 0); // raidLootMaster (index) + return 3; +} + // --- Additional WoW API --- static int lua_UnitAffectingCombat(lua_State* L) { @@ -2002,6 +2103,13 @@ void LuaEngine::registerCoreAPI() { {"IsCurrentAction", lua_IsCurrentAction}, {"IsUsableAction", lua_IsUsableAction}, {"GetActionCooldown", lua_GetActionCooldown}, + // Loot API + {"GetNumLootItems", lua_GetNumLootItems}, + {"GetLootSlotInfo", lua_GetLootSlotInfo}, + {"GetLootSlotLink", lua_GetLootSlotLink}, + {"LootSlot", lua_LootSlot}, + {"CloseLoot", lua_CloseLoot}, + {"GetLootMethod", lua_GetLootMethod}, // Utilities {"strsplit", lua_strsplit}, {"strtrim", lua_strtrim}, From ce26284b904923497be85d23d93b2d25f8ebf428 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:44:59 -0700 Subject: [PATCH 06/74] feat: add GetCursorPosition, screen size queries, and frame positioning methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add global Lua API functions: - GetCursorPosition() returns mouse x,y screen coordinates - GetScreenWidth()/GetScreenHeight() return window dimensions Add frame methods for UI layout: - SetPoint, SetSize, SetWidth, SetHeight, GetWidth, GetHeight, GetCenter - SetAlpha, GetAlpha, SetParent, GetParent These enable UI customization addons to query cursor position, screen dimensions, and manage frame layout — fundamental for unit frames, action bars, and tooltip addons. --- src/addons/lua_engine.cpp | 142 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 3bf2d70c..077fd1ed 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -4,6 +4,8 @@ #include "game/entity.hpp" #include "game/update_field_table.hpp" #include "core/logger.hpp" +#include "core/application.hpp" +#include #include #include #include @@ -1828,6 +1830,127 @@ static int lua_Frame_IsShown(lua_State* L) { return 1; } +// GetCursorPosition() → x, y (screen coordinates, origin top-left) +static int lua_GetCursorPosition(lua_State* L) { + const auto& io = ImGui::GetIO(); + lua_pushnumber(L, io.MousePos.x); + lua_pushnumber(L, io.MousePos.y); + return 2; +} + +// GetScreenWidth() → width +static int lua_GetScreenWidth(lua_State* L) { + auto* window = core::Application::getInstance().getWindow(); + lua_pushnumber(L, window ? window->getWidth() : 1920); + return 1; +} + +// GetScreenHeight() → height +static int lua_GetScreenHeight(lua_State* L) { + auto* window = core::Application::getInstance().getWindow(); + lua_pushnumber(L, window ? window->getHeight() : 1080); + return 1; +} + +// Frame methods: SetPoint, SetSize, SetWidth, SetHeight, GetWidth, GetHeight, GetCenter, SetAlpha, GetAlpha +static int lua_Frame_SetPoint(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + const char* point = luaL_optstring(L, 2, "CENTER"); + // Store point info in frame table + lua_pushstring(L, point); + lua_setfield(L, 1, "__point"); + // Optional x/y offsets (args 4,5 if relativeTo is given, or 3,4 if not) + double xOfs = 0, yOfs = 0; + if (lua_isnumber(L, 4)) { xOfs = lua_tonumber(L, 4); yOfs = lua_tonumber(L, 5); } + else if (lua_isnumber(L, 3)) { xOfs = lua_tonumber(L, 3); yOfs = lua_tonumber(L, 4); } + lua_pushnumber(L, xOfs); + lua_setfield(L, 1, "__xOfs"); + lua_pushnumber(L, yOfs); + lua_setfield(L, 1, "__yOfs"); + return 0; +} + +static int lua_Frame_SetSize(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + double w = luaL_optnumber(L, 2, 0); + double h = luaL_optnumber(L, 3, 0); + lua_pushnumber(L, w); + lua_setfield(L, 1, "__width"); + lua_pushnumber(L, h); + lua_setfield(L, 1, "__height"); + return 0; +} + +static int lua_Frame_SetWidth(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnumber(L, luaL_checknumber(L, 2)); + lua_setfield(L, 1, "__width"); + return 0; +} + +static int lua_Frame_SetHeight(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnumber(L, luaL_checknumber(L, 2)); + lua_setfield(L, 1, "__height"); + return 0; +} + +static int lua_Frame_GetWidth(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__width"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); lua_pushnumber(L, 0); } + return 1; +} + +static int lua_Frame_GetHeight(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__height"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); lua_pushnumber(L, 0); } + return 1; +} + +static int lua_Frame_GetCenter(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__xOfs"); + double x = lua_isnumber(L, -1) ? lua_tonumber(L, -1) : 0; + lua_pop(L, 1); + lua_getfield(L, 1, "__yOfs"); + double y = lua_isnumber(L, -1) ? lua_tonumber(L, -1) : 0; + lua_pop(L, 1); + lua_pushnumber(L, x); + lua_pushnumber(L, y); + return 2; +} + +static int lua_Frame_SetAlpha(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnumber(L, luaL_checknumber(L, 2)); + lua_setfield(L, 1, "__alpha"); + return 0; +} + +static int lua_Frame_GetAlpha(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__alpha"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); lua_pushnumber(L, 1.0); } + return 1; +} + +static int lua_Frame_SetParent(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (lua_istable(L, 2) || lua_isnil(L, 2)) { + lua_pushvalue(L, 2); + lua_setfield(L, 1, "__parent"); + } + return 0; +} + +static int lua_Frame_GetParent(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__parent"); + return 1; +} + // CreateFrame(frameType, name, parent, template) static int lua_CreateFrame(lua_State* L) { const char* frameType = luaL_optstring(L, 1, "Frame"); @@ -2155,6 +2278,17 @@ void LuaEngine::registerCoreAPI() { {"Hide", lua_Frame_Hide}, {"IsShown", lua_Frame_IsShown}, {"IsVisible", lua_Frame_IsShown}, // alias + {"SetPoint", lua_Frame_SetPoint}, + {"SetSize", lua_Frame_SetSize}, + {"SetWidth", lua_Frame_SetWidth}, + {"SetHeight", lua_Frame_SetHeight}, + {"GetWidth", lua_Frame_GetWidth}, + {"GetHeight", lua_Frame_GetHeight}, + {"GetCenter", lua_Frame_GetCenter}, + {"SetAlpha", lua_Frame_SetAlpha}, + {"GetAlpha", lua_Frame_GetAlpha}, + {"SetParent", lua_Frame_SetParent}, + {"GetParent", lua_Frame_GetParent}, {nullptr, nullptr} }; for (const luaL_Reg* r = frameMethods; r->name; r++) { @@ -2167,6 +2301,14 @@ void LuaEngine::registerCoreAPI() { lua_pushcfunction(L_, lua_CreateFrame); lua_setglobal(L_, "CreateFrame"); + // Cursor/screen functions + lua_pushcfunction(L_, lua_GetCursorPosition); + lua_setglobal(L_, "GetCursorPosition"); + lua_pushcfunction(L_, lua_GetScreenWidth); + lua_setglobal(L_, "GetScreenWidth"); + lua_pushcfunction(L_, lua_GetScreenHeight); + lua_setglobal(L_, "GetScreenHeight"); + // Frame event dispatch table lua_newtable(L_); lua_setglobal(L_, "__WoweeFrameEvents"); From 00a97aae3fc65cbacfcd99e2d87558cdcb1d0b20 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:52:59 -0700 Subject: [PATCH 07/74] fix: remove Lua stubs overriding C implementations; add GameTooltip and frame factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix GetScreenWidth/GetScreenHeight/GetNumLootItems/GetFramerate being overridden by hardcoded Lua stubs that ran after the C functions were registered. Now the real C implementations correctly take effect. Add GameTooltip global frame with 20+ methods (SetOwner, ClearLines, AddLine, AddDoubleLine, SetText, NumLines, GetText, SetHyperlink, etc.) and ShoppingTooltip1/2 — critical for virtually all WoW addons. Add frame:CreateTexture() and frame:CreateFontString() methods returning stub objects with common API methods, enabling UI creation addons. Add real GetFramerate() returning actual FPS from ImGui. --- src/addons/lua_engine.cpp | 108 +++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 077fd1ed..1b9067c0 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1830,6 +1830,72 @@ static int lua_Frame_IsShown(lua_State* L) { return 1; } +// Frame method: frame:CreateTexture(name, layer) → texture stub +static int lua_Frame_CreateTexture(lua_State* L) { + lua_newtable(L); + // Add noop methods for common texture operations + luaL_dostring(L, + "return function(t) " + "function t:SetTexture() end " + "function t:SetTexCoord() end " + "function t:SetVertexColor() end " + "function t:SetAllPoints() end " + "function t:SetPoint() end " + "function t:SetSize() end " + "function t:SetWidth() end " + "function t:SetHeight() end " + "function t:Show() end " + "function t:Hide() end " + "function t:SetAlpha() end " + "function t:GetTexture() return '' end " + "function t:SetDesaturated() end " + "function t:SetBlendMode() end " + "function t:SetDrawLayer() end " + "end"); + lua_pushvalue(L, -2); // push the table + lua_call(L, 1, 0); // call the function with the table + return 1; +} + +// Frame method: frame:CreateFontString(name, layer, template) → fontstring stub +static int lua_Frame_CreateFontString(lua_State* L) { + lua_newtable(L); + luaL_dostring(L, + "return function(fs) " + "fs._text = '' " + "function fs:SetText(t) self._text = t or '' end " + "function fs:GetText() return self._text end " + "function fs:SetFont() end " + "function fs:SetFontObject() end " + "function fs:SetTextColor() end " + "function fs:SetJustifyH() end " + "function fs:SetJustifyV() end " + "function fs:SetPoint() end " + "function fs:SetAllPoints() end " + "function fs:Show() end " + "function fs:Hide() end " + "function fs:SetAlpha() end " + "function fs:GetStringWidth() return 0 end " + "function fs:GetStringHeight() return 0 end " + "function fs:SetWordWrap() end " + "function fs:SetNonSpaceWrap() end " + "function fs:SetMaxLines() end " + "function fs:SetShadowOffset() end " + "function fs:SetShadowColor() end " + "function fs:SetWidth() end " + "function fs:SetHeight() end " + "end"); + lua_pushvalue(L, -2); + lua_call(L, 1, 0); + return 1; +} + +// GetFramerate() → fps +static int lua_GetFramerate(lua_State* L) { + lua_pushnumber(L, static_cast(ImGui::GetIO().Framerate)); + return 1; +} + // GetCursorPosition() → x, y (screen coordinates, origin top-left) static int lua_GetCursorPosition(lua_State* L) { const auto& io = ImGui::GetIO(); @@ -2289,6 +2355,8 @@ void LuaEngine::registerCoreAPI() { {"GetAlpha", lua_Frame_GetAlpha}, {"SetParent", lua_Frame_SetParent}, {"GetParent", lua_Frame_GetParent}, + {"CreateTexture", lua_Frame_CreateTexture}, + {"CreateFontString", lua_Frame_CreateFontString}, {nullptr, nullptr} }; for (const luaL_Reg* r = frameMethods; r->name; r++) { @@ -2301,13 +2369,15 @@ void LuaEngine::registerCoreAPI() { lua_pushcfunction(L_, lua_CreateFrame); lua_setglobal(L_, "CreateFrame"); - // Cursor/screen functions + // Cursor/screen/FPS functions lua_pushcfunction(L_, lua_GetCursorPosition); lua_setglobal(L_, "GetCursorPosition"); lua_pushcfunction(L_, lua_GetScreenWidth); lua_setglobal(L_, "GetScreenWidth"); lua_pushcfunction(L_, lua_GetScreenHeight); lua_setglobal(L_, "GetScreenHeight"); + lua_pushcfunction(L_, lua_GetFramerate); + lua_setglobal(L_, "GetFramerate"); // Frame event dispatch table lua_newtable(L_); @@ -2402,6 +2472,33 @@ void LuaEngine::registerCoreAPI() { "function UIParent_OnEvent() end\n" "UIParent = CreateFrame('Frame', 'UIParent')\n" "WorldFrame = CreateFrame('Frame', 'WorldFrame')\n" + // GameTooltip: global tooltip frame used by virtually all addons + "GameTooltip = CreateFrame('Frame', 'GameTooltip')\n" + "GameTooltip.__lines = {}\n" + "function GameTooltip:SetOwner(owner, anchor) self.__owner = owner; self.__anchor = anchor end\n" + "function GameTooltip:ClearLines() self.__lines = {} end\n" + "function GameTooltip:AddLine(text, r, g, b, wrap) table.insert(self.__lines, {text=text or '',r=r,g=g,b=b}) end\n" + "function GameTooltip:AddDoubleLine(l, r, lr, lg, lb, rr, rg, rb) table.insert(self.__lines, {text=(l or '')..' '..(r or '')}) end\n" + "function GameTooltip:SetText(text, r, g, b) self.__lines = {{text=text or '',r=r,g=g,b=b}} end\n" + "function GameTooltip:GetItem() return nil end\n" + "function GameTooltip:GetSpell() return nil end\n" + "function GameTooltip:GetUnit() return nil end\n" + "function GameTooltip:NumLines() return #self.__lines end\n" + "function GameTooltip:GetText() return self.__lines[1] and self.__lines[1].text or '' end\n" + "function GameTooltip:SetUnitBuff(...) end\n" + "function GameTooltip:SetUnitDebuff(...) end\n" + "function GameTooltip:SetHyperlink(...) end\n" + "function GameTooltip:SetInventoryItem(...) end\n" + "function GameTooltip:SetBagItem(...) end\n" + "function GameTooltip:SetSpellByID(...) end\n" + "function GameTooltip:SetAction(...) end\n" + "function GameTooltip:FadeOut() end\n" + "function GameTooltip:SetFrameStrata(...) end\n" + "function GameTooltip:SetClampedToScreen(...) end\n" + "function GameTooltip:IsOwned(f) return self.__owner == f end\n" + // ShoppingTooltip: used by comparison tooltips + "ShoppingTooltip1 = CreateFrame('Frame', 'ShoppingTooltip1')\n" + "ShoppingTooltip2 = CreateFrame('Frame', 'ShoppingTooltip2')\n" // Error handling stubs (used by many addons) "local _errorHandler = function(err) return err end\n" "function geterrorhandler() return _errorHandler end\n" @@ -2416,16 +2513,13 @@ void LuaEngine::registerCoreAPI() { "function GetCVarBool(name) return _cvars[name] == '1' end\n" "function SetCVar(name, value) _cvars[name] = tostring(value) end\n" // Misc compatibility stubs - "function GetScreenWidth() return 1920 end\n" - "function GetScreenHeight() return 1080 end\n" - "function GetFramerate() return 60 end\n" + // GetScreenWidth, GetScreenHeight, GetNumLootItems are now C functions + // GetFramerate is now a C function "function GetNetStats() return 0, 0, 0, 0 end\n" "function IsLoggedIn() return true end\n" - // IsMounted, IsFlying, IsSwimming, IsResting, IsFalling, IsStealthed - // are now C functions registered in registerCoreAPI() with real game state - "function GetNumLootItems() return 0 end\n" "function StaticPopup_Show() end\n" "function StaticPopup_Hide() end\n" + // CreateTexture/CreateFontString are now C frame methods in the metatable "RAID_CLASS_COLORS = {\n" " WARRIOR={r=0.78,g=0.61,b=0.43}, PALADIN={r=0.96,g=0.55,b=0.73},\n" " HUNTER={r=0.67,g=0.83,b=0.45}, ROGUE={r=1.0,g=0.96,b=0.41},\n" From 3ae18f03a159223fb8f18226eae27a9130774f9e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:55:30 -0700 Subject: [PATCH 08/74] feat: fire UNIT_HEALTH/POWER/AURA events for party members; fix closeLoot event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire UNIT_HEALTH, UNIT_POWER, and UNIT_AURA events from SMSG_PARTY_MEMBER_STATS with proper unit IDs (party1..4, raid1..40). Previously, health/power changes for party members via the stats packet were silent — raid frame addons never got notified. Also fix closeLoot() not firing LOOT_CLOSED event when the loot window is closed by the player (only handleLootReleaseResponse fired it). --- src/game/game_handler.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 01abd3dc..d26a673a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20130,6 +20130,40 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { LOG_DEBUG("Party member stats for ", member->name, ": HP=", member->curHealth, "/", member->maxHealth, " Level=", member->level); + + // Fire addon events for party/raid member health/power/aura changes + if (addonEventCallback_) { + // Resolve unit ID for this member (party1..4 or raid1..40) + std::string unitId; + if (partyData.groupType == 1) { + // Raid: find 1-based index + for (size_t i = 0; i < partyData.members.size(); ++i) { + if (partyData.members[i].guid == memberGuid) { + unitId = "raid" + std::to_string(i + 1); + break; + } + } + } else { + // Party: find 1-based index excluding self + int found = 0; + for (const auto& m : partyData.members) { + if (m.guid == playerGuid) continue; + ++found; + if (m.guid == memberGuid) { + unitId = "party" + std::to_string(found); + break; + } + } + } + if (!unitId.empty()) { + if (updateFlags & (0x0002 | 0x0004)) // CUR_HP or MAX_HP + addonEventCallback_("UNIT_HEALTH", {unitId}); + if (updateFlags & (0x0010 | 0x0020)) // CUR_POWER or MAX_POWER + addonEventCallback_("UNIT_POWER", {unitId}); + if (updateFlags & 0x0200) // AURAS + addonEventCallback_("UNIT_AURA", {unitId}); + } + } } // ============================================================ @@ -20660,6 +20694,7 @@ void GameHandler::lootItem(uint8_t slotIndex) { void GameHandler::closeLoot() { if (!lootWindowOpen) return; lootWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {}); masterLootCandidates_.clear(); if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) { clearTarget(); From 45850c5aa911da12fc2584ccdda164f76089a7b2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 01:58:03 -0700 Subject: [PATCH 09/74] feat: add targeting Lua API functions for addon and macro support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement 8 targeting functions commonly used by unit frame addons, targeting macros, and click-casting addons: - TargetUnit(unitId) / ClearTarget() - FocusUnit(unitId) / ClearFocus() - AssistUnit(unitId) — target the given unit's target - TargetLastTarget() — return to previous target - TargetNearestEnemy() / TargetNearestFriend() — tab-targeting --- src/addons/lua_engine.cpp | 82 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 1b9067c0..7160576e 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -582,6 +582,80 @@ static int lua_HasTarget(lua_State* L) { return 1; } +// TargetUnit(unitId) — set current target +static int lua_TargetUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_checkstring(L, 1); + 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) gh->setTarget(guid); + return 0; +} + +// ClearTarget() — clear current target +static int lua_ClearTarget(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->clearTarget(); + return 0; +} + +// FocusUnit(unitId) — set focus target +static int lua_FocusUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, nullptr); + if (!uid || !*uid) return 0; + 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) gh->setFocus(guid); + return 0; +} + +// ClearFocus() — clear focus target +static int lua_ClearFocus(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->clearFocus(); + return 0; +} + +// AssistUnit(unitId) — target whatever the given unit is targeting +static int lua_AssistUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + 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) return 0; + uint64_t theirTarget = getEntityTargetGuid(gh, guid); + if (theirTarget != 0) gh->setTarget(theirTarget); + return 0; +} + +// TargetLastTarget() — re-target previous target +static int lua_TargetLastTarget(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->targetLastTarget(); + return 0; +} + +// TargetNearestEnemy() — tab-target nearest enemy +static int lua_TargetNearestEnemy(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->targetEnemy(false); + return 0; +} + +// TargetNearestFriend() — target nearest friendly unit +static int lua_TargetNearestFriend(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->targetFriend(false); + return 0; +} + // --- GetSpellInfo / GetSpellTexture --- // GetSpellInfo(spellIdOrName) -> name, rank, icon, castTime, minRange, maxRange, spellId static int lua_GetSpellInfo(lua_State* L) { @@ -2214,6 +2288,14 @@ void LuaEngine::registerCoreAPI() { {"IsSpellKnown", lua_IsSpellKnown}, {"GetSpellCooldown", lua_GetSpellCooldown}, {"HasTarget", lua_HasTarget}, + {"TargetUnit", lua_TargetUnit}, + {"ClearTarget", lua_ClearTarget}, + {"FocusUnit", lua_FocusUnit}, + {"ClearFocus", lua_ClearFocus}, + {"AssistUnit", lua_AssistUnit}, + {"TargetLastTarget", lua_TargetLastTarget}, + {"TargetNearestEnemy", lua_TargetNearestEnemy}, + {"TargetNearestFriend", lua_TargetNearestFriend}, {"UnitRace", lua_UnitRace}, {"UnitPowerType", lua_UnitPowerType}, {"GetNumGroupMembers", lua_GetNumGroupMembers}, From 6e863a323a4f444011bd411aeead8ffde3dd26c1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:03:51 -0700 Subject: [PATCH 10/74] feat: add UseAction, CancelUnitBuff, and CastSpellByID Lua functions Implement 3 critical gameplay Lua API functions: - UseAction(slot) activates an action bar slot (spell/item), enabling action bar addons like Bartender/Dominos to fire abilities - CancelUnitBuff("player", index) cancels a buff by index, enabling auto-cancel and buff management addons - CastSpellByID(id) casts a spell by numeric ID, enabling macro addons and spell queuing systems --- src/addons/lua_engine.cpp | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 7160576e..2715a69c 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1774,6 +1774,58 @@ static int lua_GetActionCooldown(lua_State* L) { return 3; } +// UseAction(slot, checkCursor, onSelf) — activate action bar slot (1-indexed) +static int lua_UseAction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) return 0; + const auto& action = bar[slot]; + if (action.type == game::ActionBarSlot::SPELL && action.isReady()) { + uint64_t target = gh->hasTarget() ? gh->getTargetGuid() : 0; + gh->castSpell(action.id, target); + } else if (action.type == game::ActionBarSlot::ITEM && action.id != 0) { + gh->useItemById(action.id); + } + // Macro execution requires GameScreen context; not available from pure Lua API + return 0; +} + +// CancelUnitBuff(unit, index) — cancel a buff by index (1-indexed) +static int lua_CancelUnitBuff(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, "player"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") return 0; // Can only cancel own buffs + int index = static_cast(luaL_checknumber(L, 2)); + const auto& auras = gh->getPlayerAuras(); + // Find the Nth buff (non-debuff) + int buffCount = 0; + for (const auto& a : auras) { + if (a.isEmpty()) continue; + if ((a.flags & 0x80) != 0) continue; // skip debuffs + if (++buffCount == index) { + gh->cancelAura(a.spellId); + break; + } + } + return 0; +} + +// CastSpellByID(spellId) — cast spell by numeric ID +static int lua_CastSpellByID(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + uint32_t spellId = static_cast(luaL_checknumber(L, 1)); + if (spellId == 0) return 0; + uint64_t target = gh->hasTarget() ? gh->getTargetGuid() : 0; + gh->castSpell(spellId, target); + return 0; +} + // --- Frame System --- // Minimal WoW-compatible frame objects with RegisterEvent/SetScript/GetScript. // Frames are Lua tables with a metatable that provides methods. @@ -2374,6 +2426,9 @@ void LuaEngine::registerCoreAPI() { {"IsCurrentAction", lua_IsCurrentAction}, {"IsUsableAction", lua_IsUsableAction}, {"GetActionCooldown", lua_GetActionCooldown}, + {"UseAction", lua_UseAction}, + {"CancelUnitBuff", lua_CancelUnitBuff}, + {"CastSpellByID", lua_CastSpellByID}, // Loot API {"GetNumLootItems", lua_GetNumLootItems}, {"GetLootSlotInfo", lua_GetLootSlotInfo}, From c20db42479124d82db99d4fce78fcc8f4405a0d8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:10:09 -0700 Subject: [PATCH 11/74] feat: fire UNIT_SPELLCAST_SENT and UNIT_SPELLCAST_STOP events Fire UNIT_SPELLCAST_SENT when the player initiates a spell cast (before server confirms), enabling cast bar addons like Quartz to show latency. Includes target name and spell ID as arguments. Fire UNIT_SPELLCAST_STOP whenever a cast bar should disappear: - On successful cast completion (SMSG_SPELL_GO) - On cast failure (SMSG_CAST_RESULT with error) - On spell interrupt (SMSG_SPELL_FAILURE/SMSG_SPELL_FAILED_OTHER) - On manual cast cancel These events are essential for cast bar replacement addons to properly track when casts begin and end. --- src/game/game_handler.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d26a673a..00b6ac48 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2304,8 +2304,10 @@ void GameHandler::handlePacket(network::Packet& packet) { : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addUIError(errMsg); if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId); - if (addonEventCallback_) + if (addonEventCallback_) { addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)}); + } MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; @@ -3418,8 +3420,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (failGuid == playerGuid || failGuid == 0) unitId = "player"; else if (failGuid == targetGuid) unitId = "target"; else if (failGuid == focusGuid) unitId = "focus"; - if (!unitId.empty()) + if (!unitId.empty()) { addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); + } } if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed — clear gather-node loot target so the @@ -18728,6 +18732,13 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { socket->send(packet); LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); + // Fire UNIT_SPELLCAST_SENT for cast bar addons (fires on client intent, before server confirms) + if (addonEventCallback_) { + std::string targetName; + if (target != 0) targetName = lookupName(target); + addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)}); + } + // Optimistically start GCD immediately on cast, but do not restart it while // already active (prevents timeout animation reset on repeated key presses). if (!isGCDActive()) { @@ -18756,6 +18767,8 @@ void GameHandler::cancelCast() { craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; + if (addonEventCallback_) + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"}); } void GameHandler::startCraftQueue(uint32_t spellId, int count) { @@ -19255,6 +19268,10 @@ void GameHandler::handleSpellGo(network::Packet& packet) { spellCastAnimCallback_(playerGuid, false, false); } + // Fire UNIT_SPELLCAST_STOP — cast bar should disappear + if (addonEventCallback_) + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); + // Spell queue: fire the next queued spell now that casting has ended if (queuedSpellId_ != 0) { uint32_t nextSpell = queuedSpellId_; From 855f00c5b5c60c88d8a0a2fe40b61ddc37a63e2b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:15:50 -0700 Subject: [PATCH 12/74] feat: add LibStub and CallbackHandler-1.0 for Ace3 addon compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement LibStub — the universal library version management system that virtually every WoW addon framework depends on (Ace3, LibDataBroker, LibSharedMedia, etc.). Without LibStub, most popular addons fail to load. Also implement CallbackHandler-1.0 — the standard event callback library used by Ace3-based addons for inter-module communication. Supports RegisterCallback, UnregisterCallback, UnregisterAllCallbacks, and Fire. These two libraries unlock the entire Ace3 addon ecosystem. --- src/addons/lua_engine.cpp | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 2715a69c..cadad268 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2599,6 +2599,66 @@ void LuaEngine::registerCoreAPI() { "end\n" ); + // LibStub — universal library version management used by Ace3 and virtually all addon libs. + // This is the standard WoW LibStub implementation that addons embed/expect globally. + luaL_dostring(L_, + "local LibStub = LibStub or {}\n" + "LibStub.libs = LibStub.libs or {}\n" + "LibStub.minors = LibStub.minors or {}\n" + "function LibStub:NewLibrary(major, minor)\n" + " assert(type(major) == 'string', 'LibStub:NewLibrary: bad argument #1 (string expected)')\n" + " minor = assert(tonumber(minor or (type(minor) == 'string' and minor:match('(%d+)'))), 'LibStub:NewLibrary: bad argument #2 (number expected)')\n" + " local oldMinor = self.minors[major]\n" + " if oldMinor and oldMinor >= minor then return nil end\n" + " local lib = self.libs[major] or {}\n" + " self.libs[major] = lib\n" + " self.minors[major] = minor\n" + " return lib, oldMinor\n" + "end\n" + "function LibStub:GetLibrary(major, silent)\n" + " if not self.libs[major] and not silent then\n" + " error('Cannot find a library instance of \"' .. tostring(major) .. '\".')\n" + " end\n" + " return self.libs[major], self.minors[major]\n" + "end\n" + "function LibStub:IterateLibraries() return pairs(self.libs) end\n" + "setmetatable(LibStub, { __call = LibStub.GetLibrary })\n" + "_G['LibStub'] = LibStub\n" + ); + + // CallbackHandler-1.0 — minimal implementation for Ace3-based addons + luaL_dostring(L_, + "if LibStub then\n" + " local CBH = LibStub:NewLibrary('CallbackHandler-1.0', 7)\n" + " if CBH then\n" + " CBH.mixins = { 'RegisterCallback', 'UnregisterCallback', 'UnregisterAllCallbacks', 'Fire' }\n" + " function CBH:New(target, regName, unregName, unregAllName, onUsed)\n" + " local registry = setmetatable({}, { __index = CBH })\n" + " registry.callbacks = {}\n" + " target = target or {}\n" + " target[regName or 'RegisterCallback'] = function(self, event, method, ...)\n" + " if not registry.callbacks[event] then registry.callbacks[event] = {} end\n" + " local handler = type(method) == 'function' and method or self[method]\n" + " registry.callbacks[event][self] = handler\n" + " end\n" + " target[unregName or 'UnregisterCallback'] = function(self, event)\n" + " if registry.callbacks[event] then registry.callbacks[event][self] = nil end\n" + " end\n" + " target[unregAllName or 'UnregisterAllCallbacks'] = function(self)\n" + " for event, handlers in pairs(registry.callbacks) do handlers[self] = nil end\n" + " end\n" + " registry.Fire = function(self, event, ...)\n" + " if not self.callbacks[event] then return end\n" + " for obj, handler in pairs(self.callbacks[event]) do\n" + " handler(obj, event, ...)\n" + " end\n" + " end\n" + " return registry\n" + " end\n" + " end\n" + "end\n" + ); + // Noop stubs for commonly called functions that don't need implementation luaL_dostring(L_, "function SetDesaturation() end\n" From 0a6fdfb8b175374c383a79de16f27943ab07d2a5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:18:25 -0700 Subject: [PATCH 13/74] feat: add GetNumSkillLines and GetSkillLineInfo for profession addons Implement skill line API functions that profession and tradeskill addons need to display player skills: - GetNumSkillLines() returns count of player skills - GetSkillLineInfo(index) returns full 12-value tuple: name, isHeader, isExpanded, rank, tempPoints, modifier, maxRank, isAbandonable, etc. Data comes from SMSG_SKILLS_INFO update fields and SkillLine.dbc names. --- src/addons/lua_engine.cpp | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index cadad268..6185969a 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1189,6 +1189,51 @@ static int lua_IsQuestComplete(lua_State* L) { return 1; } +// --- Skill Line API --- + +// GetNumSkillLines() → count +static int lua_GetNumSkillLines(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + lua_pushnumber(L, gh->getPlayerSkills().size()); + return 1; +} + +// GetSkillLineInfo(index) → skillName, isHeader, isExpanded, skillRank, numTempPoints, skillModifier, skillMaxRank, isAbandonable, stepCost, rankCost, minLevel, skillCostType +static int lua_GetSkillLineInfo(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& skills = gh->getPlayerSkills(); + if (index > static_cast(skills.size())) { + lua_pushnil(L); + return 1; + } + // Skills are in a map — iterate to the Nth entry + auto it = skills.begin(); + std::advance(it, index - 1); + const auto& skill = it->second; + std::string name = gh->getSkillName(skill.skillId); + if (name.empty()) name = "Skill " + std::to_string(skill.skillId); + + lua_pushstring(L, name.c_str()); // 1: skillName + lua_pushboolean(L, 0); // 2: isHeader (false — flat list) + lua_pushboolean(L, 1); // 3: isExpanded + lua_pushnumber(L, skill.effectiveValue()); // 4: skillRank + lua_pushnumber(L, skill.bonusTemp); // 5: numTempPoints + lua_pushnumber(L, skill.bonusPerm); // 6: skillModifier + lua_pushnumber(L, skill.maxValue); // 7: skillMaxRank + lua_pushboolean(L, 0); // 8: isAbandonable + lua_pushnumber(L, 0); // 9: stepCost + lua_pushnumber(L, 0); // 10: rankCost + lua_pushnumber(L, 0); // 11: minLevel + lua_pushnumber(L, 0); // 12: skillCostType + return 12; +} + // --- Loot API --- // GetNumLootItems() → count @@ -2416,6 +2461,9 @@ void LuaEngine::registerCoreAPI() { {"GetQuestLogTitle", lua_GetQuestLogTitle}, {"GetQuestLogQuestText", lua_GetQuestLogQuestText}, {"IsQuestComplete", lua_IsQuestComplete}, + // Skill line API + {"GetNumSkillLines", lua_GetNumSkillLines}, + {"GetSkillLineInfo", lua_GetSkillLineInfo}, // Reaction/connection queries {"UnitReaction", lua_UnitReaction}, {"UnitIsConnected", lua_UnitIsConnected}, From 55ef607093726959f53025fd1edfd211c139e1d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:22:35 -0700 Subject: [PATCH 14/74] feat: add talent tree Lua API for talent inspection addons Implement 5 talent-related WoW Lua API functions: - GetNumTalentTabs() returns class-specific talent tree count (usually 3) - GetTalentTabInfo(tab) returns name, icon, pointsSpent, background - GetNumTalents(tab) returns talent count in a specific tree - GetTalentInfo(tab, index) returns full 8-value tuple with name, tier, column, current rank, max rank, and availability - GetActiveTalentGroup() returns active spec (1 or 2) Data sourced from Talent.dbc, TalentTab.dbc, and the server-sent talent info packet. Enables talent addons and spec display addons. --- src/addons/lua_engine.cpp | 141 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 6185969a..ee8b3f34 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1234,6 +1234,141 @@ static int lua_GetSkillLineInfo(lua_State* L) { return 12; } +// --- Talent API --- + +// GetNumTalentTabs() → count (usually 3) +static int lua_GetNumTalentTabs(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + // Count tabs matching the player's class + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + int count = 0; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) count++; + } + lua_pushnumber(L, count); + return 1; +} + +// GetTalentTabInfo(tabIndex) → name, iconTexture, pointsSpent, background +static int lua_GetTalentTabInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIndex = static_cast(luaL_checknumber(L, 1)); // 1-indexed + if (!gh || tabIndex < 1) { + lua_pushnil(L); return 1; + } + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + // Find the Nth tab for this class (sorted by orderIndex) + std::vector classTabs; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) classTabs.push_back(&tab); + } + std::sort(classTabs.begin(), classTabs.end(), + [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); + if (tabIndex > static_cast(classTabs.size())) { + lua_pushnil(L); return 1; + } + const auto* tab = classTabs[tabIndex - 1]; + // Count points spent in this tab + int pointsSpent = 0; + const auto& learned = gh->getLearnedTalents(); + for (const auto& [talentId, rank] : learned) { + const auto* entry = gh->getTalentEntry(talentId); + if (entry && entry->tabId == tab->tabId) pointsSpent += rank; + } + lua_pushstring(L, tab->name.c_str()); // 1: name + lua_pushnil(L); // 2: iconTexture (not resolved) + lua_pushnumber(L, pointsSpent); // 3: pointsSpent + lua_pushstring(L, tab->backgroundFile.c_str()); // 4: background + return 4; +} + +// GetNumTalents(tabIndex) → count +static int lua_GetNumTalents(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIndex = static_cast(luaL_checknumber(L, 1)); + if (!gh || tabIndex < 1) { lua_pushnumber(L, 0); return 1; } + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + std::vector classTabs; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) classTabs.push_back(&tab); + } + std::sort(classTabs.begin(), classTabs.end(), + [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); + if (tabIndex > static_cast(classTabs.size())) { + lua_pushnumber(L, 0); return 1; + } + uint32_t targetTabId = classTabs[tabIndex - 1]->tabId; + int count = 0; + for (const auto& [talentId, entry] : gh->getAllTalents()) { + if (entry.tabId == targetTabId) count++; + } + lua_pushnumber(L, count); + return 1; +} + +// GetTalentInfo(tabIndex, talentIndex) → name, iconTexture, tier, column, rank, maxRank, isExceptional, available +static int lua_GetTalentInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIndex = static_cast(luaL_checknumber(L, 1)); + int talentIndex = static_cast(luaL_checknumber(L, 2)); + if (!gh || tabIndex < 1 || talentIndex < 1) { + for (int i = 0; i < 8; i++) lua_pushnil(L); + return 8; + } + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + std::vector classTabs; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) classTabs.push_back(&tab); + } + std::sort(classTabs.begin(), classTabs.end(), + [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); + if (tabIndex > static_cast(classTabs.size())) { + for (int i = 0; i < 8; i++) lua_pushnil(L); + return 8; + } + uint32_t targetTabId = classTabs[tabIndex - 1]->tabId; + // Collect talents for this tab, sorted by row then column + std::vector tabTalents; + for (const auto& [talentId, entry] : gh->getAllTalents()) { + if (entry.tabId == targetTabId) tabTalents.push_back(&entry); + } + std::sort(tabTalents.begin(), tabTalents.end(), + [](const auto* a, const auto* b) { + return (a->row != b->row) ? a->row < b->row : a->column < b->column; + }); + if (talentIndex > static_cast(tabTalents.size())) { + for (int i = 0; i < 8; i++) lua_pushnil(L); + return 8; + } + const auto* talent = tabTalents[talentIndex - 1]; + uint8_t rank = gh->getTalentRank(talent->talentId); + // Get spell name for rank 1 spell + std::string name = gh->getSpellName(talent->rankSpells[0]); + if (name.empty()) name = "Talent " + std::to_string(talent->talentId); + + lua_pushstring(L, name.c_str()); // 1: name + lua_pushnil(L); // 2: iconTexture + lua_pushnumber(L, talent->row + 1); // 3: tier (1-indexed) + lua_pushnumber(L, talent->column + 1); // 4: column (1-indexed) + lua_pushnumber(L, rank); // 5: rank + lua_pushnumber(L, talent->maxRank); // 6: maxRank + lua_pushboolean(L, 0); // 7: isExceptional + lua_pushboolean(L, 1); // 8: available + return 8; +} + +// GetActiveTalentGroup() → 1 or 2 +static int lua_GetActiveTalentGroup(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? (gh->getActiveTalentSpec() + 1) : 1); + return 1; +} + // --- Loot API --- // GetNumLootItems() → count @@ -2464,6 +2599,12 @@ void LuaEngine::registerCoreAPI() { // Skill line API {"GetNumSkillLines", lua_GetNumSkillLines}, {"GetSkillLineInfo", lua_GetSkillLineInfo}, + // Talent API + {"GetNumTalentTabs", lua_GetNumTalentTabs}, + {"GetTalentTabInfo", lua_GetTalentTabInfo}, + {"GetNumTalents", lua_GetNumTalents}, + {"GetTalentInfo", lua_GetTalentInfo}, + {"GetActiveTalentGroup", lua_GetActiveTalentGroup}, // Reaction/connection queries {"UnitReaction", lua_UnitReaction}, {"UnitIsConnected", lua_UnitIsConnected}, From d75f2c62e557bb501127fb7dbef98156cbd980e8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:26:44 -0700 Subject: [PATCH 15/74] feat: fire UNIT_HEALTH/UNIT_POWER events from dedicated update packets SMSG_HEALTH_UPDATE and SMSG_POWER_UPDATE are high-frequency WotLK packets that update entity health/power values but weren't firing addon events. Unit frame addons (Pitbull, oUF, SUF) depend on these events to update health/mana bars in real-time. Now fire UNIT_HEALTH for player/target/focus on SMSG_HEALTH_UPDATE and UNIT_POWER on SMSG_POWER_UPDATE, matching the events already fired from the UPDATE_OBJECT path. --- src/game/game_handler.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 00b6ac48..3832eb39 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2160,6 +2160,14 @@ void GameHandler::handlePacket(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { unit->setHealth(hp); } + if (addonEventCallback_ && guid != 0) { + std::string unitId; + if (guid == playerGuid) unitId = "player"; + else if (guid == targetGuid) unitId = "target"; + else if (guid == focusGuid) unitId = "focus"; + if (!unitId.empty()) + addonEventCallback_("UNIT_HEALTH", {unitId}); + } break; } case Opcode::SMSG_POWER_UPDATE: { @@ -2177,6 +2185,14 @@ void GameHandler::handlePacket(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { unit->setPowerByType(powerType, value); } + if (addonEventCallback_ && guid != 0) { + std::string unitId; + if (guid == playerGuid) unitId = "player"; + else if (guid == targetGuid) unitId = "target"; + else if (guid == focusGuid) unitId = "focus"; + if (!unitId.empty()) + addonEventCallback_("UNIT_POWER", {unitId}); + } break; } From 60904e2e158b94111c2cb9e63ea94f26bae6dcb2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:29:48 -0700 Subject: [PATCH 16/74] fix: fire talent/spell events correctly when learning talents Fix bug where learning a talent caused an early return before firing LEARNED_SPELL_IN_TAB and SPELLS_CHANGED events, leaving talent addons unaware of changes. Now talent learning fires CHARACTER_POINTS_CHANGED, PLAYER_TALENT_UPDATE, LEARNED_SPELL_IN_TAB, and SPELLS_CHANGED. Also fire CHARACTER_POINTS_CHANGED, ACTIVE_TALENT_GROUP_CHANGED, and PLAYER_TALENT_UPDATE from handleTalentsInfo (SMSG_TALENTS_INFO), so talent addons update when the full talent state is received from the server (login, spec switch, respec). Also fire UNIT_HEALTH/UNIT_POWER events from SMSG_HEALTH_UPDATE and SMSG_POWER_UPDATE packets for real-time unit frame updates. --- src/game/game_handler.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3832eb39..74f027ef 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -19524,6 +19524,7 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : ""); // Check if this spell corresponds to a talent rank + bool isTalentSpell = false; for (const auto& [talentId, talent] : talentCache_) { for (int rank = 0; rank < 5; ++rank) { if (talent.rankSpells[rank] == spellId) { @@ -19532,9 +19533,15 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { learnedTalents_[activeTalentSpec_][talentId] = newRank; LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank, " (spell ", spellId, ") in spec ", (int)activeTalentSpec_); - return; + isTalentSpell = true; + if (addonEventCallback_) { + addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); + addonEventCallback_("PLAYER_TALENT_UPDATE", {}); + } + break; } } + if (isTalentSpell) break; } // Fire LEARNED_SPELL_IN_TAB / SPELLS_CHANGED for Lua addons @@ -19543,6 +19550,8 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { addonEventCallback_("SPELLS_CHANGED", {}); } + if (isTalentSpell) return; // talent spells don't show chat message + // Show chat message for non-talent spells, but only if not already announced by // SMSG_TRAINER_BUY_SUCCEEDED (which pre-inserts into knownSpells). if (!alreadyKnown) { @@ -19720,6 +19729,13 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) { " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, " learned=", learnedTalents_[activeTalentGroup].size()); + // Fire talent-related events for addons + if (addonEventCallback_) { + addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); + addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {}); + addonEventCallback_("PLAYER_TALENT_UPDATE", {}); + } + if (!talentsInitialized_) { talentsInitialized_ = true; if (unspentTalents > 0) { From 760c6a2790bc47300d41a5d88d5673953d518d34 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:31:59 -0700 Subject: [PATCH 17/74] feat: fire PLAYER_ENTER_COMBAT and PLAYER_LEAVE_COMBAT events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire PLAYER_ENTER_COMBAT when the player's auto-attack starts (SMSG_ATTACKSTART) and PLAYER_LEAVE_COMBAT when auto-attack stops. These events are distinct from PLAYER_REGEN_DISABLED/ENABLED — they specifically track physical melee combat state and are used by combat-aware addons for weapon swing timers and attack state tracking. --- 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 74f027ef..f8c71576 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16043,6 +16043,8 @@ void GameHandler::stopAutoAttack() { socket->send(packet); } LOG_INFO("Stopping auto-attack"); + if (addonEventCallback_) + addonEventCallback_("PLAYER_LEAVE_COMBAT", {}); } void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, @@ -16164,6 +16166,8 @@ void GameHandler::handleAttackStart(network::Packet& packet) { autoAttacking = true; autoAttackRetryPending_ = false; autoAttackTarget = data.victimGuid; + if (addonEventCallback_) + addonEventCallback_("PLAYER_ENTER_COMBAT", {}); } else if (data.victimGuid == playerGuid && data.attackerGuid != 0) { hostileAttackers_.insert(data.attackerGuid); autoTargetAttacker(data.attackerGuid); From 154140f18524b5b1155952340125fedaf5bf2780 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:36:06 -0700 Subject: [PATCH 18/74] feat: add UIDropDownMenu framework, font objects, and UI global stubs Add the UIDropDownMenu compatibility framework used by virtually every addon with settings or selection menus: UIDropDownMenu_Initialize, CreateInfo, AddButton, SetWidth, SetText, GetText, SetSelectedID, etc. Add global font object stubs (GameFontNormal, GameFontHighlight, etc.) referenced by CreateFontString template arguments. Add UISpecialFrames table, InterfaceOptionsFrame for addon panels, InterfaceOptions_AddCategory, and common font color constants (GRAY_FONT_COLOR, NORMAL_FONT_COLOR, etc.). These globals prevent nil-reference errors in most popular addons. --- 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 ee8b3f34..d86103c5 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2928,6 +2928,69 @@ void LuaEngine::registerCoreAPI() { "end\n" "GetCoinText = GetCoinTextureString\n" ); + + // UIDropDownMenu framework — minimal compat for addons using dropdown menus + luaL_dostring(L_, + "UIDROPDOWNMENU_MENU_LEVEL = 1\n" + "UIDROPDOWNMENU_MENU_VALUE = nil\n" + "UIDROPDOWNMENU_OPEN_MENU = nil\n" + "local _ddMenuList = {}\n" + "function UIDropDownMenu_Initialize(frame, initFunc, displayMode, level, menuList)\n" + " if frame then frame.__initFunc = initFunc end\n" + "end\n" + "function UIDropDownMenu_CreateInfo() return {} end\n" + "function UIDropDownMenu_AddButton(info, level) table.insert(_ddMenuList, info) end\n" + "function UIDropDownMenu_SetWidth(frame, width) end\n" + "function UIDropDownMenu_SetButtonWidth(frame, width) end\n" + "function UIDropDownMenu_SetText(frame, text)\n" + " if frame then frame.__text = text end\n" + "end\n" + "function UIDropDownMenu_GetText(frame)\n" + " return frame and frame.__text or ''\n" + "end\n" + "function UIDropDownMenu_SetSelectedID(frame, id) end\n" + "function UIDropDownMenu_SetSelectedValue(frame, value) end\n" + "function UIDropDownMenu_GetSelectedID(frame) return 1 end\n" + "function UIDropDownMenu_GetSelectedValue(frame) return nil end\n" + "function UIDropDownMenu_JustifyText(frame, justify) end\n" + "function UIDropDownMenu_EnableDropDown(frame) end\n" + "function UIDropDownMenu_DisableDropDown(frame) end\n" + "function CloseDropDownMenus() end\n" + "function ToggleDropDownMenu(level, value, frame, anchor, xOfs, yOfs) end\n" + ); + + // UISpecialFrames: frames in this list close on Escape key + luaL_dostring(L_, + "UISpecialFrames = {}\n" + // Font object stubs — addons reference these for CreateFontString templates + "GameFontNormal = {}\n" + "GameFontNormalSmall = {}\n" + "GameFontNormalLarge = {}\n" + "GameFontHighlight = {}\n" + "GameFontHighlightSmall = {}\n" + "GameFontHighlightLarge = {}\n" + "GameFontDisable = {}\n" + "GameFontDisableSmall = {}\n" + "GameFontWhite = {}\n" + "GameFontRed = {}\n" + "GameFontGreen = {}\n" + "NumberFontNormal = {}\n" + "ChatFontNormal = {}\n" + "SystemFont = {}\n" + // InterfaceOptionsFrame: addons register settings panels here + "InterfaceOptionsFrame = CreateFrame('Frame', 'InterfaceOptionsFrame')\n" + "InterfaceOptionsFramePanelContainer = CreateFrame('Frame', 'InterfaceOptionsFramePanelContainer')\n" + "function InterfaceOptions_AddCategory(panel) end\n" + "function InterfaceOptionsFrame_OpenToCategory(panel) end\n" + // Commonly expected global tables + "SLASH_RELOAD1 = '/reload'\n" + "SLASH_RELOADUI1 = '/reloadui'\n" + "GRAY_FONT_COLOR = {r=0.5,g=0.5,b=0.5}\n" + "NORMAL_FONT_COLOR = {r=1.0,g=0.82,b=0.0}\n" + "HIGHLIGHT_FONT_COLOR = {r=1.0,g=1.0,b=1.0}\n" + "GREEN_FONT_COLOR = {r=0.1,g=1.0,b=0.1}\n" + "RED_FONT_COLOR = {r=1.0,g=0.1,b=0.1}\n" + ); } // ---- Event System ---- From b99bf7021b20f7b1888602fa0574a96535dd0f88 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:37:56 -0700 Subject: [PATCH 19/74] feat: add WoW table/string/math/bit utility functions for addon compat Add commonly used WoW global utility functions that many addons depend on: Table: tContains, tInvert, CopyTable, tDeleteItem String: strupper, strlower, strfind, strsub, strlen, strrep, strbyte, strchar, strrev, gsub, gmatch, strjoin Math: Clamp, Round Bit ops: bit.band, bit.bor, bit.bxor, bit.bnot, bit.lshift, bit.rshift (pure Lua implementation for Lua 5.1 which lacks native bit ops) These prevent nil-reference errors and missing-function crashes in addons that use standard WoW utility globals. --- src/addons/lua_engine.cpp | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index d86103c5..665ecff9 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2991,6 +2991,55 @@ void LuaEngine::registerCoreAPI() { "GREEN_FONT_COLOR = {r=0.1,g=1.0,b=0.1}\n" "RED_FONT_COLOR = {r=1.0,g=0.1,b=0.1}\n" ); + + // WoW table/string utility functions used by many addons + luaL_dostring(L_, + // Table utilities + "function tContains(tbl, item)\n" + " for _, v in pairs(tbl) do if v == item then return true end end\n" + " return false\n" + "end\n" + "function tInvert(tbl)\n" + " local inv = {}\n" + " for k, v in pairs(tbl) do inv[v] = k end\n" + " return inv\n" + "end\n" + "function CopyTable(src)\n" + " if type(src) ~= 'table' then return src end\n" + " local copy = {}\n" + " for k, v in pairs(src) do copy[k] = CopyTable(v) end\n" + " return setmetatable(copy, getmetatable(src))\n" + "end\n" + "function tDeleteItem(tbl, item)\n" + " for i = #tbl, 1, -1 do if tbl[i] == item then table.remove(tbl, i) end end\n" + "end\n" + // String utilities (WoW globals that alias Lua string functions) + "strupper = string.upper\n" + "strlower = string.lower\n" + "strfind = string.find\n" + "strsub = string.sub\n" + "strlen = string.len\n" + "strrep = string.rep\n" + "strbyte = string.byte\n" + "strchar = string.char\n" + "strrev = string.reverse\n" + "gsub = string.gsub\n" + "gmatch = string.gmatch\n" + "strjoin = function(delim, ...)\n" + " return table.concat({...}, delim)\n" + "end\n" + // Math utilities + "function Clamp(val, lo, hi) return math.min(math.max(val, lo), hi) end\n" + "function Round(val) return math.floor(val + 0.5) end\n" + // Bit operations (WoW provides these; Lua 5.1 doesn't have native bit ops) + "bit = bit or {}\n" + "bit.band = bit.band or function(a, b) local r,m=0,1 for i=0,31 do if a%2==1 and b%2==1 then r=r+m end a=math.floor(a/2) b=math.floor(b/2) m=m*2 end return r end\n" + "bit.bor = bit.bor or function(a, b) local r,m=0,1 for i=0,31 do if a%2==1 or b%2==1 then r=r+m end a=math.floor(a/2) b=math.floor(b/2) m=m*2 end return r end\n" + "bit.bxor = bit.bxor or function(a, b) local r,m=0,1 for i=0,31 do if (a%2==1)~=(b%2==1) then r=r+m end a=math.floor(a/2) b=math.floor(b/2) m=m*2 end return r end\n" + "bit.bnot = bit.bnot or function(a) return 4294967295 - a end\n" + "bit.lshift = bit.lshift or function(a, n) return a * (2^n) end\n" + "bit.rshift = bit.rshift or function(a, n) return math.floor(a / (2^n)) end\n" + ); } // ---- Event System ---- From 0d2fd02dcaa1e835b95c6178615b739f9ef81af2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:39:44 -0700 Subject: [PATCH 20/74] feat: add 40+ frame metatable methods to prevent addon nil-reference errors Add commonly called frame methods as no-ops or with basic state tracking on the frame metatable, so any CreateFrame result supports them: Layout: SetFrameLevel/Get, SetFrameStrata/Get, SetScale/Get/GetEffective, ClearAllPoints, SetID/GetID, GetLeft/Right/Top/Bottom, GetNumPoints, GetPoint, SetHitRectInsets Behavior: EnableMouse, EnableMouseWheel, SetMovable, SetResizable, RegisterForDrag, SetClampedToScreen, SetToplevel, Raise, Lower, StartMoving, StopMovingOrSizing, RegisterForClicks, IsMouseOver Visual: SetBackdrop, SetBackdropColor, SetBackdropBorderColor Scripting: HookScript (chains with existing SetScript handlers), SetAttribute/GetAttribute, GetObjectType Sizing: SetMinResize, SetMaxResize These prevent the most common addon errors when addons call standard WoW frame methods on CreateFrame results. --- src/addons/lua_engine.cpp | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 665ecff9..4c87f094 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2691,6 +2691,57 @@ void LuaEngine::registerCoreAPI() { } lua_setglobal(L_, "__WoweeFrameMT"); + // Add commonly called no-op frame methods to prevent addon errors + luaL_dostring(L_, + "local mt = __WoweeFrameMT\n" + "function mt:SetFrameLevel(level) self.__frameLevel = level end\n" + "function mt:GetFrameLevel() return self.__frameLevel or 1 end\n" + "function mt:SetFrameStrata(strata) self.__strata = strata end\n" + "function mt:GetFrameStrata() return self.__strata or 'MEDIUM' end\n" + "function mt:EnableMouse(enable) end\n" + "function mt:EnableMouseWheel(enable) end\n" + "function mt:SetMovable(movable) end\n" + "function mt:SetResizable(resizable) end\n" + "function mt:RegisterForDrag(...) end\n" + "function mt:SetClampedToScreen(clamped) end\n" + "function mt:SetBackdrop(backdrop) end\n" + "function mt:SetBackdropColor(...) end\n" + "function mt:SetBackdropBorderColor(...) end\n" + "function mt:ClearAllPoints() end\n" + "function mt:SetID(id) self.__id = id end\n" + "function mt:GetID() return self.__id or 0 end\n" + "function mt:SetScale(scale) self.__scale = scale end\n" + "function mt:GetScale() return self.__scale or 1.0 end\n" + "function mt:GetEffectiveScale() return self.__scale or 1.0 end\n" + "function mt:SetToplevel(top) end\n" + "function mt:Raise() end\n" + "function mt:Lower() end\n" + "function mt:GetLeft() return 0 end\n" + "function mt:GetRight() return 0 end\n" + "function mt:GetTop() return 0 end\n" + "function mt:GetBottom() return 0 end\n" + "function mt:GetNumPoints() return 0 end\n" + "function mt:GetPoint(n) return 'CENTER', nil, 'CENTER', 0, 0 end\n" + "function mt:SetHitRectInsets(...) end\n" + "function mt:RegisterForClicks(...) end\n" + "function mt:SetAttribute(name, value) self['attr_'..name] = value end\n" + "function mt:GetAttribute(name) return self['attr_'..name] end\n" + "function mt:HookScript(scriptType, fn)\n" + " local orig = self.__scripts and self.__scripts[scriptType]\n" + " if orig then\n" + " self:SetScript(scriptType, function(...) orig(...); fn(...) end)\n" + " else\n" + " self:SetScript(scriptType, fn)\n" + " end\n" + "end\n" + "function mt:SetMinResize(...) end\n" + "function mt:SetMaxResize(...) end\n" + "function mt:StartMoving() end\n" + "function mt:StopMovingOrSizing() end\n" + "function mt:IsMouseOver() return false end\n" + "function mt:GetObjectType() return 'Frame' end\n" + ); + // CreateFrame function lua_pushcfunction(L_, lua_CreateFrame); lua_setglobal(L_, "CreateFrame"); From e21f808714c4ce423da94373bc1ea4e31280ee23 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:46:21 -0700 Subject: [PATCH 21/74] feat: support SavedVariablesPerCharacter for per-character addon data Implement the SavedVariablesPerCharacter TOC directive that many addons use to store different settings per character (Bartender, Dominos, MoveAnything, WeakAuras, etc.). Without this, all characters share the same addon data file. Per-character files are stored as ..lua.saved alongside the existing account-wide .lua.saved files. The character name is resolved from the player GUID at world entry time. Changes: - TocFile::getSavedVariablesPerCharacter() parses the TOC directive - AddonManager loads/saves per-character vars alongside account-wide vars - Character name set from game handler before addon loading --- include/addons/addon_manager.hpp | 3 +++ include/addons/toc_parser.hpp | 1 + src/addons/addon_manager.cpp | 21 +++++++++++++++++++++ src/addons/toc_parser.cpp | 17 +++++++++++------ src/core/application.cpp | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/include/addons/addon_manager.hpp b/include/addons/addon_manager.hpp index 681d3822..cfbfd297 100644 --- a/include/addons/addon_manager.hpp +++ b/include/addons/addon_manager.hpp @@ -26,6 +26,7 @@ public: bool isInitialized() const { return luaEngine_.isInitialized(); } void saveAllSavedVariables(); + void setCharacterName(const std::string& name) { characterName_ = name; } /// Re-initialize the Lua VM and reload all addons (used by /reload). bool reload(); @@ -38,6 +39,8 @@ private: bool loadAddon(const TocFile& addon); std::string getSavedVariablesPath(const TocFile& addon) const; + std::string getSavedVariablesPerCharacterPath(const TocFile& addon) const; + std::string characterName_; }; } // namespace wowee::addons diff --git a/include/addons/toc_parser.hpp b/include/addons/toc_parser.hpp index 7bfff469..b19b4a78 100644 --- a/include/addons/toc_parser.hpp +++ b/include/addons/toc_parser.hpp @@ -18,6 +18,7 @@ struct TocFile { std::string getInterface() const; bool isLoadOnDemand() const; std::vector getSavedVariables() const; + std::vector getSavedVariablesPerCharacter() const; }; std::optional parseTocFile(const std::string& tocPath); diff --git a/src/addons/addon_manager.cpp b/src/addons/addon_manager.cpp index e826097f..ca91e92d 100644 --- a/src/addons/addon_manager.cpp +++ b/src/addons/addon_manager.cpp @@ -68,6 +68,11 @@ std::string AddonManager::getSavedVariablesPath(const TocFile& addon) const { return addon.basePath + "/" + addon.addonName + ".lua.saved"; } +std::string AddonManager::getSavedVariablesPerCharacterPath(const TocFile& addon) const { + if (characterName_.empty()) return ""; + return addon.basePath + "/" + addon.addonName + "." + characterName_ + ".lua.saved"; +} + bool AddonManager::loadAddon(const TocFile& addon) { // Load SavedVariables before addon code (so globals are available at load time) auto savedVars = addon.getSavedVariables(); @@ -76,6 +81,15 @@ bool AddonManager::loadAddon(const TocFile& addon) { luaEngine_.loadSavedVariables(svPath); LOG_DEBUG("AddonManager: loaded saved variables for '", addon.addonName, "'"); } + // Load per-character SavedVariables + auto savedVarsPC = addon.getSavedVariablesPerCharacter(); + if (!savedVarsPC.empty()) { + std::string svpcPath = getSavedVariablesPerCharacterPath(addon); + if (!svpcPath.empty()) { + luaEngine_.loadSavedVariables(svpcPath); + LOG_DEBUG("AddonManager: loaded per-character saved variables for '", addon.addonName, "'"); + } + } bool success = true; for (const auto& filename : addon.files) { @@ -120,6 +134,13 @@ void AddonManager::saveAllSavedVariables() { std::string svPath = getSavedVariablesPath(addon); luaEngine_.saveSavedVariables(svPath, savedVars); } + auto savedVarsPC = addon.getSavedVariablesPerCharacter(); + if (!savedVarsPC.empty()) { + std::string svpcPath = getSavedVariablesPerCharacterPath(addon); + if (!svpcPath.empty()) { + luaEngine_.saveSavedVariables(svpcPath, savedVarsPC); + } + } } } diff --git a/src/addons/toc_parser.cpp b/src/addons/toc_parser.cpp index 3b5c03ab..523a164a 100644 --- a/src/addons/toc_parser.cpp +++ b/src/addons/toc_parser.cpp @@ -19,17 +19,12 @@ bool TocFile::isLoadOnDemand() const { return (it != directives.end()) && it->second == "1"; } -std::vector TocFile::getSavedVariables() const { +static std::vector parseVarList(const std::string& val) { std::vector result; - auto it = directives.find("SavedVariables"); - if (it == directives.end()) return result; - // Parse comma-separated variable names - std::string val = it->second; size_t pos = 0; while (pos <= val.size()) { size_t comma = val.find(',', pos); std::string name = (comma != std::string::npos) ? val.substr(pos, comma - pos) : val.substr(pos); - // Trim whitespace size_t start = name.find_first_not_of(" \t"); size_t end = name.find_last_not_of(" \t"); if (start != std::string::npos) @@ -40,6 +35,16 @@ std::vector TocFile::getSavedVariables() const { return result; } +std::vector TocFile::getSavedVariables() const { + auto it = directives.find("SavedVariables"); + return (it != directives.end()) ? parseVarList(it->second) : std::vector{}; +} + +std::vector TocFile::getSavedVariablesPerCharacter() const { + auto it = directives.find("SavedVariablesPerCharacter"); + return (it != directives.end()) ? parseVarList(it->second) : std::vector{}; +} + std::optional parseTocFile(const std::string& tocPath) { std::ifstream f(tocPath); if (!f.is_open()) return std::nullopt; diff --git a/src/core/application.cpp b/src/core/application.cpp index 8b4aeeb0..91d6d619 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -5182,6 +5182,21 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // Load addons once per session on first world entry if (addonManager_ && !addonsLoaded_) { + // Set character name for per-character SavedVariables + if (gameHandler) { + const std::string& charName = gameHandler->lookupName(gameHandler->getPlayerGuid()); + if (!charName.empty()) { + addonManager_->setCharacterName(charName); + } else { + // Fallback: find name from character list + for (const auto& c : gameHandler->getCharacters()) { + if (c.guid == gameHandler->getPlayerGuid()) { + addonManager_->setCharacterName(c.name); + break; + } + } + } + } addonManager_->loadAllAddons(); addonsLoaded_ = true; addonManager_->fireEvent("VARIABLES_LOADED"); From 7105672f06edf92107486af11e877f88cc291717 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:53:07 -0700 Subject: [PATCH 22/74] feat: resolve item icon paths from ItemDisplayInfo.dbc for Lua API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ItemIconPathResolver that lazily loads ItemDisplayInfo.dbc to map displayInfoId → icon texture path. This fixes three Lua API functions that previously returned nil for item icons: - GetItemInfo() field 10 (texture) now returns the icon path - GetActionTexture() for item-type action bar slots now returns icons - GetLootSlotInfo() field 1 (texture) now returns proper item icons instead of incorrectly using the spell icon resolver Follows the same lazy-loading pattern as SpellIconPathResolver. The DBC is loaded once on first query and cached for all subsequent lookups. --- include/game/game_handler.hpp | 8 ++++++++ src/addons/lua_engine.cpp | 26 ++++++++++++++++++++------ src/core/application.cpp | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 97dd3103..5208bc53 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -294,6 +294,13 @@ public: return spellIconPathResolver_ ? spellIconPathResolver_(spellId) : std::string{}; } + // Item icon path resolver: displayInfoId -> texture path (e.g., "Interface\\Icons\\INV_Sword_04") + using ItemIconPathResolver = std::function; + void setItemIconPathResolver(ItemIconPathResolver r) { itemIconPathResolver_ = std::move(r); } + std::string getItemIconPath(uint32_t displayInfoId) const { + return itemIconPathResolver_ ? itemIconPathResolver_(displayInfoId) : 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; @@ -2662,6 +2669,7 @@ private: AddonChatCallback addonChatCallback_; AddonEventCallback addonEventCallback_; SpellIconPathResolver spellIconPathResolver_; + ItemIconPathResolver itemIconPathResolver_; RandomPropertyNameResolver randomPropertyNameResolver_; EmoteAnimCallback emoteAnimCallback_; diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 4c87f094..3dad867b 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -766,7 +766,14 @@ static int lua_GetItemInfo(lua_State* L) { lua_pushstring(L, ""); // 7: subclass lua_pushnumber(L, info->maxStack > 0 ? info->maxStack : 1); // 8: maxStack lua_pushstring(L, ""); // 9: equipSlot - lua_pushnil(L); // 10: texture (icon path — no ItemDisplayInfo icon resolver yet) + // 10: texture (icon path from ItemDisplayInfo.dbc) + if (info->displayInfoId != 0) { + std::string iconPath = gh->getItemIconPath(info->displayInfoId); + if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); + else lua_pushnil(L); + } else { + lua_pushnil(L); + } lua_pushnumber(L, info->sellPrice); // 11: vendorPrice return 11; } @@ -1393,11 +1400,10 @@ static int lua_GetLootSlotInfo(lua_State* L) { const auto& item = loot.items[slot - 1]; const auto* info = gh->getItemInfo(item.itemId); - // texture (icon path — nil if not available) + // texture (icon path from ItemDisplayInfo.dbc) std::string icon; - if (info) { - // Try spell icon resolver as fallback for item icon - icon = gh->getSpellIconPath(item.itemId); + if (info && info->displayInfoId != 0) { + icon = gh->getItemIconPath(info->displayInfoId); } if (!icon.empty()) lua_pushstring(L, icon.c_str()); else lua_pushnil(L); @@ -1883,8 +1889,16 @@ static int lua_GetActionTexture(lua_State* L) { lua_pushstring(L, icon.c_str()); return 1; } + } else if (action.type == game::ActionBarSlot::ITEM && action.id != 0) { + const auto* info = gh->getItemInfo(action.id); + if (info && info->displayInfoId != 0) { + std::string icon = gh->getItemIconPath(info->displayInfoId); + if (!icon.empty()) { + lua_pushstring(L, icon.c_str()); + return 1; + } + } } - // For items we don't have icon resolution yet (needs ItemDisplayInfo DBC) lua_pushnil(L); return 1; } diff --git a/src/core/application.cpp b/src/core/application.cpp index 91d6d619..c007c09c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -413,6 +413,32 @@ bool Application::initialize() { return pit->second; }); } + // Wire item icon path resolver: displayInfoId -> "Interface\\Icons\\INV_..." + { + auto iconNames = std::make_shared>(); + auto loaded = std::make_shared(false); + auto* am = assetManager.get(); + gameHandler->setItemIconPathResolver([iconNames, loaded, am](uint32_t displayInfoId) -> std::string { + if (!am || displayInfoId == 0) return {}; + if (!*loaded) { + *loaded = true; + auto dbc = am->loadDBC("ItemDisplayInfo.dbc"); + const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + if (dbc && dbc->isLoaded()) { + uint32_t iconField = dispL ? (*dispL)["InventoryIcon"] : 5; + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); // field 0 = ID + std::string name = dbc->getString(i, iconField); + if (id > 0 && !name.empty()) (*iconNames)[id] = name; + } + LOG_INFO("Loaded ", iconNames->size(), " item icon names from ItemDisplayInfo.dbc"); + } + } + auto it = iconNames->find(displayInfoId); + if (it == iconNames->end()) return {}; + return "Interface\\Icons\\" + it->second; + }); + } // Wire random property/suffix name resolver for item display { auto propNames = std::make_shared>(); From 3b8165cbef7af68b81963b26605f98375b4912d8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:57:00 -0700 Subject: [PATCH 23/74] feat: fire events for loot rolls, trade windows, and duels Add missing addon events for three gameplay systems: Loot rolls: - START_LOOT_ROLL fires on SMSG_LOOT_START_ROLL with slot and countdown - LOOT_SLOT_CLEARED fires when a loot item is removed (SMSG_LOOT_REMOVED) Trade: - TRADE_REQUEST when another player initiates a trade - TRADE_SHOW when the trade window opens - TRADE_CLOSED when trade is cancelled, declined, or completed - TRADE_ACCEPT_UPDATE when the trade partner accepts Duels: - DUEL_REQUESTED with challenger name on incoming duel challenge - DUEL_FINISHED when a duel completes or is cancelled --- 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 f8c71576..cf25f82b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2420,6 +2420,8 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec); + if (addonEventCallback_) + addonEventCallback_("START_LOOT_ROLL", {std::to_string(slot), std::to_string(countdown)}); break; } @@ -14261,6 +14263,7 @@ void GameHandler::handleDuelRequested(network::Packet& packet) { } LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_, " flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_); + if (addonEventCallback_) addonEventCallback_("DUEL_REQUESTED", {duelChallengerName_}); } void GameHandler::handleDuelComplete(network::Packet& packet) { @@ -14273,6 +14276,7 @@ void GameHandler::handleDuelComplete(network::Packet& packet) { addSystemChatMessage("The duel was cancelled."); } LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast(started)); + if (addonEventCallback_) addonEventCallback_("DUEL_FINISHED", {}); } void GameHandler::handleDuelWinner(network::Packet& packet) { @@ -22280,6 +22284,8 @@ void GameHandler::handleLootRemoved(network::Packet& packet) { sfx->playLootItem(); } currentLoot.items.erase(it); + if (addonEventCallback_) + addonEventCallback_("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)}); break; } } @@ -25749,6 +25755,7 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { } tradeStatus_ = TradeStatus::PendingIncoming; addSystemChatMessage(tradePeerName_ + " wants to trade with you."); + if (addonEventCallback_) addonEventCallback_("TRADE_REQUEST", {}); break; } case 2: // OPEN_WINDOW @@ -25758,22 +25765,27 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { peerTradeGold_ = 0; tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); + if (addonEventCallback_) addonEventCallback_("TRADE_SHOW", {}); break; case 3: // CANCELLED case 12: // CLOSE_WINDOW resetTradeState(); addSystemChatMessage("Trade cancelled."); + if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); break; case 9: // REJECTED — other player clicked Decline resetTradeState(); addSystemChatMessage("Trade declined."); + if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); break; case 4: // ACCEPTED (partner accepted) tradeStatus_ = TradeStatus::Accepted; addSystemChatMessage("Trade accepted. Awaiting other player..."); + if (addonEventCallback_) addonEventCallback_("TRADE_ACCEPT_UPDATE", {}); break; case 8: // COMPLETE addSystemChatMessage("Trade complete!"); + if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); resetTradeState(); break; case 7: // BACK_TO_TRADE (unaccepted after a change) From 8f2a2dfbb4068a59a0160ccbedbf60ac6beaf859 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 02:58:55 -0700 Subject: [PATCH 24/74] feat: fire UNIT_NAME_UPDATE event when player names are resolved Fire UNIT_NAME_UPDATE for target/focus/player when SMSG_NAME_QUERY_RESPONSE resolves a player's name. Nameplate and unit frame addons use this event to update displayed names when they become available asynchronously. --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index cf25f82b..757cc379 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14841,6 +14841,16 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { if (friendGuids_.count(data.guid)) { friendsCache[data.name] = data.guid; } + + // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available + if (addonEventCallback_) { + std::string unitId; + if (data.guid == targetGuid) unitId = "target"; + else if (data.guid == focusGuid) unitId = "focus"; + else if (data.guid == playerGuid) unitId = "player"; + if (!unitId.empty()) + addonEventCallback_("UNIT_NAME_UPDATE", {unitId}); + } } } From b8d92b5ff261291e2c1c2aa860983831e389b648 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:01:55 -0700 Subject: [PATCH 25/74] feat: fire FRIENDLIST_UPDATE and IGNORELIST_UPDATE events Fire FRIENDLIST_UPDATE from all three friend list packet handlers: - SMSG_FRIEND_LIST (Classic format) - SMSG_CONTACT_LIST (WotLK format) - SMSG_FRIEND_STATUS (add/remove/online/offline updates) Fire IGNORELIST_UPDATE when SMSG_CONTACT_LIST includes ignore entries. These events are used by social addons to refresh their UI when the friend/ignore list changes. --- 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 757cc379..b3a681a3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -24215,6 +24215,7 @@ void GameHandler::handleFriendList(network::Packet& packet) { entry.classId = classId; contacts_.push_back(std::move(entry)); } + if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {}); } void GameHandler::handleContactList(network::Packet& packet) { @@ -24278,6 +24279,11 @@ void GameHandler::handleContactList(network::Packet& packet) { } LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, " count=", lastContactListCount_); + if (addonEventCallback_) { + addonEventCallback_("FRIENDLIST_UPDATE", {}); + if (lastContactListMask_ & 0x2) // ignore list + addonEventCallback_("IGNORELIST_UPDATE", {}); + } } void GameHandler::handleFriendStatus(network::Packet& packet) { @@ -24361,6 +24367,7 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { } LOG_INFO("Friend status update: ", playerName, " status=", (int)data.status); + if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {}); } void GameHandler::handleRandomRoll(network::Packet& packet) { From b7e5034f27821c9a9806ffd5ce5e3bd0ef6dc08a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:04:59 -0700 Subject: [PATCH 26/74] feat: fire GUILD_ROSTER_UPDATE and GUILD_MOTD events for guild addons Fire GUILD_ROSTER_UPDATE from SMSG_GUILD_ROSTER and from guild events (member join/leave/kick, promotions, leader changes, online/offline, disbanded). Fire GUILD_MOTD with the MOTD text when received. These events are needed by guild management addons (GuildGreet, GuildRoster replacements, officer tools) to refresh their UI. --- src/game/game_handler.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b3a681a3..d5c7d364 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20545,6 +20545,7 @@ void GameHandler::handleGuildRoster(network::Packet& packet) { guildRoster_ = std::move(data); hasGuildRoster_ = true; LOG_INFO("Guild roster received: ", guildRoster_.members.size(), " members"); + if (addonEventCallback_) addonEventCallback_("GUILD_ROSTER_UPDATE", {}); } void GameHandler::handleGuildQueryResponse(network::Packet& packet) { @@ -20643,6 +20644,28 @@ void GameHandler::handleGuildEvent(network::Packet& packet) { addLocalChatMessage(chatMsg); } + // Fire addon events for guild state changes + if (addonEventCallback_) { + switch (data.eventType) { + case GuildEvent::MOTD: + addonEventCallback_("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""}); + break; + case GuildEvent::SIGNED_ON: + case GuildEvent::SIGNED_OFF: + case GuildEvent::PROMOTION: + case GuildEvent::DEMOTION: + case GuildEvent::JOINED: + case GuildEvent::LEFT: + case GuildEvent::REMOVED: + case GuildEvent::LEADER_CHANGED: + case GuildEvent::DISBANDED: + addonEventCallback_("GUILD_ROSTER_UPDATE", {}); + break; + default: + break; + } + } + // Auto-refresh roster after membership/rank changes switch (data.eventType) { case GuildEvent::PROMOTION: From 4f4c169825c54e91a98a5f04bc07884b34d3e08e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:08:37 -0700 Subject: [PATCH 27/74] feat: add GetNumFriends/GetFriendInfo/GetNumIgnores/GetIgnoreName API Implement friend and ignore list query functions for social addons: - GetNumFriends() returns friend count from contacts list - GetFriendInfo(index) returns 7-value tuple: name, level, class, area, connected, status (AFK/DND), note - GetNumIgnores() returns ignore count - GetIgnoreName(index) returns ignored player's name Data sourced from the contacts list populated by SMSG_FRIEND_LIST and SMSG_CONTACT_LIST. Area names resolved from AreaTable.dbc. --- src/addons/lua_engine.cpp | 75 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 3dad867b..9b6137e4 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1241,6 +1241,76 @@ static int lua_GetSkillLineInfo(lua_State* L) { return 12; } +// --- Friends/Ignore API --- + +// GetNumFriends() → count +static int lua_GetNumFriends(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int count = 0; + for (const auto& c : gh->getContacts()) + if (c.isFriend()) count++; + lua_pushnumber(L, count); + return 1; +} + +// GetFriendInfo(index) → name, level, class, area, connected, status, note +static int lua_GetFriendInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { + lua_pushnil(L); return 1; + } + int found = 0; + for (const auto& c : gh->getContacts()) { + if (!c.isFriend()) continue; + if (++found == index) { + lua_pushstring(L, c.name.c_str()); // 1: name + lua_pushnumber(L, c.level); // 2: level + static const char* kClasses[] = {"","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + lua_pushstring(L, c.classId < 12 ? kClasses[c.classId] : "Unknown"); // 3: class + std::string area; + if (c.areaId != 0) area = gh->getWhoAreaName(c.areaId); + lua_pushstring(L, area.c_str()); // 4: area + lua_pushboolean(L, c.isOnline()); // 5: connected + lua_pushstring(L, c.status == 2 ? "" : (c.status == 3 ? "" : "")); // 6: status + lua_pushstring(L, c.note.c_str()); // 7: note + return 7; + } + } + lua_pushnil(L); + return 1; +} + +// GetNumIgnores() → count +static int lua_GetNumIgnores(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int count = 0; + for (const auto& c : gh->getContacts()) + if (c.isIgnored()) count++; + lua_pushnumber(L, count); + return 1; +} + +// GetIgnoreName(index) → name +static int lua_GetIgnoreName(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + int found = 0; + for (const auto& c : gh->getContacts()) { + if (!c.isIgnored()) continue; + if (++found == index) { + lua_pushstring(L, c.name.c_str()); + return 1; + } + } + lua_pushnil(L); + return 1; +} + // --- Talent API --- // GetNumTalentTabs() → count (usually 3) @@ -2619,6 +2689,11 @@ void LuaEngine::registerCoreAPI() { {"GetNumTalents", lua_GetNumTalents}, {"GetTalentInfo", lua_GetTalentInfo}, {"GetActiveTalentGroup", lua_GetActiveTalentGroup}, + // Friends/ignore API + {"GetNumFriends", lua_GetNumFriends}, + {"GetFriendInfo", lua_GetFriendInfo}, + {"GetNumIgnores", lua_GetNumIgnores}, + {"GetIgnoreName", lua_GetIgnoreName}, // Reaction/connection queries {"UnitReaction", lua_UnitReaction}, {"UnitIsConnected", lua_UnitIsConnected}, From a39acd71babf74a2ea0f0fe57a12ff99509bb54f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:14:57 -0700 Subject: [PATCH 28/74] feat: apply M2 color alpha and transparency tracks to batch opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply at-rest values from M2 color alpha and transparency animation tracks to batch rendering opacity. This fixes models that should render as semi-transparent (ghosts, ethereal effects, fading doodads) but were previously rendering at full opacity. The fix multiplies colorAlphas[batch.colorIndex] and textureWeights[batch.transparencyIndex] into batchOpacity during model setup. Zero values are skipped to avoid the edge case where animated tracks start at 0 (invisible) and animate up — baking that first keyframe would make the entire batch permanently invisible. --- src/rendering/m2_renderer.cpp | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 365bf7c3..f711f542 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1579,12 +1579,26 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // since we don't have the full combo table — dual-UV effects are rare edge cases. bgpu.textureUnit = 0; - // Batch is hidden only when its named texture failed to load (avoids white shell artifacts). - // Do NOT bake transparency/color animation tracks here — they animate over time and - // baking the first keyframe value causes legitimate meshes to become invisible. - // Keep terrain clutter visible even when source texture paths are malformed. + // Start at full opacity; hide only if texture failed to load. bgpu.batchOpacity = (texFailed && !groundDetailModel) ? 0.0f : 1.0f; + // Apply at-rest transparency and color alpha from the M2 animation tracks. + // These provide per-batch opacity for ghosts, ethereal effects, fading doodads, etc. + // Skip zero values: some animated tracks start at 0 and animate up, and baking + // that first keyframe would make the entire batch permanently invisible. + if (bgpu.batchOpacity > 0.0f) { + float animAlpha = 1.0f; + if (batch.colorIndex < model.colorAlphas.size()) { + float ca = model.colorAlphas[batch.colorIndex]; + if (ca > 0.001f) animAlpha *= ca; + } + if (batch.transparencyIndex < model.textureWeights.size()) { + float tw = model.textureWeights[batch.transparencyIndex]; + if (tw > 0.001f) animAlpha *= tw; + } + bgpu.batchOpacity *= animAlpha; + } + // Compute batch center and radius for glow sprite positioning if ((bgpu.blendMode >= 3 || bgpu.colorKeyBlack) && batch.indexCount > 0) { glm::vec3 sum(0.0f); From e64f9f4585a57d636e0cef26f49f1b9ee4d94d44 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:24:23 -0700 Subject: [PATCH 29/74] fix: add mail, auction, quest, and trade windows to Escape key chain The Escape key now properly closes these windows before showing the escape menu: - Mail window (closeMailbox) - Auction house (closeAuctionHouse) - Quest details dialog (declineQuest) - Quest offer reward dialog (closeQuestOfferReward) - Quest request items dialog (closeQuestRequestItems) - Trade window (cancelTrade) Previously these windows required clicking their close button since Escape would skip directly to the escape menu. --- src/ui/game_screen.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 18d0a6ec..2884d85d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2787,6 +2787,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.closeBank(); } else if (gameHandler.isTrainerWindowOpen()) { gameHandler.closeTrainer(); + } else if (gameHandler.isMailboxOpen()) { + gameHandler.closeMailbox(); + } else if (gameHandler.isAuctionHouseOpen()) { + gameHandler.closeAuctionHouse(); + } else if (gameHandler.isQuestDetailsOpen()) { + gameHandler.declineQuest(); + } else if (gameHandler.isQuestOfferRewardOpen()) { + gameHandler.closeQuestOfferReward(); + } else if (gameHandler.isQuestRequestItemsOpen()) { + gameHandler.closeQuestRequestItems(); + } else if (gameHandler.isTradeOpen()) { + gameHandler.cancelTrade(); } else if (showWhoWindow_) { showWhoWindow_ = false; } else if (showCombatLog_) { From b2826ce5898bfb039c09197ccc60bce7d6a58e7e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:27:09 -0700 Subject: [PATCH 30/74] feat: fire PLAYER_UPDATE_RESTING event on rest state changes Fire PLAYER_UPDATE_RESTING when the player enters or leaves a resting area (inn/capital city). Fires from both the SET_REST_START packet and the QUEST_FORCE_REMOVE rest-state update path. Used by XP bar addons and rest state indicator addons. --- 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 d5c7d364..7971a6c2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5564,6 +5564,8 @@ void GameHandler::handlePacket(network::Packet& packet) { isResting_ = nowResting; addSystemChatMessage(isResting_ ? "You are now resting." : "You are no longer resting."); + if (addonEventCallback_) + addonEventCallback_("PLAYER_UPDATE_RESTING", {}); } break; } @@ -6433,6 +6435,8 @@ void GameHandler::handlePacket(network::Packet& packet) { isResting_ = (restTrigger > 0); addSystemChatMessage(isResting_ ? "You are now resting." : "You are no longer resting."); + if (addonEventCallback_) + addonEventCallback_("PLAYER_UPDATE_RESTING", {}); } break; } From 7807058f9c9da5234e51682b8975f615979a7f23 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:31:54 -0700 Subject: [PATCH 31/74] feat: add SendAddonMessage and RegisterAddonMessagePrefix for addon comms Implement the addon messaging API used by virtually every multiplayer addon (DBM, BigWigs, EPGP, RC Loot Council, WeakAuras, etc.): - SendAddonMessage(prefix, text, chatType, target) sends an addon message encoded as "prefix\ttext" via the appropriate chat channel - RegisterAddonMessagePrefix(prefix) registers a prefix for filtering incoming addon messages - IsAddonMessagePrefixRegistered(prefix) checks registration status - C_ChatInfo table with aliases for the above functions (newer API compat) Without these functions, all inter-addon communication between players fails, breaking boss mods, loot distribution, and group coordination. --- 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 9b6137e4..968fdc9b 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -535,6 +535,65 @@ static int lua_CastSpellByName(lua_State* L) { return 0; } +// SendAddonMessage(prefix, text, chatType, target) — send addon message +static int lua_SendAddonMessage(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* prefix = luaL_checkstring(L, 1); + const char* text = luaL_checkstring(L, 2); + const char* chatType = luaL_optstring(L, 3, "PARTY"); + const char* target = luaL_optstring(L, 4, ""); + + // Build addon message: prefix + TAB + text, send via the appropriate channel + std::string typeStr(chatType); + for (char& c : typeStr) c = static_cast(std::toupper(static_cast(c))); + + game::ChatType ct = game::ChatType::PARTY; + if (typeStr == "PARTY") ct = game::ChatType::PARTY; + else if (typeStr == "RAID") ct = game::ChatType::RAID; + else if (typeStr == "GUILD") ct = game::ChatType::GUILD; + else if (typeStr == "OFFICER") ct = game::ChatType::OFFICER; + else if (typeStr == "BATTLEGROUND") ct = game::ChatType::BATTLEGROUND; + else if (typeStr == "WHISPER") ct = game::ChatType::WHISPER; + + // Encode as prefix\ttext (WoW addon message format) + std::string encoded = std::string(prefix) + "\t" + text; + std::string targetStr(target && *target ? target : ""); + gh->sendChatMessage(ct, encoded, targetStr); + return 0; +} + +// RegisterAddonMessagePrefix(prefix) — register prefix for receiving addon messages +static int lua_RegisterAddonMessagePrefix(lua_State* L) { + const char* prefix = luaL_checkstring(L, 1); + // Store in a global Lua table for filtering + lua_getglobal(L, "__WoweeAddonPrefixes"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setglobal(L, "__WoweeAddonPrefixes"); + } + lua_pushboolean(L, 1); + lua_setfield(L, -2, prefix); + lua_pop(L, 1); + lua_pushboolean(L, 1); // success + return 1; +} + +// IsAddonMessagePrefixRegistered(prefix) → boolean +static int lua_IsAddonMessagePrefixRegistered(lua_State* L) { + const char* prefix = luaL_checkstring(L, 1); + lua_getglobal(L, "__WoweeAddonPrefixes"); + if (lua_istable(L, -1)) { + lua_getfield(L, -1, prefix); + lua_pushboolean(L, lua_toboolean(L, -1)); + return 1; + } + lua_pushboolean(L, 0); + return 1; +} + static int lua_IsSpellKnown(lua_State* L) { auto* gh = getGameHandler(L); uint32_t spellId = static_cast(luaL_checknumber(L, 1)); @@ -2600,6 +2659,9 @@ void LuaEngine::registerCoreAPI() { {"IsInRaid", lua_IsInRaid}, {"GetPlayerMapPosition", lua_GetPlayerMapPosition}, {"SendChatMessage", lua_SendChatMessage}, + {"SendAddonMessage", lua_SendAddonMessage}, + {"RegisterAddonMessagePrefix", lua_RegisterAddonMessagePrefix}, + {"IsAddonMessagePrefixRegistered", lua_IsAddonMessagePrefixRegistered}, {"CastSpellByName", lua_CastSpellByName}, {"IsSpellKnown", lua_IsSpellKnown}, {"GetSpellCooldown", lua_GetSpellCooldown}, @@ -3130,6 +3192,11 @@ void LuaEngine::registerCoreAPI() { "HIGHLIGHT_FONT_COLOR = {r=1.0,g=1.0,b=1.0}\n" "GREEN_FONT_COLOR = {r=0.1,g=1.0,b=0.1}\n" "RED_FONT_COLOR = {r=1.0,g=0.1,b=0.1}\n" + // C_ChatInfo — addon message prefix API used by some addons + "C_ChatInfo = C_ChatInfo or {}\n" + "C_ChatInfo.RegisterAddonMessagePrefix = RegisterAddonMessagePrefix\n" + "C_ChatInfo.IsAddonMessagePrefixRegistered = IsAddonMessagePrefixRegistered\n" + "C_ChatInfo.SendAddonMessage = SendAddonMessage\n" ); // WoW table/string utility functions used by many addons From a63f980e022d80e0d134825273d4f879addb60a3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:34:31 -0700 Subject: [PATCH 32/74] feat: add guild roster Lua API for guild management addons Implement 5 guild-related WoW Lua API functions: - IsInGuild() returns whether the player is in a guild - GetGuildInfo("player") returns guildName, rankName, rankIndex - GetNumGuildMembers() returns totalMembers, onlineMembers - GetGuildRosterInfo(index) returns full 11-value tuple: name, rank, rankIndex, level, class, zone, note, officerNote, online, status, classId - GetGuildRosterMOTD() returns the guild message of the day Data sourced from SMSG_GUILD_ROSTER and SMSG_GUILD_QUERY_RESPONSE. Enables guild management addons (GreenWall, officer tools, roster UIs). --- src/addons/lua_engine.cpp | 87 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 968fdc9b..15482e0f 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1342,6 +1342,87 @@ static int lua_GetFriendInfo(lua_State* L) { return 1; } +// --- Guild API --- + +// IsInGuild() → boolean +static int lua_IsInGuild(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isInGuild()); + return 1; +} + +// GetGuildInfo("player") → guildName, guildRankName, guildRankIndex +static int lua_GetGuildInfoFunc(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh || !gh->isInGuild()) { lua_pushnil(L); return 1; } + lua_pushstring(L, gh->getGuildName().c_str()); + // Get rank name for the player + const auto& roster = gh->getGuildRoster(); + std::string rankName; + uint32_t rankIndex = 0; + for (const auto& m : roster.members) { + if (m.guid == gh->getPlayerGuid()) { + rankIndex = m.rankIndex; + const auto& rankNames = gh->getGuildRankNames(); + if (rankIndex < rankNames.size()) rankName = rankNames[rankIndex]; + break; + } + } + lua_pushstring(L, rankName.c_str()); + lua_pushnumber(L, rankIndex); + return 3; +} + +// GetNumGuildMembers() → totalMembers, onlineMembers +static int lua_GetNumGuildMembers(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + const auto& roster = gh->getGuildRoster(); + int online = 0; + for (const auto& m : roster.members) + if (m.online) online++; + lua_pushnumber(L, roster.members.size()); + lua_pushnumber(L, online); + return 2; +} + +// GetGuildRosterInfo(index) → name, rank, rankIndex, level, class, zone, note, officerNote, online, status, classId +static int lua_GetGuildRosterInfo(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& roster = gh->getGuildRoster(); + if (index > static_cast(roster.members.size())) { lua_pushnil(L); return 1; } + const auto& m = roster.members[index - 1]; + + lua_pushstring(L, m.name.c_str()); // 1: name + const auto& rankNames = gh->getGuildRankNames(); + lua_pushstring(L, m.rankIndex < rankNames.size() + ? rankNames[m.rankIndex].c_str() : ""); // 2: rank name + lua_pushnumber(L, m.rankIndex); // 3: rankIndex + lua_pushnumber(L, m.level); // 4: level + static const char* kCls[] = {"","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + lua_pushstring(L, m.classId < 12 ? kCls[m.classId] : "Unknown"); // 5: class + std::string zone; + if (m.zoneId != 0 && m.online) zone = gh->getWhoAreaName(m.zoneId); + lua_pushstring(L, zone.c_str()); // 6: zone + lua_pushstring(L, m.publicNote.c_str()); // 7: note + lua_pushstring(L, m.officerNote.c_str()); // 8: officerNote + lua_pushboolean(L, m.online); // 9: online + lua_pushnumber(L, 0); // 10: status (0=online, 1=AFK, 2=DND) + lua_pushnumber(L, m.classId); // 11: classId (numeric) + return 11; +} + +// GetGuildRosterMOTD() → motd +static int lua_GetGuildRosterMOTD(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, ""); return 1; } + lua_pushstring(L, gh->getGuildRoster().motd.c_str()); + return 1; +} + // GetNumIgnores() → count static int lua_GetNumIgnores(lua_State* L) { auto* gh = getGameHandler(L); @@ -2752,6 +2833,12 @@ void LuaEngine::registerCoreAPI() { {"GetTalentInfo", lua_GetTalentInfo}, {"GetActiveTalentGroup", lua_GetActiveTalentGroup}, // Friends/ignore API + // Guild API + {"IsInGuild", lua_IsInGuild}, + {"GetGuildInfo", lua_GetGuildInfoFunc}, + {"GetNumGuildMembers", lua_GetNumGuildMembers}, + {"GetGuildRosterInfo", lua_GetGuildRosterInfo}, + {"GetGuildRosterMOTD", lua_GetGuildRosterMOTD}, {"GetNumFriends", lua_GetNumFriends}, {"GetFriendInfo", lua_GetFriendInfo}, {"GetNumIgnores", lua_GetNumIgnores}, From 0d49cc8b9424b9b2b22024f01a99a6a7af1b431e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:38:17 -0700 Subject: [PATCH 33/74] fix: handle NPC facing-only rotation in SMSG_MONSTER_MOVE Fix bug where NPCs receiving moveType=4 (FacingAngle) or moveType=3 (FacingTarget) monster move packets with zero waypoints would not rotate in place. The handler only processed orientation when hasDest was true, but facing-only updates have no destination waypoints. Now NPCs properly rotate when: - moveType=4: server specifies an exact facing angle (e.g., NPC turns to face the player during dialogue or scripted events) - moveType=3: NPC should face a specific target entity This fixes NPCs appearing frozen/unresponsive during scripted events, quest interactions, and patrol waypoint facing changes. --- src/game/game_handler.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7971a6c2..60706f69 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18364,6 +18364,27 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { creatureMoveCallback_(data.guid, posCanonical.x, posCanonical.y, posCanonical.z, 0); } + } else if (data.moveType == 4) { + // FacingAngle without movement — rotate NPC in place + float orientation = core::coords::serverToCanonicalYaw(data.facingAngle); + glm::vec3 posCanonical = core::coords::serverToCanonical( + glm::vec3(data.x, data.y, data.z)); + entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, orientation); + if (creatureMoveCallback_) { + creatureMoveCallback_(data.guid, + posCanonical.x, posCanonical.y, posCanonical.z, 0); + } + } else if (data.moveType == 3 && data.facingTarget != 0) { + // FacingTarget without movement — rotate NPC to face a target + auto target = entityManager.getEntity(data.facingTarget); + if (target) { + float dx = target->getX() - entity->getX(); + float dy = target->getY() - entity->getY(); + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { + float orientation = std::atan2(-dy, dx); + entity->setOrientation(orientation); + } + } } } From 8229a963d18760c54b1c3fd9e42b249b737be56c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:49:02 -0700 Subject: [PATCH 34/74] feat: add player name tab-completion in chat input When typing commands like /w, /whisper, /invite, /trade, /duel, /follow, /inspect, etc., pressing Tab now cycles through matching player names. Name sources (in priority order): 1. Last whisper sender (most likely target for /r follow-ups) 2. Party/raid members 3. Friends list 4. Nearby visible players Tab cycles through all matches; single match auto-appends a space. Complements the existing slash-command tab-completion. --- include/ui/game_screen.hpp | 5 +- src/ui/game_screen.cpp | 102 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5391978f..bf8ac8b5 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -62,7 +62,10 @@ private: // Populated by the SpellCastFailedCallback; queried during action bar button rendering. std::unordered_map actionFlashEndTimes_; - // Tab-completion state for slash commands + // Cached game handler for input callbacks (set each frame in render) + game::GameHandler* cachedGameHandler_ = nullptr; + + // Tab-completion state for slash commands and player names std::string chatTabPrefix_; // prefix captured on first Tab press std::vector chatTabMatches_; // matching command list int chatTabMatchIdx_ = -1; // active match index (-1 = inactive) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2884d85d..4daf51ff 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -268,6 +268,7 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, static std::string getMacroShowtooltipArg(const std::string& macroText); void GameScreen::render(game::GameHandler& gameHandler) { + cachedGameHandler_ = &gameHandler; // Set up chat bubble callback (once) if (!chatBubbleCallbackSet_) { gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { @@ -2674,6 +2675,107 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, newBuf.c_str()); } + } else if (data->BufTextLen > 0) { + // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel + // Also works for plain text (completes nearby player names) + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + bool isNameCommand = false; + std::string namePrefix; + size_t replaceStart = 0; + + if (fullBuf[0] == '/' && spacePos != std::string::npos) { + std::string cmd = fullBuf.substr(0, spacePos); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + // Commands that take a player name as the first argument after the command + if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || + cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || + cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || + cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || + cmd == "/t" || cmd == "/target" || cmd == "/kick" || + cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { + // Extract the partial name after the space + namePrefix = fullBuf.substr(spacePos + 1); + // Only complete the first word after the command + size_t nameSpace = namePrefix.find(' '); + if (nameSpace == std::string::npos) { + isNameCommand = true; + replaceStart = spacePos + 1; + } + } + } + + if (isNameCommand && !namePrefix.empty()) { + std::string lowerPrefix = namePrefix; + for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); + + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerPrefix) { + self->chatTabPrefix_ = lowerPrefix; + self->chatTabMatches_.clear(); + // Search player name cache and nearby entities + auto* gh = self->cachedGameHandler_; + // Party/raid members + for (const auto& m : gh->getPartyData().members) { + if (m.name.empty()) continue; + std::string lname = m.name; + for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) + self->chatTabMatches_.push_back(m.name); + } + // Friends + for (const auto& c : gh->getContacts()) { + if (!c.isFriend() || c.name.empty()) continue; + std::string lname = c.name; + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + // Avoid duplicates from party + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == c.name) { dup = true; break; } + if (!dup) self->chatTabMatches_.push_back(c.name); + } + } + // Nearby visible players + for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) continue; + std::string lname = player->getName(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == player->getName()) { dup = true; break; } + if (!dup) self->chatTabMatches_.push_back(player->getName()); + } + } + // Last whisper sender + if (!gh->getLastWhisperSender().empty()) { + std::string lname = gh->getLastWhisperSender(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == gh->getLastWhisperSender()) { dup = true; break; } + if (!dup) self->chatTabMatches_.insert(self->chatTabMatches_.begin(), gh->getLastWhisperSender()); + } + } + self->chatTabMatchIdx_ = 0; + } else { + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + std::string prefix = fullBuf.substr(0, replaceStart); + std::string newBuf = prefix + match; + if (self->chatTabMatches_.size() == 1) newBuf += ' '; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } } return 0; } From ec082e029ce04058daddca4bf16afec10698d59b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:55:23 -0700 Subject: [PATCH 35/74] fix: UnitClass and UnitRace now work for target, focus, party, and all units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously UnitClass() only returned the correct class for "player" and returned "Unknown" for all other units (target, focus, party1-4, etc.). UnitRace() had the same bug. Now both functions read UNIT_FIELD_BYTES_0 from the entity's update fields to resolve class (byte 1) and race (byte 0) for any unit. This fixes unit frame addons, class-colored names, and race-based logic for all unit IDs. Also fix UnitRace to return 3 values (localized, English, raceId) to match WoW's API signature — previously it only returned 1. --- src/addons/lua_engine.cpp | 56 +++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 15482e0f..4634aee4 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -201,11 +201,32 @@ static int lua_UnitClass(lua_State* L) { static const char* kClasses[] = {"", "Warrior","Paladin","Hunter","Rogue","Priest", "Death Knight","Shaman","Mage","Warlock","","Druid"}; uint8_t classId = 0; - // For player, use character data; for others, use UNIT_FIELD_BYTES_0 std::string uidStr(uid); for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); - if (uidStr == "player") classId = gh->getPlayerClass(); - const char* name = (classId < 12) ? kClasses[classId] : "Unknown"; + if (uidStr == "player") { + classId = gh->getPlayerClass(); + } else { + // Read class from UNIT_FIELD_BYTES_0 (class is byte 1) + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t bytes0 = entity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + classId = static_cast((bytes0 >> 8) & 0xFF); + } + } + // Fallback: check party/raid member data + if (classId == 0 && guid != 0) { + for (const auto& m : gh->getPartyData().members) { + if (m.guid == guid && m.hasPartyStats) { + // Party stats don't have class, but check guild roster + break; + } + } + } + } + const char* name = (classId > 0 && classId < 12) ? kClasses[classId] : "Unknown"; lua_pushstring(L, name); lua_pushstring(L, name); // WoW returns localized + English lua_pushnumber(L, classId); @@ -252,18 +273,31 @@ static int lua_GetPlayerMapPosition(lua_State* L) { static int lua_UnitRace(lua_State* L) { auto* gh = getGameHandler(L); - if (!gh) { lua_pushstring(L, "Unknown"); return 1; } + if (!gh) { lua_pushstring(L, "Unknown"); lua_pushstring(L, "Unknown"); lua_pushnumber(L, 0); return 3; } std::string uid(luaL_optstring(L, 1, "player")); for (char& c : uid) c = static_cast(std::tolower(static_cast(c))); + static const char* kRaces[] = {"","Human","Orc","Dwarf","Night Elf","Undead", + "Tauren","Gnome","Troll","","Blood Elf","Draenei"}; + uint8_t raceId = 0; if (uid == "player") { - uint8_t race = gh->getPlayerRace(); - static const char* kRaces[] = {"","Human","Orc","Dwarf","Night Elf","Undead", - "Tauren","Gnome","Troll","","Blood Elf","Draenei"}; - lua_pushstring(L, (race < 12) ? kRaces[race] : "Unknown"); - return 1; + raceId = gh->getPlayerRace(); + } else { + // Read race from UNIT_FIELD_BYTES_0 (race is byte 0) + uint64_t guid = resolveUnitGuid(gh, uid); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t bytes0 = entity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + raceId = static_cast(bytes0 & 0xFF); + } + } } - lua_pushstring(L, "Unknown"); - return 1; + const char* name = (raceId > 0 && raceId < 12) ? kRaces[raceId] : "Unknown"; + lua_pushstring(L, name); // 1: localized race + lua_pushstring(L, name); // 2: English race + lua_pushnumber(L, raceId); // 3: raceId (WoW returns 3 values) + return 3; } static int lua_UnitPowerType(lua_State* L) { From 61b54cfa7413f2342a12b4fc0c2378c2d68a8909 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 03:59:04 -0700 Subject: [PATCH 36/74] feat: add unit state query functions and fix UnitAffectingCombat Add 6 commonly needed unit state functions: - UnitIsGhost(unit) checks ghost flag from UNIT_FIELD_FLAGS - UnitIsDeadOrGhost(unit) combines dead + ghost checks - UnitIsAFK(unit) / UnitIsDND(unit) check player flags - UnitPlayerControlled(unit) true for players and player pets - UnitSex(unit) reads gender from UNIT_FIELD_BYTES_0 byte 2 Fix UnitAffectingCombat to check UNIT_FLAG_IN_COMBAT (0x00080000) from entity update fields for any unit, not just "player". Previously returned false for all non-player units. These functions are needed by unit frame addons (SUF, Pitbull, oUF) to properly display ghost state, AFK/DND status, and combat state. --- src/addons/lua_engine.cpp | 142 +++++++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 4634aee4..01af7fd5 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -238,6 +238,129 @@ static int lua_UnitClass(lua_State* L) { return 3; } +// UnitIsGhost(unit) — true if unit is in ghost form +static int lua_UnitIsGhost(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") { + lua_pushboolean(L, gh->isPlayerGhost()); + } else { + // Check UNIT_FIELD_FLAGS for UNIT_FLAG_GHOST (0x00000100) — best approximation + uint64_t guid = resolveUnitGuid(gh, uidStr); + bool ghost = false; + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t flags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + ghost = (flags & 0x00000100) != 0; // PLAYER_FLAGS_GHOST + } + } + lua_pushboolean(L, ghost); + } + return 1; +} + +// UnitIsDeadOrGhost(unit) +static int lua_UnitIsDeadOrGhost(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + auto* gh = getGameHandler(L); + bool dead = (unit && unit->getHealth() == 0); + if (!dead && gh) { + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") dead = gh->isPlayerGhost() || gh->isPlayerDead(); + } + lua_pushboolean(L, dead); + return 1; +} + +// UnitIsAFK(unit), UnitIsDND(unit) +static int lua_UnitIsAFK(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + 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) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + // PLAYER_FLAGS at UNIT_FIELD_FLAGS: PLAYER_FLAGS_AFK = 0x01 + uint32_t playerFlags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + lua_pushboolean(L, (playerFlags & 0x01) != 0); + return 1; + } + } + lua_pushboolean(L, 0); + return 1; +} + +static int lua_UnitIsDND(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + 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) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t playerFlags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + lua_pushboolean(L, (playerFlags & 0x02) != 0); // PLAYER_FLAGS_DND + return 1; + } + } + lua_pushboolean(L, 0); + return 1; +} + +// UnitPlayerControlled(unit) — true for players and player-controlled pets +static int lua_UnitPlayerControlled(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + 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_pushboolean(L, 0); return 1; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity) { lua_pushboolean(L, 0); return 1; } + // Players are always player-controlled; pets check UNIT_FLAG_PLAYER_CONTROLLED (0x01000000) + if (entity->getType() == game::ObjectType::PLAYER) { + lua_pushboolean(L, 1); + } else { + uint32_t flags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + lua_pushboolean(L, (flags & 0x01000000) != 0); + } + return 1; +} + +// UnitSex(unit) → 1=unknown, 2=male, 3=female +static int lua_UnitSex(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 uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + // Gender is byte 2 of UNIT_FIELD_BYTES_0 (0=male, 1=female) + uint32_t bytes0 = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + uint8_t gender = static_cast((bytes0 >> 16) & 0xFF); + lua_pushnumber(L, gender == 0 ? 2 : (gender == 1 ? 3 : 1)); // WoW: 2=male, 3=female + return 1; + } + } + lua_pushnumber(L, 1); // unknown + return 1; +} + // --- Player/Game API --- static int lua_GetMoney(lua_State* L) { @@ -1731,7 +1854,18 @@ static int lua_UnitAffectingCombat(lua_State* L) { if (uidStr == "player") { lua_pushboolean(L, gh->isInCombat()); } else { - lua_pushboolean(L, 0); + // Check UNIT_FLAG_IN_COMBAT (0x00080000) in UNIT_FIELD_FLAGS + uint64_t guid = resolveUnitGuid(gh, uidStr); + bool inCombat = false; + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t flags = entity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + inCombat = (flags & 0x00080000) != 0; // UNIT_FLAG_IN_COMBAT + } + } + lua_pushboolean(L, inCombat); } return 1; } @@ -2768,6 +2902,12 @@ void LuaEngine::registerCoreAPI() { {"UnitLevel", lua_UnitLevel}, {"UnitExists", lua_UnitExists}, {"UnitIsDead", lua_UnitIsDead}, + {"UnitIsGhost", lua_UnitIsGhost}, + {"UnitIsDeadOrGhost", lua_UnitIsDeadOrGhost}, + {"UnitIsAFK", lua_UnitIsAFK}, + {"UnitIsDND", lua_UnitIsDND}, + {"UnitPlayerControlled", lua_UnitPlayerControlled}, + {"UnitSex", lua_UnitSex}, {"UnitClass", lua_UnitClass}, {"GetMoney", lua_GetMoney}, {"IsInGroup", lua_IsInGroup}, From d6a25ca8f245ac69472cf4c467971da1bc4cee15 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:04:39 -0700 Subject: [PATCH 37/74] fix: unit API functions return data for out-of-range party members Previously UnitHealth, UnitHealthMax, UnitPower, UnitPowerMax, UnitLevel, UnitName, and UnitExists returned 0/"Unknown"/false for party members in other zones because the entity doesn't exist in the entity manager. Now these functions fall back to SMSG_PARTY_MEMBER_STATS data stored in GroupMember structs, which provides health, power, level, and name for all party members regardless of distance. UnitName also falls back to the player name cache. This fixes raid frame addons (Grid, Healbot, VuhDo) showing blank/zero data for party members who are out of UPDATE_OBJECT range. --- src/addons/lua_engine.cpp | 93 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 01af7fd5..c0759ebd 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -133,56 +133,135 @@ static game::Unit* resolveUnit(lua_State* L, const char* unitId) { // --- WoW Unit API --- +// Helper: find GroupMember data for a GUID (for party members out of entity range) +static const game::GroupMember* findPartyMember(game::GameHandler* gh, uint64_t guid) { + if (!gh || guid == 0) return nullptr; + for (const auto& m : gh->getPartyData().members) { + if (m.guid == guid && m.hasPartyStats) return &m; + } + return nullptr; +} + static int lua_UnitName(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); if (unit && !unit->getName().empty()) { lua_pushstring(L, unit->getName().c_str()); } else { - lua_pushstring(L, "Unknown"); + // Fallback: party member name for out-of-range members + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + if (pm && !pm->name.empty()) { + lua_pushstring(L, pm->name.c_str()); + } else if (gh && guid != 0) { + // Try player name cache + const std::string& cached = gh->lookupName(guid); + lua_pushstring(L, cached.empty() ? "Unknown" : cached.c_str()); + } else { + lua_pushstring(L, "Unknown"); + } } return 1; } + static int lua_UnitHealth(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushnumber(L, unit ? unit->getHealth() : 0); + if (unit) { + lua_pushnumber(L, unit->getHealth()); + } else { + // Fallback: party member stats for out-of-range members + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->curHealth : 0); + } return 1; } static int lua_UnitHealthMax(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushnumber(L, unit ? unit->getMaxHealth() : 0); + if (unit) { + lua_pushnumber(L, unit->getMaxHealth()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->maxHealth : 0); + } return 1; } static int lua_UnitPower(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushnumber(L, unit ? unit->getPower() : 0); + if (unit) { + lua_pushnumber(L, unit->getPower()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->curPower : 0); + } return 1; } static int lua_UnitPowerMax(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushnumber(L, unit ? unit->getMaxPower() : 0); + if (unit) { + lua_pushnumber(L, unit->getMaxPower()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->maxPower : 0); + } return 1; } static int lua_UnitLevel(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushnumber(L, unit ? unit->getLevel() : 0); + if (unit) { + lua_pushnumber(L, unit->getLevel()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->level : 0); + } return 1; } static int lua_UnitExists(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushboolean(L, unit != nullptr); + if (unit) { + lua_pushboolean(L, 1); + } else { + // Party members in other zones don't have entities but still "exist" + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + lua_pushboolean(L, guid != 0 && findPartyMember(gh, guid) != nullptr); + } return 1; } From cfb9e09e1d4240164c7f6548c81b467912f7f949 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:11:48 -0700 Subject: [PATCH 38/74] feat: cache player class/race from name queries for UnitClass/UnitRace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add playerClassRaceCache_ that stores classId and raceId from SMSG_NAME_QUERY_RESPONSE. This enables UnitClass and UnitRace to return correct data for players who were previously seen but are now out of UPDATE_OBJECT range. Fallback chain for UnitClass/UnitRace is now: 1. Entity update fields (UNIT_FIELD_BYTES_0) — for nearby entities 2. Name query cache — for previously queried players 3. getPlayerClass/Race() — for the local player This improves class-colored names in chat, unit frames, and nameplates for players who move out of view range. --- include/game/game_handler.hpp | 13 +++++++++++++ src/addons/lua_engine.cpp | 11 ++++------- src/game/game_handler.cpp | 4 ++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5208bc53..145f2f48 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1235,6 +1235,16 @@ public: // Player GUID uint64_t getPlayerGuid() const { return playerGuid; } + // Look up class/race for a player GUID from name query cache. Returns 0 if unknown. + uint8_t lookupPlayerClass(uint64_t guid) const { + auto it = playerClassRaceCache_.find(guid); + return it != playerClassRaceCache_.end() ? it->second.classId : 0; + } + uint8_t lookupPlayerRace(uint64_t guid) const { + auto it = playerClassRaceCache_.find(guid); + return it != playerClassRaceCache_.end() ? it->second.raceId : 0; + } + // Look up a display name for any guid: checks playerNameCache then entity manager. // Returns empty string if unknown. Used by chat display to resolve names at render time. const std::string& lookupName(uint64_t guid) const { @@ -2710,6 +2720,9 @@ private: // ---- Phase 1: Name caches ---- std::unordered_map playerNameCache; + // Class/race cache from SMSG_NAME_QUERY_RESPONSE (guid → {classId, raceId}) + struct PlayerClassRace { uint8_t classId = 0; uint8_t raceId = 0; }; + std::unordered_map playerClassRaceCache_; std::unordered_set pendingNameQueries; std::unordered_map creatureInfoCache; std::unordered_set pendingCreatureQueries; diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index c0759ebd..b3ad636e 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -295,14 +295,9 @@ static int lua_UnitClass(lua_State* L) { classId = static_cast((bytes0 >> 8) & 0xFF); } } - // Fallback: check party/raid member data + // Fallback: check name query class/race cache if (classId == 0 && guid != 0) { - for (const auto& m : gh->getPartyData().members) { - if (m.guid == guid && m.hasPartyStats) { - // Party stats don't have class, but check guild roster - break; - } - } + classId = gh->lookupPlayerClass(guid); } } const char* name = (classId > 0 && classId < 12) ? kClasses[classId] : "Unknown"; @@ -493,6 +488,8 @@ static int lua_UnitRace(lua_State* L) { game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); raceId = static_cast(bytes0 & 0xFF); } + // Fallback: name query class/race cache + if (raceId == 0) raceId = gh->lookupPlayerRace(guid); } } const char* name = (raceId > 0 && raceId < 12) ? kRaces[raceId] : "Unknown"; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 60706f69..03365c10 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14819,6 +14819,10 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { if (data.isValid()) { playerNameCache[data.guid] = data.name; + // Cache class/race from name query for UnitClass/UnitRace fallback + if (data.classId != 0 || data.race != 0) { + playerClassRaceCache_[data.guid] = {data.classId, data.race}; + } // Update entity name auto entity = entityManager.getEntity(data.guid); if (entity && entity->getType() == ObjectType::PLAYER) { From c7e16646fc59085f9051d7afa2d30ac3be1da8c1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:16:12 -0700 Subject: [PATCH 39/74] feat: resolve spell cast time and range from DBC for GetSpellInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SpellDataResolver that lazily loads Spell.dbc, SpellCastTimes.dbc, and SpellRange.dbc to provide cast time and range data. GetSpellInfo() now returns real castTime (ms), minRange, and maxRange instead of hardcoded 0 values. This enables spell tooltip addons, cast bar addons (Quartz), and range check addons to display accurate spell information. The DBC chain is: Spell.dbc[CastingTimeIndex] → SpellCastTimes.dbc[Base ms] Spell.dbc[RangeIndex] → SpellRange.dbc[MinRange, MaxRange] Follows the same lazy-loading pattern as SpellIconPathResolver and ItemIconPathResolver. --- include/game/game_handler.hpp | 9 +++++ src/addons/lua_engine.cpp | 8 ++-- src/core/application.cpp | 70 +++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 145f2f48..f98c47aa 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -294,6 +294,14 @@ public: return spellIconPathResolver_ ? spellIconPathResolver_(spellId) : std::string{}; } + // Spell data resolver: spellId -> {castTimeMs, minRange, maxRange} + struct SpellDataInfo { uint32_t castTimeMs = 0; float minRange = 0; float maxRange = 0; }; + using SpellDataResolver = std::function; + void setSpellDataResolver(SpellDataResolver r) { spellDataResolver_ = std::move(r); } + SpellDataInfo getSpellData(uint32_t spellId) const { + return spellDataResolver_ ? spellDataResolver_(spellId) : SpellDataInfo{}; + } + // Item icon path resolver: displayInfoId -> texture path (e.g., "Interface\\Icons\\INV_Sword_04") using ItemIconPathResolver = std::function; void setItemIconPathResolver(ItemIconPathResolver r) { itemIconPathResolver_ = std::move(r); } @@ -2680,6 +2688,7 @@ private: AddonEventCallback addonEventCallback_; SpellIconPathResolver spellIconPathResolver_; ItemIconPathResolver itemIconPathResolver_; + SpellDataResolver spellDataResolver_; RandomPropertyNameResolver randomPropertyNameResolver_; EmoteAnimCallback emoteAnimCallback_; diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index b3ad636e..e43f5dd9 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -990,9 +990,11 @@ static int lua_GetSpellInfo(lua_State* L) { std::string iconPath = gh->getSpellIconPath(spellId); if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); else lua_pushnil(L); // 3: icon texture path - lua_pushnumber(L, 0); // 4: castTime (ms) — not tracked - lua_pushnumber(L, 0); // 5: minRange - lua_pushnumber(L, 0); // 6: maxRange + // Resolve cast time and range from Spell.dbc → SpellCastTimes.dbc / SpellRange.dbc + auto spellData = gh->getSpellData(spellId); + lua_pushnumber(L, spellData.castTimeMs); // 4: castTime (ms) + lua_pushnumber(L, spellData.minRange); // 5: minRange (yards) + lua_pushnumber(L, spellData.maxRange); // 6: maxRange (yards) lua_pushnumber(L, spellId); // 7: spellId return 7; } diff --git a/src/core/application.cpp b/src/core/application.cpp index c007c09c..c8ee0903 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -439,6 +439,76 @@ bool Application::initialize() { return "Interface\\Icons\\" + it->second; }); } + // Wire spell data resolver: spellId -> {castTimeMs, minRange, maxRange} + { + auto castTimeMap = std::make_shared>(); + auto rangeMap = std::make_shared>>(); + auto spellCastIdx = std::make_shared>(); // spellId→castTimeIdx + auto spellRangeIdx = std::make_shared>(); // spellId→rangeIdx + auto loaded = std::make_shared(false); + auto* am = assetManager.get(); + gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo { + if (!am) return {}; + if (!*loaded) { + *loaded = true; + // Load SpellCastTimes.dbc + auto ctDbc = am->loadDBC("SpellCastTimes.dbc"); + if (ctDbc && ctDbc->isLoaded()) { + for (uint32_t i = 0; i < ctDbc->getRecordCount(); ++i) { + uint32_t id = ctDbc->getUInt32(i, 0); + int32_t base = static_cast(ctDbc->getUInt32(i, 1)); + if (id > 0 && base > 0) (*castTimeMap)[id] = static_cast(base); + } + } + // Load SpellRange.dbc + const auto* srL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr; + uint32_t minRField = srL ? (*srL)["MinRange"] : 1; + uint32_t maxRField = srL ? (*srL)["MaxRange"] : 4; + auto rDbc = am->loadDBC("SpellRange.dbc"); + if (rDbc && rDbc->isLoaded()) { + for (uint32_t i = 0; i < rDbc->getRecordCount(); ++i) { + uint32_t id = rDbc->getUInt32(i, 0); + float minR = rDbc->getFloat(i, minRField); + float maxR = rDbc->getFloat(i, maxRField); + if (id > 0) (*rangeMap)[id] = {minR, maxR}; + } + } + // Load Spell.dbc: extract castTimeIndex and rangeIndex per spell + auto sDbc = am->loadDBC("Spell.dbc"); + const auto* spL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + if (sDbc && sDbc->isLoaded()) { + uint32_t idF = spL ? (*spL)["ID"] : 0; + uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default + uint32_t rF = spL ? (*spL)["RangeIndex"] : 132; + for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) { + uint32_t id = sDbc->getUInt32(i, idF); + if (id == 0) continue; + uint32_t ct = sDbc->getUInt32(i, ctF); + uint32_t ri = sDbc->getUInt32(i, rF); + if (ct > 0) (*spellCastIdx)[id] = ct; + if (ri > 0) (*spellRangeIdx)[id] = ri; + } + } + LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ", + spellRangeIdx->size(), " range indices"); + } + game::GameHandler::SpellDataInfo info; + auto ciIt = spellCastIdx->find(spellId); + if (ciIt != spellCastIdx->end()) { + auto ctIt = castTimeMap->find(ciIt->second); + if (ctIt != castTimeMap->end()) info.castTimeMs = ctIt->second; + } + auto riIt = spellRangeIdx->find(spellId); + if (riIt != spellRangeIdx->end()) { + auto rIt = rangeMap->find(riIt->second); + if (rIt != rangeMap->end()) { + info.minRange = rIt->second.first; + info.maxRange = rIt->second.second; + } + } + return info; + }); + } // Wire random property/suffix name resolver for item display { auto propNames = std::make_shared>(); From 91794f421e00086df9ca212754dc2f6dd0111c81 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:20:58 -0700 Subject: [PATCH 40/74] feat: add spell power cost to SpellDataResolver; fix IsUsableSpell mana check Extend SpellDataInfo with manaCost and powerType fields, extracted from Spell.dbc ManaCost and PowerType columns. This enables IsUsableSpell() to properly check if the player has enough mana/rage/energy to cast. Previously IsUsableSpell always returned notEnoughMana=false since cost data wasn't available. Now it compares the spell's DBC mana cost against the player's current power, returning accurate usability and mana state. This fixes action bar addons showing abilities as usable when the player lacks sufficient power, and enables OmniCC-style cooldown text to properly dim insufficient-power abilities. --- include/game/game_handler.hpp | 2 +- src/addons/lua_engine.cpp | 18 ++++++++++++++++-- src/core/application.cpp | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f98c47aa..158c4274 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -295,7 +295,7 @@ public: } // Spell data resolver: spellId -> {castTimeMs, minRange, maxRange} - struct SpellDataInfo { uint32_t castTimeMs = 0; float minRange = 0; float maxRange = 0; }; + struct SpellDataInfo { uint32_t castTimeMs = 0; float minRange = 0; float maxRange = 0; uint32_t manaCost = 0; uint8_t powerType = 0; }; using SpellDataResolver = std::function; void setSpellDataResolver(SpellDataResolver r) { spellDataResolver_ = std::move(r); } SpellDataInfo getSpellData(uint32_t spellId) const { diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index e43f5dd9..42dd640a 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2189,8 +2189,22 @@ static int lua_IsUsableSpell(lua_State* L) { 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) + // Check mana/power cost + bool noMana = false; + if (!onCooldown) { + auto spellData = gh->getSpellData(spellId); + if (spellData.manaCost > 0) { + auto playerEntity = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (playerEntity) { + auto* unit = dynamic_cast(playerEntity.get()); + if (unit && unit->getPower() < spellData.manaCost) { + noMana = true; + } + } + } + } + lua_pushboolean(L, (onCooldown || noMana) ? 0 : 1); // usable + lua_pushboolean(L, noMana ? 1 : 0); // notEnoughMana return 2; } diff --git a/src/core/application.cpp b/src/core/application.cpp index c8ee0903..2a8579ad 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -445,9 +445,11 @@ bool Application::initialize() { auto rangeMap = std::make_shared>>(); auto spellCastIdx = std::make_shared>(); // spellId→castTimeIdx auto spellRangeIdx = std::make_shared>(); // spellId→rangeIdx + struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; }; + auto spellCostMap = std::make_shared>(); auto loaded = std::make_shared(false); auto* am = assetManager.get(); - gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo { + gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo { if (!am) return {}; if (!*loaded) { *loaded = true; @@ -480,6 +482,12 @@ bool Application::initialize() { uint32_t idF = spL ? (*spL)["ID"] : 0; uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default uint32_t rF = spL ? (*spL)["RangeIndex"] : 132; + uint32_t ptF = UINT32_MAX, mcF = UINT32_MAX; + if (spL) { + try { ptF = (*spL)["PowerType"]; } catch (...) {} + try { mcF = (*spL)["ManaCost"]; } catch (...) {} + } + uint32_t fc = sDbc->getFieldCount(); for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) { uint32_t id = sDbc->getUInt32(i, idF); if (id == 0) continue; @@ -487,6 +495,10 @@ bool Application::initialize() { uint32_t ri = sDbc->getUInt32(i, rF); if (ct > 0) (*spellCastIdx)[id] = ct; if (ri > 0) (*spellRangeIdx)[id] = ri; + // Extract power cost + uint32_t mc = (mcF < fc) ? sDbc->getUInt32(i, mcF) : 0; + uint8_t pt = (ptF < fc) ? static_cast(sDbc->getUInt32(i, ptF)) : 0; + if (mc > 0) (*spellCostMap)[id] = {mc, pt}; } } LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ", @@ -506,6 +518,11 @@ bool Application::initialize() { info.maxRange = rIt->second.second; } } + auto mcIt = spellCostMap->find(spellId); + if (mcIt != spellCostMap->end()) { + info.manaCost = mcIt->second.manaCost; + info.powerType = mcIt->second.powerType; + } return info; }); } From 6a0e86efe8cf86d0cfa505fb999596f9c36cb091 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:23:07 -0700 Subject: [PATCH 41/74] fix: IsUsableAction now checks spell power cost from DBC IsUsableAction previously always returned notEnoughMana=false. Now it checks the spell's mana cost from SpellDataResolver against the player's current power, matching the same fix applied to IsUsableSpell. This fixes action bar addons (Bartender, Dominos) incorrectly showing abilities as usable when the player lacks mana/rage/energy. --- src/addons/lua_engine.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 42dd640a..07ed2232 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -2394,11 +2394,26 @@ static int lua_IsUsableAction(lua_State* L) { } const auto& action = bar[slot]; bool usable = action.isReady(); + bool noMana = false; if (action.type == game::ActionBarSlot::SPELL) { usable = usable && gh->getKnownSpells().count(action.id); + // Check power cost + if (usable && action.id != 0) { + auto spellData = gh->getSpellData(action.id); + if (spellData.manaCost > 0) { + auto pe = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (pe) { + auto* unit = dynamic_cast(pe.get()); + if (unit && unit->getPower() < spellData.manaCost) { + noMana = true; + usable = false; + } + } + } + } } lua_pushboolean(L, usable ? 1 : 0); - lua_pushboolean(L, 0); // notEnoughMana (can't determine without cost data) + lua_pushboolean(L, noMana ? 1 : 0); return 2; } From 70a5d3240c7eae1901d5fbb366df5da4e14789ab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:28:15 -0700 Subject: [PATCH 42/74] feat: add ACHIEVEMENT_EARNED event and 15 missing CHAT_MSG_* events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire ACHIEVEMENT_EARNED event when a player earns an achievement, enabling achievement tracking addons. Add 15 previously unmapped chat type → addon event mappings: - CHAT_MSG_ACHIEVEMENT, CHAT_MSG_GUILD_ACHIEVEMENT - CHAT_MSG_WHISPER_INFORM (echo of sent whispers) - CHAT_MSG_RAID_LEADER, CHAT_MSG_BATTLEGROUND_LEADER - CHAT_MSG_MONSTER_SAY/YELL/EMOTE/WHISPER - CHAT_MSG_RAID_BOSS_EMOTE/WHISPER - CHAT_MSG_BG_SYSTEM_NEUTRAL/ALLIANCE/HORDE These events are needed by boss mod addons (DBM, BigWigs) to detect boss emotes, by achievement trackers, and by chat filter addons that process all message types. --- src/core/application.cpp | 14 ++++++++++++++ src/game/game_handler.cpp | 2 ++ 2 files changed, 16 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index 2a8579ad..becb007c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -354,6 +354,20 @@ bool Application::initialize() { case game::ChatType::CHANNEL: eventName = "CHAT_MSG_CHANNEL"; break; case game::ChatType::EMOTE: case game::ChatType::TEXT_EMOTE: eventName = "CHAT_MSG_EMOTE"; break; + case game::ChatType::ACHIEVEMENT: eventName = "CHAT_MSG_ACHIEVEMENT"; break; + case game::ChatType::GUILD_ACHIEVEMENT: eventName = "CHAT_MSG_GUILD_ACHIEVEMENT"; break; + case game::ChatType::WHISPER_INFORM: eventName = "CHAT_MSG_WHISPER_INFORM"; break; + case game::ChatType::RAID_LEADER: eventName = "CHAT_MSG_RAID_LEADER"; break; + case game::ChatType::BATTLEGROUND_LEADER: eventName = "CHAT_MSG_BATTLEGROUND_LEADER"; break; + case game::ChatType::MONSTER_SAY: eventName = "CHAT_MSG_MONSTER_SAY"; break; + case game::ChatType::MONSTER_YELL: eventName = "CHAT_MSG_MONSTER_YELL"; break; + case game::ChatType::MONSTER_EMOTE: eventName = "CHAT_MSG_MONSTER_EMOTE"; break; + case game::ChatType::MONSTER_WHISPER: eventName = "CHAT_MSG_MONSTER_WHISPER"; break; + case game::ChatType::RAID_BOSS_EMOTE: eventName = "CHAT_MSG_RAID_BOSS_EMOTE"; break; + case game::ChatType::RAID_BOSS_WHISPER: eventName = "CHAT_MSG_RAID_BOSS_WHISPER"; break; + case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break; + case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break; + case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break; default: break; } if (eventName) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 03365c10..7359f330 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -26312,6 +26312,8 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec, " achievementId=", achievementId, " self=", isSelf, achName.empty() ? "" : " name=", achName); + if (addonEventCallback_) + addonEventCallback_("ACHIEVEMENT_EARNED", {std::to_string(achievementId)}); } // --------------------------------------------------------------------------- From 8e5175461562535e8bfd6b4ce4eb88273030566a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:38:35 -0700 Subject: [PATCH 43/74] feat: fire READY_CHECK, READY_CHECK_CONFIRM, READY_CHECK_FINISHED events Fire addon events for the raid ready check system: - READY_CHECK fires when a ready check is initiated, with initiator name - READY_CHECK_CONFIRM fires for each player's response, with GUID and ready state (1=ready, 0=not ready) - READY_CHECK_FINISHED fires when the ready check period ends These events are used by raid frame addons (Grid, VuhDo, Healbot) to show ready check status on unit frames, and by raid management addons to track responsiveness. --- src/game/game_handler.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7359f330..c9d7c902 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3776,6 +3776,8 @@ void GameHandler::handlePacket(network::Packet& packet) { ? "Ready check initiated!" : readyCheckInitiator_ + " initiated a ready check!"); LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_); + if (addonEventCallback_) + addonEventCallback_("READY_CHECK", {readyCheckInitiator_}); break; } case Opcode::MSG_RAID_READY_CHECK_CONFIRM: { @@ -3804,6 +3806,11 @@ void GameHandler::handlePacket(network::Packet& packet) { std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); addSystemChatMessage(rbuf); } + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)respGuid); + addonEventCallback_("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"}); + } break; } case Opcode::MSG_RAID_READY_CHECK_FINISHED: { @@ -3816,6 +3823,8 @@ void GameHandler::handlePacket(network::Packet& packet) { readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; readyCheckResults_.clear(); + if (addonEventCallback_) + addonEventCallback_("READY_CHECK_FINISHED", {}); break; } case Opcode::SMSG_RAID_INSTANCE_INFO: From 1f3e3625129063083c1bc726bbe8bbd53d86becd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:43:42 -0700 Subject: [PATCH 44/74] feat: add RAID_TARGET_UPDATE event and GetRaidTargetIndex/SetRaidTarget Fire RAID_TARGET_UPDATE event when raid markers (skull, cross, etc.) are set or cleared on targets. Add two Lua API functions: - GetRaidTargetIndex(unit) returns marker index 1-8 (or nil) - SetRaidTarget(unit, index) sets marker 1-8 (or 0 to clear) Enables raid marking addons and nameplate addons that display raid icons to react to marker changes in real-time. --- src/addons/lua_engine.cpp | 34 ++++++++++++++++++++++++++++++++++ src/game/game_handler.cpp | 2 ++ 2 files changed, 36 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 07ed2232..ae5605d5 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -948,6 +948,38 @@ static int lua_TargetNearestFriend(lua_State* L) { return 0; } +// GetRaidTargetIndex(unit) → icon index (1-8) or nil +static int lua_GetRaidTargetIndex(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); 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_pushnil(L); return 1; } + uint8_t mark = gh->getEntityRaidMark(guid); + if (mark == 0xFF) { lua_pushnil(L); return 1; } + lua_pushnumber(L, mark + 1); // WoW uses 1-indexed (1=Star, 2=Circle, ... 8=Skull) + return 1; +} + +// SetRaidTarget(unit, index) — set raid marker (1-8, or 0 to clear) +static int lua_SetRaidTarget(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, "target"); + int index = static_cast(luaL_checknumber(L, 2)); + 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) return 0; + if (index >= 1 && index <= 8) + gh->setRaidMark(guid, static_cast(index - 1)); + else if (index == 0) + gh->setRaidMark(guid, 0xFF); // clear + return 0; +} + // --- GetSpellInfo / GetSpellTexture --- // GetSpellInfo(spellIdOrName) -> name, rank, icon, castTime, minRange, maxRange, spellId static int lua_GetSpellInfo(lua_State* L) { @@ -3036,6 +3068,8 @@ void LuaEngine::registerCoreAPI() { {"TargetLastTarget", lua_TargetLastTarget}, {"TargetNearestEnemy", lua_TargetNearestEnemy}, {"TargetNearestFriend", lua_TargetNearestFriend}, + {"GetRaidTargetIndex", lua_GetRaidTargetIndex}, + {"SetRaidTarget", lua_SetRaidTarget}, {"UnitRace", lua_UnitRace}, {"UnitPowerType", lua_UnitPowerType}, {"GetNumGroupMembers", lua_GetNumGroupMembers}, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c9d7c902..1e2f2de7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5022,6 +5022,8 @@ void GameHandler::handlePacket(network::Packet& packet) { } } LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); + if (addonEventCallback_) + addonEventCallback_("RAID_TARGET_UPDATE", {}); break; } case Opcode::SMSG_BUY_ITEM: { From 74d7e969abd042504f905bf49e0962a5f4b85b0b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:48:06 -0700 Subject: [PATCH 45/74] feat: add action bar constants and functions for Bartender/Dominos compat Add essential WoW action bar globals and functions that action bar addons (Bartender4, Dominos, CT_BarMod) require on initialization: Constants: NUM_ACTIONBAR_BUTTONS, NUM_ACTIONBAR_PAGES, NUM_PET_ACTION_SLOTS Functions: GetActionBarPage, ChangeActionBarPage, GetBonusBarOffset, GetActionText, GetActionCount Binding: GetBindingKey, GetBindingAction, SetBinding, SaveBindings Macro: GetNumMacros, GetMacroInfo, GetMacroBody, GetMacroIndexByName Stance: GetNumShapeshiftForms, GetShapeshiftFormInfo Pet: GetPetActionInfo, GetPetActionsUsable These prevent nil-reference errors during addon initialization and enable basic action bar addon functionality. --- src/addons/lua_engine.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index ae5605d5..1b47bc91 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -3601,6 +3601,40 @@ void LuaEngine::registerCoreAPI() { "C_ChatInfo.SendAddonMessage = SendAddonMessage\n" ); + // Action bar constants and functions used by action bar addons + luaL_dostring(L_, + "NUM_ACTIONBAR_BUTTONS = 12\n" + "NUM_ACTIONBAR_PAGES = 6\n" + "ACTION_BUTTON_SHOW_GRID_REASON_CVAR = 1\n" + "ACTION_BUTTON_SHOW_GRID_REASON_EVENT = 2\n" + // Action bar page tracking + "local _actionBarPage = 1\n" + "function GetActionBarPage() return _actionBarPage end\n" + "function ChangeActionBarPage(page) _actionBarPage = page end\n" + "function GetBonusBarOffset() return 0 end\n" + // Action type query + "function GetActionText(slot) return nil end\n" + "function GetActionCount(slot) return 0 end\n" + // Binding functions + "function GetBindingKey(action) return nil end\n" + "function GetBindingAction(key) return nil end\n" + "function SetBinding(key, action) end\n" + "function SaveBindings(which) end\n" + "function GetCurrentBindingSet() return 1 end\n" + // Macro functions + "function GetNumMacros() return 0, 0 end\n" + "function GetMacroInfo(id) return nil end\n" + "function GetMacroBody(id) return nil end\n" + "function GetMacroIndexByName(name) return 0 end\n" + // Stance bar + "function GetNumShapeshiftForms() return 0 end\n" + "function GetShapeshiftFormInfo(index) return nil, nil, nil, nil end\n" + // Pet action bar + "NUM_PET_ACTION_SLOTS = 10\n" + "function GetPetActionInfo(slot) return nil end\n" + "function GetPetActionsUsable() return false end\n" + ); + // WoW table/string utility functions used by many addons luaL_dostring(L_, // Table utilities From f99f4a732a3063aee235e9e7644bbf6689756488 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:53:08 -0700 Subject: [PATCH 46/74] feat: fire INSPECT_READY event from both WotLK and Classic inspect paths Fire INSPECT_READY with the inspected player's GUID when inspection results are received. Fires from both: - WotLK SMSG_TALENTS_INFO type=1 (talent + gear inspect) - Classic SMSG_INSPECT (gear-only inspect) Used by GearScore, TacoTip, and other inspection addons that need to know when inspect data is available for a specific player. --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1e2f2de7..a3fb8823 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8060,6 +8060,11 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", std::count_if(items.begin(), items.end(), [](uint32_t e) { return e != 0; }), "/19 slots"); + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); + addonEventCallback_("INSPECT_READY", {guidBuf}); + } break; } @@ -15245,6 +15250,11 @@ void GameHandler::handleInspectResults(network::Packet& packet) { LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ", unspentTalents, " unspent, ", (int)talentGroupCount, " specs"); + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); + addonEventCallback_("INSPECT_READY", {guidBuf}); + } } uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const { From 494175e2a766b1821af507c6d982de0cf61de9f9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 04:57:19 -0700 Subject: [PATCH 47/74] feat: add remaining CHAT_MSG_* event mappings Map 5 previously unmapped chat types to addon events: - CHAT_MSG_MONSTER_PARTY (NPC party chat in dungeons/scripted events) - CHAT_MSG_AFK (player AFK auto-reply messages) - CHAT_MSG_DND (player DND auto-reply messages) - CHAT_MSG_LOOT (loot roll/distribution messages) - CHAT_MSG_SKILL (skill-up messages) All WoW chat types in the ChatType enum are now mapped to addon events. --- src/core/application.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index becb007c..ea1a2a7a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -368,6 +368,11 @@ bool Application::initialize() { case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break; case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break; case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break; + case game::ChatType::MONSTER_PARTY: eventName = "CHAT_MSG_MONSTER_PARTY"; break; + case game::ChatType::AFK: eventName = "CHAT_MSG_AFK"; break; + case game::ChatType::DND: eventName = "CHAT_MSG_DND"; break; + case game::ChatType::LOOT: eventName = "CHAT_MSG_LOOT"; break; + case game::ChatType::SKILL: eventName = "CHAT_MSG_SKILL"; break; default: break; } if (eventName) { From d4c1eda22b954d05ee3e73938e58fbfbc3fefe91 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:03:03 -0700 Subject: [PATCH 48/74] feat: fire PARTY_LEADER_CHANGED event on leader changes Fire PARTY_LEADER_CHANGED (with GROUP_ROSTER_UPDATE) from both: - SMSG_GROUP_SET_LEADER: when a new leader is named by string - SMSG_REAL_GROUP_UPDATE: when leader GUID changes via group update Used by raid frame addons to update leader crown icons and by group management addons to track leadership changes. --- src/game/game_handler.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a3fb8823..9068ac34 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5743,6 +5743,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (!leaderName.empty()) addSystemChatMessage(leaderName + " is now the group leader."); LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName); + if (addonEventCallback_) { + addonEventCallback_("PARTY_LEADER_CHANGED", {}); + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + } } break; } @@ -7716,6 +7720,10 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast(newGroupType), " memberFlags=0x", std::hex, newMemberFlags, std::dec, " leaderGuid=", newLeaderGuid); + if (addonEventCallback_) { + addonEventCallback_("PARTY_LEADER_CHANGED", {}); + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + } break; } From 82d3abe5dab90e4d7874916455608f46a685780b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:13:28 -0700 Subject: [PATCH 49/74] feat: add GetAddOnMetadata for reading TOC directives from Lua Implement GetAddOnMetadata(addonNameOrIndex, key) which reads arbitrary TOC file directives. All directives are now stored in the addon info registry table under a "metadata" sub-table. This enables addons to read their own version, author, X-* custom fields, and other TOC metadata at runtime. Used by addon managers, version checkers, and self-updating addons. --- 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 1b47bc91..0c54d838 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -600,6 +600,37 @@ static int lua_GetAddOnInfo(lua_State* L) { return 5; } +// GetAddOnMetadata(addonNameOrIndex, key) → value +static int lua_GetAddOnMetadata(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, "wowee_addon_info"); + if (!lua_istable(L, -1)) { lua_pop(L, 1); lua_pushnil(L); return 1; } + + int idx = 0; + if (lua_isnumber(L, 1)) { + idx = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + int count = static_cast(lua_objlen(L, -1)); + for (int i = 1; i <= count; i++) { + lua_rawgeti(L, -1, i); + lua_getfield(L, -1, "name"); + const char* aName = lua_tostring(L, -1); + lua_pop(L, 1); + if (aName && strcmp(aName, name) == 0) { idx = i; lua_pop(L, 1); break; } + lua_pop(L, 1); + } + } + if (idx < 1) { lua_pop(L, 1); lua_pushnil(L); return 1; } + + const char* key = luaL_checkstring(L, 2); + lua_rawgeti(L, -1, idx); + if (!lua_istable(L, -1)) { lua_pop(L, 2); lua_pushnil(L); return 1; } + lua_getfield(L, -1, "metadata"); + if (!lua_istable(L, -1)) { lua_pop(L, 3); lua_pushnil(L); return 1; } + lua_getfield(L, -1, key); + return 1; +} + // UnitBuff(unitId, index) / UnitDebuff(unitId, index) // Returns: name, rank, icon, count, debuffType, duration, expirationTime, caster, isStealable, shouldConsolidate, spellId static int lua_UnitAura(lua_State* L, bool wantBuff) { @@ -3081,6 +3112,7 @@ void LuaEngine::registerCoreAPI() { {"UnitAura", lua_UnitAuraGeneric}, {"GetNumAddOns", lua_GetNumAddOns}, {"GetAddOnInfo", lua_GetAddOnInfo}, + {"GetAddOnMetadata", lua_GetAddOnMetadata}, {"GetSpellInfo", lua_GetSpellInfo}, {"GetSpellTexture", lua_GetSpellTexture}, {"GetItemInfo", lua_GetItemInfo}, @@ -3995,6 +4027,13 @@ void LuaEngine::setAddonList(const std::vector& addons) { auto notesIt = addons[i].directives.find("Notes"); lua_pushstring(L_, notesIt != addons[i].directives.end() ? notesIt->second.c_str() : ""); lua_setfield(L_, -2, "notes"); + // Store all TOC directives for GetAddOnMetadata + lua_newtable(L_); + for (const auto& [key, val] : addons[i].directives) { + lua_pushstring(L_, val.c_str()); + lua_setfield(L_, -2, key.c_str()); + } + lua_setfield(L_, -2, "metadata"); lua_rawseti(L_, -2, static_cast(i + 1)); } lua_setfield(L_, LUA_REGISTRYINDEX, "wowee_addon_info"); From 7f0d9fe4329a40e5fe1b78760131030fe62dfe4d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:17:40 -0700 Subject: [PATCH 50/74] feat: fire PLAYER_GUILD_UPDATE event on guild join/disband Fire PLAYER_GUILD_UPDATE when the player's guild membership changes: - When guild name is first resolved (player joins guild/logs in) - When guild is disbanded Used by guild frame addons and guild info display to update when guild status changes. --- src/game/game_handler.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9068ac34..41c2f665 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20629,8 +20629,10 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { guildRankNames_.push_back(data.rankNames[i]); } LOG_INFO("Guild name set to: ", guildName_); - if (wasUnknown && !guildName_.empty()) + if (wasUnknown && !guildName_.empty()) { addSystemChatMessage("Guild: <" + guildName_ + ">"); + if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {}); + } } else { LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName); } @@ -20680,6 +20682,7 @@ void GameHandler::handleGuildEvent(network::Packet& packet) { guildRankNames_.clear(); guildRoster_ = GuildRosterData{}; hasGuildRoster_ = false; + if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {}); break; case GuildEvent::SIGNED_ON: if (data.numStrings >= 1) From a02e021730bd51ee0a5d1f3d2a9e35039547ac08 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:22:17 -0700 Subject: [PATCH 51/74] fix: fire UNIT_SPELLCAST_FAILED/STOP for other units on SPELL_FAILED_OTHER SMSG_SPELL_FAILED_OTHER was clearing the unit cast state but not firing addon events. Cast bar addons (Quartz, ClassicCastbars) showing target/ focus cast bars need UNIT_SPELLCAST_FAILED and UNIT_SPELLCAST_STOP to clear the bar when another unit's cast fails. Now fires both events for target and focus units, matching the behavior already implemented for the player's own cast failures. --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 41c2f665..a4ab783d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2344,6 +2344,16 @@ void GameHandler::handlePacket(network::Packet& packet) { : UpdateObjectParser::readPackedGuid(packet); if (failOtherGuid != 0 && failOtherGuid != playerGuid) { unitCastStates_.erase(failOtherGuid); + // Fire cast failure events so cast bar addons clear the bar + if (addonEventCallback_) { + std::string unitId; + if (failOtherGuid == targetGuid) unitId = "target"; + else if (failOtherGuid == focusGuid) unitId = "focus"; + if (!unitId.empty()) { + addonEventCallback_("UNIT_SPELLCAST_FAILED", {unitId}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); + } + } } packet.setReadPos(packet.getSize()); break; From b5f7659db514a33f1612e41c4e1f009161354902 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:27:34 -0700 Subject: [PATCH 52/74] feat: fire MERCHANT_UPDATE and BAG_UPDATE events after purchase Fire MERCHANT_UPDATE after a successful SMSG_BUY_ITEM so vendor addons refresh their stock display. Also fire BAG_UPDATE so bag addons show the newly purchased item immediately. --- 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 a4ab783d..fa1eca79 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5063,6 +5063,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; + if (addonEventCallback_) { + addonEventCallback_("MERCHANT_UPDATE", {}); + addonEventCallback_("BAG_UPDATE", {}); + } } break; } From 2560bd1307f9033c8585f3bede008bdfd16f25ff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:33:29 -0700 Subject: [PATCH 53/74] feat: fire QUEST_WATCH_UPDATE on kill and item objective progress Fire QUEST_WATCH_UPDATE (with quest ID for kills) and QUEST_LOG_UPDATE when quest objectives progress: - Kill objectives: when SMSG_QUESTUPDATE_ADD_KILL updates a kill count - Item objectives: when SMSG_QUESTUPDATE_ADD_ITEM updates an item count Used by quest tracker addons (Questie, QuestHelper) and the built-in quest tracker to refresh objective display when progress changes. --- src/game/game_handler.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fa1eca79..7e627014 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5449,6 +5449,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (questProgressCallback_) { questProgressCallback_(quest.title, creatureName, count, reqCount); } + if (addonEventCallback_) { + addonEventCallback_("QUEST_WATCH_UPDATE", {std::to_string(questId)}); + addonEventCallback_("QUEST_LOG_UPDATE", {}); + } LOG_INFO("Updated kill count for quest ", questId, ": ", count, "/", reqCount); @@ -5526,6 +5530,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } } + if (addonEventCallback_ && updatedAny) { + addonEventCallback_("QUEST_WATCH_UPDATE", {}); + addonEventCallback_("QUEST_LOG_UPDATE", {}); + } LOG_INFO("Quest item update: itemId=", itemId, " count=", count, " trackedQuestsUpdated=", updatedAny); } From 2e6400f22e9892c36db496c981b7d5719dd2d1c2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:38:35 -0700 Subject: [PATCH 54/74] feat: fire MAIL_INBOX_UPDATE and UPDATE_PENDING_MAIL events Fire MAIL_INBOX_UPDATE when the mail list is received/refreshed (SMSG_MAIL_LIST_RESULT), so mail addons can update their display. Fire UPDATE_PENDING_MAIL when new mail arrives (SMSG_RECEIVED_MAIL), enabling minimap mail icon addons and notification addons to react. --- 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 7e627014..aa757382 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -25253,6 +25253,7 @@ void GameHandler::handleMailListResult(network::Packet& packet) { selectedMailIndex_ = -1; showMailCompose_ = false; } + if (addonEventCallback_) addonEventCallback_("MAIL_INBOX_UPDATE", {}); } void GameHandler::handleSendMailResult(network::Packet& packet) { @@ -25327,6 +25328,7 @@ void GameHandler::handleReceivedMail(network::Packet& packet) { LOG_INFO("SMSG_RECEIVED_MAIL: New mail arrived!"); hasNewMail_ = true; addSystemChatMessage("New mail has arrived."); + if (addonEventCallback_) addonEventCallback_("UPDATE_PENDING_MAIL", {}); // If mailbox is open, refresh if (mailboxOpen_) { refreshMailList(); From d20357415bf113ee89c9f830ea2e4310f3b52efa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:42:57 -0700 Subject: [PATCH 55/74] feat: fire PLAYER_CONTROL_LOST/GAINED on movement control changes Fire PLAYER_CONTROL_LOST when SMSG_CLIENT_CONTROL_UPDATE revokes player movement (stun, fear, mind control, etc.) and PLAYER_CONTROL_GAINED when movement is restored. Used by loss-of-control addons and action bar addons to show stun/CC indicators and disable ability buttons during crowd control. --- 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 aa757382..61d7f941 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3295,8 +3295,10 @@ void GameHandler::handlePacket(network::Packet& packet) { sendMovement(Opcode::MSG_MOVE_STOP_TURN); sendMovement(Opcode::MSG_MOVE_STOP_SWIM); addSystemChatMessage("Movement disabled by server."); + if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_LOST", {}); } else if (changed && allowMovement) { addSystemChatMessage("Movement re-enabled."); + if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_GAINED", {}); } } break; From 64c0c75bbf7cd0982b07381f288ea4718002cd04 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 05:47:22 -0700 Subject: [PATCH 56/74] feat: fire CHAT_MSG_COMBAT_HONOR_GAIN event on PvP honor kills Fire CHAT_MSG_COMBAT_HONOR_GAIN from SMSG_PVP_CREDIT with the honor message text. Used by PvP addons (HonorSpy, HonorTracker) to track honor gains and kill counts. --- 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 61d7f941..aa21338d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2229,6 +2229,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (pvpHonorCallback_) { pvpHonorCallback_(honor, victimGuid, rank); } + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_COMBAT_HONOR_GAIN", {msg}); } break; } From 19b8d31da21da09298cff3df2453717887c3818d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:00:06 -0700 Subject: [PATCH 57/74] feat: display Lua addon errors as in-game UI errors Previously Lua addon errors only logged to the log file. Now they display as red UI error text to the player (same as spell errors and game warnings), helping addon developers debug issues in real-time. Add LuaErrorCallback to LuaEngine, fire it from event handler and frame OnEvent pcall error paths. Wire the callback to GameHandler's addUIError in application.cpp. --- include/addons/lua_engine.hpp | 6 ++++++ src/addons/lua_engine.cpp | 10 +++++++--- src/core/application.cpp | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/include/addons/lua_engine.hpp b/include/addons/lua_engine.hpp index 4a0027bb..6302a64c 100644 --- a/include/addons/lua_engine.hpp +++ b/include/addons/lua_engine.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -47,9 +48,14 @@ public: lua_State* getState() { return L_; } bool isInitialized() const { return L_ != nullptr; } + // Optional callback for Lua errors (displayed as UI errors to the player) + using LuaErrorCallback = std::function; + void setLuaErrorCallback(LuaErrorCallback cb) { luaErrorCallback_ = std::move(cb); } + private: lua_State* L_ = nullptr; game::GameHandler* gameHandler_ = nullptr; + LuaErrorCallback luaErrorCallback_; void registerCoreAPI(); void registerEventAPI(); diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 0c54d838..f64eabfb 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -3820,8 +3820,9 @@ void LuaEngine::fireEvent(const std::string& eventName, int nargs = 1 + static_cast(args.size()); if (lua_pcall(L_, nargs, 0, 0) != 0) { const char* err = lua_tostring(L_, -1); - LOG_ERROR("LuaEngine: event '", eventName, "' handler error: ", - err ? err : "(unknown)"); + std::string errStr = err ? err : "(unknown)"; + LOG_ERROR("LuaEngine: event '", eventName, "' handler error: ", errStr); + if (luaErrorCallback_) luaErrorCallback_(errStr); lua_pop(L_, 1); } } @@ -3847,7 +3848,10 @@ void LuaEngine::fireEvent(const std::string& eventName, for (const auto& arg : args) lua_pushstring(L_, arg.c_str()); int nargs = 2 + static_cast(args.size()); if (lua_pcall(L_, nargs, 0, 0) != 0) { - LOG_ERROR("LuaEngine: frame OnEvent error: ", lua_tostring(L_, -1)); + const char* ferr = lua_tostring(L_, -1); + std::string ferrStr = ferr ? ferr : "(unknown)"; + LOG_ERROR("LuaEngine: frame OnEvent error: ", ferrStr); + if (luaErrorCallback_) luaErrorCallback_(ferrStr); lua_pop(L_, 1); } } else { diff --git a/src/core/application.cpp b/src/core/application.cpp index ea1a2a7a..63b2c2f9 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -335,6 +335,10 @@ bool Application::initialize() { if (addonManager_->initialize(gameHandler.get())) { std::string addonsDir = assetPath + "/interface/AddOns"; addonManager_->scanAddons(addonsDir); + // Wire Lua errors to UI error display + addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) { + if (gh) gh->addUIError(err); + }); // Wire chat messages to addon event dispatch gameHandler->setAddonChatCallback([this](const game::MessageChatData& msg) { if (!addonManager_ || !addonsLoaded_) return; From 900626f5fe9b325e63a58745bed8b49ebcb81d6d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:02:28 -0700 Subject: [PATCH 58/74] feat: fire UPDATE_BATTLEFIELD_STATUS event on BG queue/join/leave Fire UPDATE_BATTLEFIELD_STATUS with the status code when battlefield status changes (queued, ready to join, in progress, waiting to leave). Used by BG queue addons and PvP addons to track battleground state. --- 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 aa21338d..9e05e0fa 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16803,6 +16803,8 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { LOG_INFO("Battlefield status: unknown (", statusId, ") for ", bgName); break; } + if (addonEventCallback_) + addonEventCallback_("UPDATE_BATTLEFIELD_STATUS", {std::to_string(statusId)}); } void GameHandler::handleBattlefieldList(network::Packet& packet) { From 6687c617d9c3adb6d441241d0e96ee77fda09e12 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:08:17 -0700 Subject: [PATCH 59/74] fix: display Lua errors from OnUpdate, executeFile, executeString as UI errors Extend the Lua error UI display to cover all error paths: - OnUpdate frame callbacks (previously only logged) - executeFile loading errors (now also shown as UI error) - executeString /run errors (now also shown as UI error) This ensures addon developers see ALL Lua errors in-game, not just event handler errors from the previous commit. --- src/addons/lua_engine.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index f64eabfb..12e7f8fb 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -3891,7 +3891,10 @@ void LuaEngine::dispatchOnUpdate(float elapsed) { lua_pushvalue(L_, -3); // self (frame) lua_pushnumber(L_, static_cast(elapsed)); if (lua_pcall(L_, 2, 0, 0) != 0) { - LOG_ERROR("LuaEngine: OnUpdate error: ", lua_tostring(L_, -1)); + const char* uerr = lua_tostring(L_, -1); + std::string uerrStr = uerr ? uerr : "(unknown)"; + LOG_ERROR("LuaEngine: OnUpdate error: ", uerrStr); + if (luaErrorCallback_) luaErrorCallback_(uerrStr); lua_pop(L_, 1); } } else { @@ -4098,6 +4101,7 @@ bool LuaEngine::executeFile(const std::string& path) { const char* errMsg = lua_tostring(L_, -1); std::string msg = errMsg ? errMsg : "(unknown error)"; LOG_ERROR("LuaEngine: error loading '", path, "': ", msg); + if (luaErrorCallback_) luaErrorCallback_(msg); if (gameHandler_) { game::MessageChatData errChat; errChat.type = game::ChatType::SYSTEM; @@ -4119,6 +4123,7 @@ bool LuaEngine::executeString(const std::string& code) { const char* errMsg = lua_tostring(L_, -1); std::string msg = errMsg ? errMsg : "(unknown error)"; LOG_ERROR("LuaEngine: script error: ", msg); + if (luaErrorCallback_) luaErrorCallback_(msg); if (gameHandler_) { game::MessageChatData errChat; errChat.type = game::ChatType::SYSTEM; From b04e36aaa45ef153de6495083460cb59d5c32d97 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:19:14 -0700 Subject: [PATCH 60/74] fix: include pet unit ID in all spellcast addon events Add pet GUID check to all spellcast event dispatchers: - UNIT_SPELLCAST_START - UNIT_SPELLCAST_SUCCEEDED - UNIT_SPELLCAST_CHANNEL_START - UNIT_SPELLCAST_CHANNEL_STOP - UNIT_SPELLCAST_INTERRUPTED + STOP Previously these events only fired for player/target/focus, meaning pet cast bars and pet spell tracking addons wouldn't work. Now pet spellcasts properly fire with unitId="pet". --- src/game/game_handler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9e05e0fa..05a83269 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3452,6 +3452,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (failGuid == playerGuid || failGuid == 0) unitId = "player"; else if (failGuid == targetGuid) unitId = "target"; else if (failGuid == focusGuid) unitId = "focus"; + else if (failGuid == petGuid_) unitId = "pet"; if (!unitId.empty()) { addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId}); addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); @@ -7465,6 +7466,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (chanCaster == playerGuid) unitId = "player"; else if (chanCaster == targetGuid) unitId = "target"; else if (chanCaster == focusGuid) unitId = "focus"; + else if (chanCaster == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)}); } @@ -7501,6 +7503,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (chanCaster2 == playerGuid) unitId = "player"; else if (chanCaster2 == targetGuid) unitId = "target"; else if (chanCaster2 == focusGuid) unitId = "focus"; + else if (chanCaster2 == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_CHANNEL_STOP", {unitId}); } @@ -19309,6 +19312,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) { if (data.casterUnit == playerGuid) unitId = "player"; else if (data.casterUnit == targetGuid) unitId = "target"; else if (data.casterUnit == focusGuid) unitId = "focus"; + else if (data.casterUnit == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)}); } @@ -19458,6 +19462,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) { if (data.casterUnit == playerGuid) unitId = "player"; else if (data.casterUnit == targetGuid) unitId = "target"; else if (data.casterUnit == focusGuid) unitId = "focus"; + else if (data.casterUnit == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)}); } From 5ab6286f7e93c59a472e4dd5ca30fd81c565173a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:23:03 -0700 Subject: [PATCH 61/74] fix: include pet unit ID in UNIT_HEALTH/POWER events from dedicated packets SMSG_HEALTH_UPDATE and SMSG_POWER_UPDATE were not checking for the pet GUID when dispatching addon events. Pet health/power bar addons now properly receive UNIT_HEALTH and UNIT_POWER with unitId="pet". The UPDATE_OBJECT and UNIT_AURA paths already had the pet check. --- 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 05a83269..0f4ebc18 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2165,6 +2165,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (guid == playerGuid) unitId = "player"; else if (guid == targetGuid) unitId = "target"; else if (guid == focusGuid) unitId = "focus"; + else if (guid == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_HEALTH", {unitId}); } @@ -2190,6 +2191,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (guid == playerGuid) unitId = "player"; else if (guid == targetGuid) unitId = "target"; else if (guid == focusGuid) unitId = "focus"; + else if (guid == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_POWER", {unitId}); } From b3ad64099be36ada31a382259993c09150aacca5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:27:55 -0700 Subject: [PATCH 62/74] feat: fire UNIT_PET event when pet is summoned, dismissed, or dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire UNIT_PET with "player" as arg from SMSG_PET_SPELLS when: - Pet is cleared (dismissed/dies) — both size-based and guid=0 paths - Pet is summoned (new pet GUID received with spell list) Used by pet frame addons and unit frame addons to show/hide pet frames and update pet action bars when pet state changes. --- 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 0f4ebc18..76fed46c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18936,6 +18936,7 @@ void GameHandler::handlePetSpells(network::Packet& packet) { petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); LOG_INFO("SMSG_PET_SPELLS: pet cleared"); + if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); return; } @@ -18945,6 +18946,7 @@ void GameHandler::handlePetSpells(network::Packet& packet) { petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)"); + if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); return; } @@ -18986,6 +18988,7 @@ done: LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec, " react=", (int)petReact_, " command=", (int)petCommand_, " spells=", petSpellList_.size()); + if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); } void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { From d36172fc909e3f0dc174f2adc1bb90c8baa32be5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:32:41 -0700 Subject: [PATCH 63/74] fix: fire PLAYER_MONEY event from SMSG_LOOT_MONEY_NOTIFY The loot money handler directly updates playerMoneyCopper_ but wasn't firing PLAYER_MONEY. The update object path fires it when the coinage field changes, but there can be a delay. Now gold-tracking addons (Accountant, GoldTracker) immediately see looted money. --- src/game/game_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 76fed46c..6d2dbc5f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4784,6 +4784,7 @@ void GameHandler::handlePacket(network::Packet& packet) { recentLootMoneyAnnounceCooldowns_[notifyGuid] = 1.5f; } } + if (addonEventCallback_) addonEventCallback_("PLAYER_MONEY", {}); } break; } From de903e363c1c319e1eb5938d9f7d25061ae6f201 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:38:48 -0700 Subject: [PATCH 64/74] feat: fire PLAYER_STARTED_MOVING and PLAYER_STOPPED_MOVING events Track horizontal movement flag transitions (forward/backward/strafe) and fire events when the player starts or stops moving. Used by Healbot, cast-while-moving addons, and nameplate addons that track player movement state for positioning optimization. --- src/game/game_handler.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6d2dbc5f..6d6ad674 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11173,6 +11173,13 @@ void GameHandler::sendMovement(Opcode opcode) { } } + // Track movement state transition for PLAYER_STARTED/STOPPED_MOVING events + const uint32_t kMoveMask = static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT); + const bool wasMoving = (movementInfo.flags & kMoveMask) != 0; + // Cancel any timed (non-channeled) cast the moment the player starts moving. // Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server. // Turning (MSG_MOVE_START_TURN_*) is allowed while casting. @@ -11277,6 +11284,15 @@ void GameHandler::sendMovement(Opcode opcode) { break; } + // Fire PLAYER_STARTED/STOPPED_MOVING on movement state transitions + { + const bool isMoving = (movementInfo.flags & kMoveMask) != 0; + if (isMoving && !wasMoving && addonEventCallback_) + addonEventCallback_("PLAYER_STARTED_MOVING", {}); + else if (!isMoving && wasMoving && addonEventCallback_) + addonEventCallback_("PLAYER_STOPPED_MOVING", {}); + } + if (opcode == Opcode::MSG_MOVE_SET_FACING) { lastFacingSendTimeMs_ = movementInfo.time; lastFacingSentOrientation_ = movementInfo.orientation; From 5d93108a886226d9c7f6872a0ee4cd199d753349 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:42:59 -0700 Subject: [PATCH 65/74] feat: fire CONFIRM_SUMMON event on warlock/meeting stone summons Fire CONFIRM_SUMMON from SMSG_SUMMON_REQUEST when another player summons via warlock portal or meeting stone. Used by auto-accept summon addons and summon notification addons. --- 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 6d6ad674..6bac6a62 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -25855,6 +25855,8 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { addSystemChatMessage(msg); LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_, " zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s"); + if (addonEventCallback_) + addonEventCallback_("CONFIRM_SUMMON", {}); } void GameHandler::acceptSummon() { From b407d5d632db90bc74caaef7139b066fe573b122 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:47:32 -0700 Subject: [PATCH 66/74] feat: fire RESURRECT_REQUEST event when another player offers resurrection Fire RESURRECT_REQUEST with the caster's name from SMSG_RESURRECT_REQUEST when a player/NPC offers to resurrect the dead player. Used by auto-accept resurrection addons and death tracking addons. --- 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 6bac6a62..01e86a5a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4056,6 +4056,8 @@ void GameHandler::handlePacket(network::Packet& packet) { resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : ""; } resurrectRequestPending_ = true; + if (addonEventCallback_) + addonEventCallback_("RESURRECT_REQUEST", {resurrectCasterName_}); } break; } From 12fb8f73f7d961e5cd55eaf96d7406a079045593 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 06:52:41 -0700 Subject: [PATCH 67/74] feat: fire BAG_UPDATE and PLAYER_MONEY events after selling items SMSG_SELL_ITEM success now fires BAG_UPDATE so bag addons refresh their display, and PLAYER_MONEY so gold tracking addons see the sell price income immediately. --- 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 01e86a5a..bce105c4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4808,6 +4808,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playDropOnGround(); } + if (addonEventCallback_) { + addonEventCallback_("BAG_UPDATE", {}); + addonEventCallback_("PLAYER_MONEY", {}); + } } else { bool removedPending = false; auto it = pendingSellToBuyback_.find(itemGuid); From df79e0878826d867028b37e0008944087100b629 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:07:32 -0700 Subject: [PATCH 68/74] fix: fire GROUP_ROSTER_UPDATE when group is destroyed SMSG_GROUP_DESTROYED clears all party state but wasn't firing addon events. Raid frame addons (Grid, VuhDo, Healbot) now properly hide when the group disbands. Also fires PARTY_MEMBERS_CHANGED for compat. --- 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 bce105c4..d7bde9a6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3750,6 +3750,10 @@ void GameHandler::handlePacket(network::Packet& packet) { addUIError("Your party has been disbanded."); addSystemChatMessage("Your party has been disbanded."); LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); + if (addonEventCallback_) { + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + } break; case Opcode::SMSG_GROUP_CANCEL: // Group invite was cancelled before being accepted. From 70a50e45f5082aba2ecc2e7c800841f289afc2e8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:12:38 -0700 Subject: [PATCH 69/74] feat: fire CONFIRM_TALENT_WIPE event with respec cost Fire CONFIRM_TALENT_WIPE with the gold cost when the trainer offers to reset talents (MSG_TALENT_WIPE_CONFIRM). Used by talent management addons to show the respec cost dialog. --- 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 d7bde9a6..87cc250a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6151,6 +6151,8 @@ void GameHandler::handlePacket(network::Packet& packet) { talentWipePending_ = true; LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_, std::dec, " cost=", talentWipeCost_); + if (addonEventCallback_) + addonEventCallback_("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)}); break; } From d24d12fb8fadeb68b74fad7b4aeec52e0d6f01e1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:17:20 -0700 Subject: [PATCH 70/74] feat: fire PARTY_INVITE_REQUEST event with inviter name Fire PARTY_INVITE_REQUEST when another player invites the local player to a group. Used by auto-accept group addons and invite notification addons. Includes the inviter's name as the first argument. --- 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 87cc250a..af2443d4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20028,6 +20028,8 @@ void GameHandler::handleGroupInvite(network::Packet& packet) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playTargetSelect(); } + if (addonEventCallback_) + addonEventCallback_("PARTY_INVITE_REQUEST", {data.inviterName}); } void GameHandler::handleGroupDecline(network::Packet& packet) { From 6ab1a189c7ddcb24237c3a585fa4387711ecbc2b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:23:15 -0700 Subject: [PATCH 71/74] feat: fire GUILD_INVITE_REQUEST event with inviter and guild names Fire GUILD_INVITE_REQUEST when another player invites the local player to a guild. Includes inviterName and guildName as arguments. Used by auto-accept guild addons and invitation notification addons. --- 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 af2443d4..0b025bcb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20820,6 +20820,8 @@ void GameHandler::handleGuildInvite(network::Packet& packet) { pendingGuildInviteGuildName_ = data.guildName; LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName); addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + "."); + if (addonEventCallback_) + addonEventCallback_("GUILD_INVITE_REQUEST", {data.inviterName, data.guildName}); } void GameHandler::handleGuildCommandResult(network::Packet& packet) { From c6a6849c8691c7c8e089d0e9d53757539d3b1ff7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:30:01 -0700 Subject: [PATCH 72/74] feat: fire PET_BAR_UPDATE event when pet action bar changes Fire PET_BAR_UPDATE when: - Pet is summoned (SMSG_PET_SPELLS with new spell list) - Pet learns a new spell (SMSG_PET_LEARNED_SPELL) Used by pet action bar addons to refresh their display when the pet's available abilities change. --- 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 0b025bcb..7f5da2c2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7993,6 +7993,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const std::string& sname = getSpellName(spellId); addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + ".")); LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); + if (addonEventCallback_) addonEventCallback_("PET_BAR_UPDATE", {}); } packet.setReadPos(packet.getSize()); break; @@ -19017,7 +19018,10 @@ done: LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec, " react=", (int)petReact_, " command=", (int)petCommand_, " spells=", petSpellList_.size()); - if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); + if (addonEventCallback_) { + addonEventCallback_("UNIT_PET", {"player"}); + addonEventCallback_("PET_BAR_UPDATE", {}); + } } void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { From 774f9bf214774010e24046602b205ed1bba1fb54 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:33:22 -0700 Subject: [PATCH 73/74] feat: fire BAG_UPDATE and UNIT_INVENTORY_CHANGED on item received Fire BAG_UPDATE and UNIT_INVENTORY_CHANGED from SMSG_ITEM_PUSH_RESULT when any item is received (loot, quest reward, trade, mail). Bag addons (Bagnon, AdiBags) immediately show new items, and equipment tracking addons detect inventory changes. --- src/game/game_handler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7f5da2c2..cfba79f2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2010,6 +2010,11 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingItemPushNotifs_.push_back({itemId, count}); } } + // Fire bag/inventory events for all item receipts (not just chat-visible ones) + if (addonEventCallback_) { + addonEventCallback_("BAG_UPDATE", {}); + addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"}); + } LOG_INFO("Item push: itemId=", itemId, " count=", count, " showInChat=", static_cast(showInChat)); } From 5ee2b55f4bc2c9ea92829aa1e531e7de97292b56 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:48:06 -0700 Subject: [PATCH 74/74] feat: fire MIRROR_TIMER_START and MIRROR_TIMER_STOP events Fire MIRROR_TIMER_START with type, value, maxValue, scale, and paused args when breath/fatigue/fire timers begin. Fire MIRROR_TIMER_STOP with type when they end. Timer types: 0=fatigue, 1=breath, 2=fire. Used by timer bar addons to display breath/fatigue countdown overlays. --- 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 cfba79f2..164e9d52 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2275,6 +2275,11 @@ void GameHandler::handlePacket(network::Packet& packet) { mirrorTimers_[type].scale = scale; mirrorTimers_[type].paused = (paused != 0); mirrorTimers_[type].active = true; + if (addonEventCallback_) + addonEventCallback_("MIRROR_TIMER_START", { + std::to_string(type), std::to_string(value), + std::to_string(maxV), std::to_string(scale), + paused ? "1" : "0"}); } break; } @@ -2285,6 +2290,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (type < 3) { mirrorTimers_[type].active = false; mirrorTimers_[type].value = 0; + if (addonEventCallback_) + addonEventCallback_("MIRROR_TIMER_STOP", {std::to_string(type)}); } break; }