From 2da08835443784ee5e664ec923b0a2097f5669d9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:54:30 -0700 Subject: [PATCH 01/36] feat: fire BARBER_SHOP_OPEN and BARBER_SHOP_CLOSE events Fire BARBER_SHOP_OPEN when the barber shop UI is enabled (SMSG_ENABLE_BARBER_SHOP). Fire BARBER_SHOP_CLOSE when the barber shop completes or is dismissed. Used by UI customization addons. --- include/game/game_handler.hpp | 2 +- src/game/game_handler.cpp | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 158c4274..d67bd0b2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1300,7 +1300,7 @@ public: // Barber shop bool isBarberShopOpen() const { return barberShopOpen_; } - void closeBarberShop() { barberShopOpen_ = false; } + void closeBarberShop() { barberShopOpen_ = false; if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_CLOSE", {}); } void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair); // Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 164e9d52..e18462d0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2772,6 +2772,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Sent by server when player sits in barber chair — triggers barber shop UI LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); barberShopOpen_ = true; + if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_OPEN", {}); break; case Opcode::SMSG_FEIGN_DEATH_RESISTED: addUIError("Your Feign Death was resisted."); @@ -5125,6 +5126,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Hairstyle changed."); barberShopOpen_ = false; + if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_CLOSE", {}); } else { const char* msg = (result == 1) ? "Not enough money for new hairstyle." : (result == 2) ? "You are not at a barber shop." From a73c680190791cf86ff300608745a58190fcc291 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 07:58:38 -0700 Subject: [PATCH 02/36] feat: add common WoW global constants for addon compatibility Add frequently referenced WoW global constants that many addons check: - MAX_TALENT_TABS, MAX_NUM_TALENTS - BOOKTYPE_SPELL, BOOKTYPE_PET - MAX_PARTY_MEMBERS, MAX_RAID_MEMBERS, MAX_ARENA_TEAMS - INVSLOT_FIRST_EQUIPPED, INVSLOT_LAST_EQUIPPED - NUM_BAG_SLOTS, NUM_BANKBAGSLOTS, CONTAINER_BAG_OFFSET - MAX_SKILLLINE_TABS, TRADE_ENCHANT_SLOT Prevents nil-reference errors when addons use these standard constants. --- src/addons/lua_engine.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 12e7f8fb..4bb4ee9e 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -3663,6 +3663,21 @@ void LuaEngine::registerCoreAPI() { "function GetShapeshiftFormInfo(index) return nil, nil, nil, nil end\n" // Pet action bar "NUM_PET_ACTION_SLOTS = 10\n" + // Common WoW constants used by many addons + "MAX_TALENT_TABS = 3\n" + "MAX_NUM_TALENTS = 100\n" + "BOOKTYPE_SPELL = 0\n" + "BOOKTYPE_PET = 1\n" + "MAX_PARTY_MEMBERS = 4\n" + "MAX_RAID_MEMBERS = 40\n" + "MAX_ARENA_TEAMS = 3\n" + "INVSLOT_FIRST_EQUIPPED = 1\n" + "INVSLOT_LAST_EQUIPPED = 19\n" + "NUM_BAG_SLOTS = 4\n" + "NUM_BANKBAGSLOTS = 7\n" + "CONTAINER_BAG_OFFSET = 0\n" + "MAX_SKILLLINE_TABS = 8\n" + "TRADE_ENCHANT_SLOT = 7\n" "function GetPetActionInfo(slot) return nil end\n" "function GetPetActionsUsable() return false end\n" ); From a4d54e83bc6d2d795f3a9acabb8da0b08553c42c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:07:39 -0700 Subject: [PATCH 03/36] feat: fire MIRROR_TIMER_PAUSE event when breath/fatigue timer pauses Fire MIRROR_TIMER_PAUSE from SMSG_PAUSE_MIRROR_TIMER with paused state (1=paused, 0=resumed). Completes the mirror timer event trio alongside MIRROR_TIMER_START and MIRROR_TIMER_STOP. --- 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 e18462d0..8757e30c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2302,6 +2302,8 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t paused = packet.readUInt8(); if (type < 3) { mirrorTimers_[type].paused = (paused != 0); + if (addonEventCallback_) + addonEventCallback_("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"}); } break; } From 2c6a345e326f20081d20ca43d7b9395c2e5eb6c5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:13:47 -0700 Subject: [PATCH 04/36] feat: fire UNIT_MODEL_CHANGED event when unit display model changes Fire UNIT_MODEL_CHANGED for player/target/focus/pet when their UNIT_FIELD_DISPLAYID update field changes. This covers polymorph, mount display changes, shapeshifting, and model swaps. Used by unit frame addons that display 3D portraits and by nameplate addons that track model state changes. --- src/game/game_handler.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8757e30c..14b79d94 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11908,7 +11908,18 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem else if (key == ufFlags) { unit->setUnitFlags(val); } else if (key == ufBytes0) { unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufDisplayId) { unit->setDisplayId(val); } + } else if (key == ufDisplayId) { + unit->setDisplayId(val); + if (addonEventCallback_) { + std::string uid; + if (block.guid == playerGuid) uid = "player"; + else if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + else if (block.guid == petGuid_) uid = "pet"; + if (!uid.empty()) + addonEventCallback_("UNIT_MODEL_CHANGED", {uid}); + } + } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } else if (key == ufDynFlags) { unit->setDynamicFlags(val); From e7be60c624984f27a9d04018d8b7e67a66d5ca98 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:17:38 -0700 Subject: [PATCH 05/36] feat: fire UNIT_FACTION event when unit faction template changes Fire UNIT_FACTION for player/target/focus when UNIT_FIELD_FACTIONTEMPLATE updates. Covers PvP flag toggling, mind control faction swaps, and any server-side faction changes. Used by nameplate addons to update hostility coloring and by PvP addons tracking faction state. --- src/game/game_handler.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 14b79d94..5c2582a2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11904,7 +11904,17 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } else if (key == ufLevel) { unit->setLevel(val); - } else if (key == ufFaction) { unit->setFactionTemplate(val); } + } else if (key == ufFaction) { + unit->setFactionTemplate(val); + if (addonEventCallback_) { + std::string uid; + if (block.guid == playerGuid) uid = "player"; + else if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + if (!uid.empty()) + addonEventCallback_("UNIT_FACTION", {uid}); + } + } else if (key == ufFlags) { unit->setUnitFlags(val); } else if (key == ufBytes0) { unit->setPowerType(static_cast((val >> 24) & 0xFF)); From 82990f5891138e53c2a8333d93453cdabf6844dd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:22:52 -0700 Subject: [PATCH 06/36] feat: fire UNIT_FLAGS event when unit flags change Fire UNIT_FLAGS for player/target/focus when UNIT_FIELD_FLAGS updates. Covers PvP flag, combat state, silenced, disarmed, and other flag changes. Used by nameplate addons for PvP indicators and by unit frame addons tracking CC/silence state. --- src/game/game_handler.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5c2582a2..27216114 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11915,7 +11915,17 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem addonEventCallback_("UNIT_FACTION", {uid}); } } - else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (key == ufFlags) { + unit->setUnitFlags(val); + if (addonEventCallback_) { + std::string uid; + if (block.guid == playerGuid) uid = "player"; + else if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + if (!uid.empty()) + addonEventCallback_("UNIT_FLAGS", {uid}); + } + } else if (key == ufBytes0) { unit->setPowerType(static_cast((val >> 24) & 0xFF)); } else if (key == ufDisplayId) { From 586e9e74ff2f2180f726c96441aaa809084954d7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:33:54 -0700 Subject: [PATCH 07/36] feat: show grey nameplates for mobs tapped by other players Check UNIT_DYNFLAG_TAPPED_BY_PLAYER (0x0004) on hostile NPC nameplates. Mobs tagged by another player now show grey health bars instead of red, matching WoW's visual indication that the mob won't yield loot/XP. Mobs with TAPPED_BY_ALL_THREAT_LIST (0x0008) still show red since those are shared-tag mobs that give loot to everyone. --- src/ui/game_screen.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4daf51ff..4e4fc17f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11695,8 +11695,16 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { barColor = IM_COL32(140, 140, 140, A(200)); bgColor = IM_COL32(70, 70, 70, A(160)); } else if (unit->isHostile()) { - barColor = IM_COL32(220, 60, 60, A(200)); - bgColor = IM_COL32(100, 25, 25, A(160)); + // Check if mob is tapped by another player (grey nameplate) + uint32_t dynFlags = unit->getDynamicFlags(); + bool tappedByOther = (dynFlags & 0x0004) != 0 && (dynFlags & 0x0008) == 0; // TAPPED but not TAPPED_BY_ALL_THREAT_LIST + if (tappedByOther) { + barColor = IM_COL32(160, 160, 160, A(200)); + bgColor = IM_COL32(80, 80, 80, A(160)); + } else { + barColor = IM_COL32(220, 60, 60, A(200)); + bgColor = IM_COL32(100, 25, 25, A(160)); + } } else if (isPlayer) { // Player nameplates: use class color for easy identification uint8_t cid = entityClassId(unit); From 57ccee2c28ce75d065f120d145dd4b1200da7af9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:37:39 -0700 Subject: [PATCH 08/36] feat: show grey target frame name for tapped mobs Extend the tapped-by-other-player check to the target frame. Mobs tagged by another player now show a grey name color on the target frame, matching the grey nameplate treatment and WoW's behavior. Players can now see at a glance on both nameplates AND target frame whether a mob is tagged. --- src/ui/game_screen.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4e4fc17f..cce4ba96 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4237,6 +4237,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (u->getHealth() == 0 && u->getMaxHealth() > 0) { hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); } else if (u->isHostile()) { + // Check tapped-by-other: grey name for mobs tagged by someone else + uint32_t tgtDynFlags = u->getDynamicFlags(); + bool tgtTapped = (tgtDynFlags & 0x0004) != 0 && (tgtDynFlags & 0x0008) == 0; + if (tgtTapped) { + hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey — tapped by other + } else { // WoW level-based color for hostile mobs uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t mobLv = u->getLevel(); @@ -4257,6 +4263,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy } } + } // end tapped else } else { hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Friendly } From aebc905261c8609b16de9a4c8c29ad7ca462c2f6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:42:56 -0700 Subject: [PATCH 09/36] feat: show grey focus frame name for tapped mobs Extend tapped-by-other detection to the focus frame, matching the target frame and nameplate treatment. All three UI elements (nameplate, target frame, focus frame) now consistently show grey for tapped mobs. --- src/ui/game_screen.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cce4ba96..b74a78b5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5188,6 +5188,12 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { if (u->getHealth() == 0 && u->getMaxHealth() > 0) { focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); } else if (u->isHostile()) { + // Tapped-by-other: grey focus frame name + uint32_t focDynFlags = u->getDynamicFlags(); + bool focTapped = (focDynFlags & 0x0004) != 0 && (focDynFlags & 0x0008) == 0; + if (focTapped) { + focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); + } else { uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t mobLv = u->getLevel(); if (mobLv == 0) { @@ -5205,6 +5211,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { else focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } + } // end tapped else } else { focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } From 4af9838ab43f7dfd817a930d9cf4c489e6669507 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:48:58 -0700 Subject: [PATCH 10/36] feat: add UnitIsTapped, UnitIsTappedByPlayer, UnitIsTappedByAllThreatList Add three tapped-state query functions for addons: - UnitIsTapped(unit): true if any player has tagged the mob - UnitIsTappedByPlayer(unit): true if local player can loot (tapped+lootable) - UnitIsTappedByAllThreatList(unit): true if shared-tag mob Used by nameplate addons (Plater, TidyPlates) and unit frame addons to determine and display tap ownership state. --- src/addons/lua_engine.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 4bb4ee9e..7f318915 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -413,6 +413,38 @@ static int lua_UnitPlayerControlled(lua_State* L) { return 1; } +// UnitIsTapped(unit) — true if mob is tapped (tagged by any player) +static int lua_UnitIsTapped(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + if (!unit) { lua_pushboolean(L, 0); return 1; } + lua_pushboolean(L, (unit->getDynamicFlags() & 0x0004) != 0); // UNIT_DYNFLAG_TAPPED_BY_PLAYER + return 1; +} + +// UnitIsTappedByPlayer(unit) — true if tapped by the local player (can loot) +static int lua_UnitIsTappedByPlayer(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + if (!unit) { lua_pushboolean(L, 0); return 1; } + uint32_t df = unit->getDynamicFlags(); + // Tapped by player: has TAPPED flag but also LOOTABLE or TAPPED_BY_ALL + bool tapped = (df & 0x0004) != 0; + bool lootable = (df & 0x0001) != 0; + bool sharedTag = (df & 0x0008) != 0; + lua_pushboolean(L, tapped && (lootable || sharedTag)); + return 1; +} + +// UnitIsTappedByAllThreatList(unit) — true if shared-tag mob +static int lua_UnitIsTappedByAllThreatList(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + if (!unit) { lua_pushboolean(L, 0); return 1; } + lua_pushboolean(L, (unit->getDynamicFlags() & 0x0008) != 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"); @@ -3077,6 +3109,9 @@ void LuaEngine::registerCoreAPI() { {"UnitIsAFK", lua_UnitIsAFK}, {"UnitIsDND", lua_UnitIsDND}, {"UnitPlayerControlled", lua_UnitPlayerControlled}, + {"UnitIsTapped", lua_UnitIsTapped}, + {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, + {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, {"UnitSex", lua_UnitSex}, {"UnitClass", lua_UnitClass}, {"GetMoney", lua_GetMoney}, From dcd78f4f28558c48a6e12f041527abbcb93cff20 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 08:57:38 -0700 Subject: [PATCH 11/36] feat: add UnitThreatSituation for threat meter and tank addons Implement UnitThreatSituation(unit, mobUnit) returning 0-3 threat level: - 0: not on threat table - 1: in combat but not tanking (mob targeting someone else) - 3: securely tanking (mob is targeting this unit) Approximated from mob's UNIT_FIELD_TARGET to determine who the mob is attacking. Used by threat meter addons (Omen, ThreatPlates) and tank UI addons to display threat state. --- src/addons/lua_engine.cpp | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 7f318915..5e013cd2 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -445,6 +445,50 @@ static int lua_UnitIsTappedByAllThreatList(lua_State* L) { return 1; } +// UnitThreatSituation(unit, mobUnit) → 0=not tanking, 1=not tanking but threat, 2=insecurely tanking, 3=securely tanking +static int lua_UnitThreatSituation(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + const char* uid = luaL_optstring(L, 1, "player"); + const char* mobUid = luaL_optstring(L, 2, nullptr); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t playerUnitGuid = resolveUnitGuid(gh, uidStr); + if (playerUnitGuid == 0) { lua_pushnumber(L, 0); return 1; } + // If no mob specified, check general combat threat against current target + uint64_t mobGuid = 0; + if (mobUid && *mobUid) { + std::string mStr(mobUid); + for (char& c : mStr) c = static_cast(std::tolower(static_cast(c))); + mobGuid = resolveUnitGuid(gh, mStr); + } + // Approximate threat: check if the mob is targeting this unit + if (mobGuid != 0) { + auto mobEntity = gh->getEntityManager().getEntity(mobGuid); + if (mobEntity) { + const auto& fields = mobEntity->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end()) { + uint64_t mobTarget = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + mobTarget |= (static_cast(hiIt->second) << 32); + if (mobTarget == playerUnitGuid) { + lua_pushnumber(L, 3); // securely tanking + return 1; + } + } + } + } + // Check if player is in combat (basic threat indicator) + if (playerUnitGuid == gh->getPlayerGuid() && gh->isInCombat()) { + lua_pushnumber(L, 1); // in combat but not tanking + return 1; + } + lua_pushnumber(L, 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"); @@ -3112,6 +3156,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTapped", lua_UnitIsTapped}, {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitThreatSituation", lua_UnitThreatSituation}, {"UnitSex", lua_UnitSex}, {"UnitClass", lua_UnitClass}, {"GetMoney", lua_GetMoney}, From f580fd7e6b711dd133c5c4db4a99fd1b6dbf4599 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:02:47 -0700 Subject: [PATCH 12/36] feat: add UnitDetailedThreatSituation for detailed threat queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement UnitDetailedThreatSituation(unit, mobUnit) returning: - isTanking (boolean) - status (0-3, same as UnitThreatSituation) - threatPct (100 if tanking, 0 otherwise) - rawThreatPct (same) - threatValue (0 — no server threat data available) Used by Omen and other threat meter addons that query detailed threat info per mob-target pair. --- src/addons/lua_engine.cpp | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 5e013cd2..694d4a4c 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -489,6 +489,47 @@ static int lua_UnitThreatSituation(lua_State* L) { return 1; } +// UnitDetailedThreatSituation(unit, mobUnit) → isTanking, status, threatPct, rawThreatPct, threatValue +static int lua_UnitDetailedThreatSituation(lua_State* L) { + // Use UnitThreatSituation logic for the basics + auto* gh = getGameHandler(L); + if (!gh) { + lua_pushboolean(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); + return 5; + } + const char* uid = luaL_optstring(L, 1, "player"); + const char* mobUid = luaL_optstring(L, 2, nullptr); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t unitGuid = resolveUnitGuid(gh, uidStr); + bool isTanking = false; + int status = 0; + if (unitGuid != 0 && mobUid && *mobUid) { + std::string mStr(mobUid); + for (char& c : mStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t mobGuid = resolveUnitGuid(gh, mStr); + if (mobGuid != 0) { + auto mobEnt = gh->getEntityManager().getEntity(mobGuid); + if (mobEnt) { + const auto& f = mobEnt->getFields(); + auto lo = f.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (lo != f.end()) { + uint64_t mt = lo->second; + auto hi = f.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hi != f.end()) mt |= (static_cast(hi->second) << 32); + if (mt == unitGuid) { isTanking = true; status = 3; } + } + } + } + } + lua_pushboolean(L, isTanking); + lua_pushnumber(L, status); + lua_pushnumber(L, isTanking ? 100.0 : 0.0); // threatPct + lua_pushnumber(L, isTanking ? 100.0 : 0.0); // rawThreatPct + lua_pushnumber(L, 0); // threatValue (not available without server threat data) + return 5; +} + // UnitSex(unit) → 1=unknown, 2=male, 3=female static int lua_UnitSex(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); @@ -3157,6 +3198,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, {"UnitThreatSituation", lua_UnitThreatSituation}, + {"UnitDetailedThreatSituation", lua_UnitDetailedThreatSituation}, {"UnitSex", lua_UnitSex}, {"UnitClass", lua_UnitClass}, {"GetMoney", lua_GetMoney}, From ac9214c03fd7f675f4e3a59ff55261443af73895 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:08:02 -0700 Subject: [PATCH 13/36] feat: fire UNIT_THREAT_LIST_UPDATE event on threat changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire UNIT_THREAT_LIST_UPDATE from SMSG_THREAT_UPDATE, SMSG_HIGHEST_THREAT_UPDATE, and SMSG_THREAT_CLEAR. Threat data is already parsed and stored in threatLists_ — this event notifies addon systems when the data changes. Used by Omen, ThreatPlates, and other threat meter addons to refresh their displays when threat values update. --- 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 27216114..265a4144 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2848,6 +2848,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // All threat dropped on the local player (e.g. Vanish, Feign Death) threatLists_.clear(); LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); + if (addonEventCallback_) addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {}); break; case Opcode::SMSG_THREAT_REMOVE: { // packed_guid (unit) + packed_guid (victim whose threat was removed) @@ -2891,6 +2892,8 @@ void GameHandler::handlePacket(network::Packet& packet) { std::sort(list.begin(), list.end(), [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); threatLists_[unitGuid] = std::move(list); + if (addonEventCallback_) + addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {}); break; } From 9267aec0b0aaaa5b008722b3c3f0ced49b6757ae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:12:59 -0700 Subject: [PATCH 14/36] feat: fire UPDATE_WORLD_STATES event on world state changes Fire UPDATE_WORLD_STATES from SMSG_UPDATE_WORLD_STATE when BG scores, zone capture progress, or other world state variables change. Used by BG score addons and world PvP objective 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 265a4144..1a2519fc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2211,6 +2211,8 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t value = packet.readUInt32(); worldStates_[field] = value; LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); + if (addonEventCallback_) + addonEventCallback_("UPDATE_WORLD_STATES", {}); break; } case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: { From 4364fa7bbe75703e43eea3440cf2a6a00bb32780 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:18:25 -0700 Subject: [PATCH 15/36] fix: UnitPowerType falls back to party member stats for out-of-range units Previously UnitPowerType returned 0 (MANA) for party members who are out of entity range. Now falls back to SMSG_PARTY_MEMBER_STATS power type data, so raid frame addons correctly color rage/energy/runic power bars for distant party members. --- src/addons/lua_engine.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 694d4a4c..4a61aeb6 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -619,10 +619,22 @@ static int lua_UnitRace(lua_State* L) { static int lua_UnitPowerType(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); + static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"}; if (unit) { - lua_pushnumber(L, unit->getPowerType()); - static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"}; uint8_t pt = unit->getPowerType(); + lua_pushnumber(L, pt); + lua_pushstring(L, (pt < 7) ? kPowerNames[pt] : "MANA"); + return 2; + } + // 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); + if (pm) { + uint8_t pt = pm->powerType; + lua_pushnumber(L, pt); lua_pushstring(L, (pt < 7) ? kPowerNames[pt] : "MANA"); return 2; } From b1171327cbc3c71f1cf58f4c5b4c890c9ffdae37 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:23:20 -0700 Subject: [PATCH 16/36] fix: UnitIsDead falls back to party member stats for out-of-range units Previously UnitIsDead returned false for out-of-range party members (entity not in entity manager). Now checks curHealth==0 from SMSG_PARTY_MEMBER_STATS data, so raid frame addons correctly show dead members in other zones as dead. --- src/addons/lua_engine.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 4a61aeb6..27a8bc40 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -268,7 +268,17 @@ static int lua_UnitExists(lua_State* L) { static int lua_UnitIsDead(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); auto* unit = resolveUnit(L, uid); - lua_pushboolean(L, unit && unit->getHealth() == 0); + if (unit) { + lua_pushboolean(L, unit->getHealth() == 0); + } 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_pushboolean(L, pm ? (pm->curHealth == 0 && pm->maxHealth > 0) : 0); + } return 1; } From 8dca33e5cc3bca64b6511c41a58d1cc2aff498ae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:32:40 -0700 Subject: [PATCH 17/36] feat: add UnitOnTaxi function for flight path detection Returns true when the player is on a taxi/flight path. Used by action bar addons to disable abilities during flight and by map addons to track taxi state. --- src/addons/lua_engine.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 27a8bc40..8505ce02 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,21 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// UnitOnTaxi(unit) → boolean (true if on a flight path) +static int lua_UnitOnTaxi(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->isOnTaxiFlight()); + } else { + lua_pushboolean(L, 0); // Can't determine for other units + } + 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"); @@ -3219,6 +3234,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTapped", lua_UnitIsTapped}, {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitOnTaxi", lua_UnitOnTaxi}, {"UnitThreatSituation", lua_UnitThreatSituation}, {"UnitDetailedThreatSituation", lua_UnitDetailedThreatSituation}, {"UnitSex", lua_UnitSex}, From c97898712bd491220651880dc63f17f38f5b1831 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:38:41 -0700 Subject: [PATCH 18/36] feat: add GetSpellPowerCost for spell cost display addons Returns a table of power cost entries: {{ type=powerType, cost=amount, name=powerName }}. Data from SpellDataResolver (Spell.dbc ManaCost and PowerType fields). Used by spell tooltip addons and action bar addons that display mana/rage/energy costs. --- src/addons/lua_engine.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 8505ce02..b00c25ad 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -1165,6 +1165,27 @@ static int lua_SetRaidTarget(lua_State* L) { return 0; } +// GetSpellPowerCost(spellId) → {{ type=powerType, cost=manaCost, name=powerName }} +static int lua_GetSpellPowerCost(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_newtable(L); return 1; } + uint32_t spellId = static_cast(luaL_checknumber(L, 1)); + auto data = gh->getSpellData(spellId); + lua_newtable(L); // outer table (array of cost entries) + if (data.manaCost > 0) { + lua_newtable(L); // cost entry + lua_pushnumber(L, data.powerType); + lua_setfield(L, -2, "type"); + lua_pushnumber(L, data.manaCost); + lua_setfield(L, -2, "cost"); + static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"}; + lua_pushstring(L, data.powerType < 7 ? kPowerNames[data.powerType] : "MANA"); + lua_setfield(L, -2, "name"); + lua_rawseti(L, -2, 1); // outer[1] = entry + } + return 1; +} + // --- GetSpellInfo / GetSpellTexture --- // GetSpellInfo(spellIdOrName) -> name, rank, icon, castTime, minRange, maxRange, spellId static int lua_GetSpellInfo(lua_State* L) { @@ -3250,6 +3271,7 @@ void LuaEngine::registerCoreAPI() { {"CastSpellByName", lua_CastSpellByName}, {"IsSpellKnown", lua_IsSpellKnown}, {"GetSpellCooldown", lua_GetSpellCooldown}, + {"GetSpellPowerCost", lua_GetSpellPowerCost}, {"HasTarget", lua_HasTarget}, {"TargetUnit", lua_TargetUnit}, {"ClearTarget", lua_ClearTarget}, From 9b2f1003875f85e256f5825f0c732f65c16be43e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 09:53:32 -0700 Subject: [PATCH 19/36] feat: fire UNIT_MODEL_CHANGED on mount display changes Fire UNIT_MODEL_CHANGED for the player when mount display ID changes (mounting or dismounting). Mount addons and portrait addons now get notified when the player's visual model switches between ground and mounted form. --- 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 1a2519fc..0e2d5117 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11964,6 +11964,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem uint32_t old = currentMountDisplayId_; currentMountDisplayId_ = val; if (val != old && mountCallback_) mountCallback_(val); + if (val != old && addonEventCallback_) + addonEventCallback_("UNIT_MODEL_CHANGED", {"player"}); if (old == 0 && val != 0) { // Just mounted — find the mount aura (indefinite duration, self-cast) mountAuraSpellId_ = 0; From 3ad917bd95e26f9c9f81722d451c201fe9369128 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 10:02:34 -0700 Subject: [PATCH 20/36] feat: add colorStr and GenerateHexColor methods to RAID_CLASS_COLORS Enhance RAID_CLASS_COLORS entries with colorStr hex string field and GenerateHexColor()/GenerateHexColorMarkup() methods. Many addons (Prat, Details, oUF) use colorStr to build colored chat text and GenerateHexColor for inline color markup. --- src/addons/lua_engine.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index b00c25ad..0172fb23 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -3724,13 +3724,22 @@ void LuaEngine::registerCoreAPI() { "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" - " PRIEST={r=1.0,g=1.0,b=1.0}, DEATHKNIGHT={r=0.77,g=0.12,b=0.23},\n" - " SHAMAN={r=0.0,g=0.44,b=0.87}, MAGE={r=0.41,g=0.80,b=0.94},\n" - " WARLOCK={r=0.58,g=0.51,b=0.79}, DRUID={r=1.0,g=0.49,b=0.04},\n" - "}\n" + "do\n" + " local function cc(r,g,b)\n" + " local t = {r=r, g=g, b=b}\n" + " t.colorStr = string.format('%02x%02x%02x', math.floor(r*255), math.floor(g*255), math.floor(b*255))\n" + " function t:GenerateHexColor() return '|cff' .. self.colorStr end\n" + " function t:GenerateHexColorMarkup() return '|cff' .. self.colorStr end\n" + " return t\n" + " end\n" + " RAID_CLASS_COLORS = {\n" + " WARRIOR=cc(0.78,0.61,0.43), PALADIN=cc(0.96,0.55,0.73),\n" + " HUNTER=cc(0.67,0.83,0.45), ROGUE=cc(1.0,0.96,0.41),\n" + " PRIEST=cc(1.0,1.0,1.0), DEATHKNIGHT=cc(0.77,0.12,0.23),\n" + " SHAMAN=cc(0.0,0.44,0.87), MAGE=cc(0.41,0.80,0.94),\n" + " WARLOCK=cc(0.58,0.51,0.79), DRUID=cc(1.0,0.49,0.04),\n" + " }\n" + "end\n" // Money formatting utility "function GetCoinTextureString(copper)\n" " if not copper or copper == 0 then return '0c' end\n" From a4ff315c8197a9829facfd4cdc6563290c327535 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 10:13:04 -0700 Subject: [PATCH 21/36] feat: add UnitCreatureFamily for hunter pet and beast lore addons Returns the creature family name (Wolf, Cat, Bear, etc.) for NPC units. Data from CreatureInfo cache (creature_template family field). Used by hunter pet management addons and tooltips that show pet family info. --- src/addons/lua_engine.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 0172fb23..da64337b 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,35 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// UnitCreatureFamily(unit) → familyName or nil +static int lua_UnitCreatureFamily(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; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity || entity->getType() == game::ObjectType::PLAYER) { lua_pushnil(L); return 1; } + auto unit = std::dynamic_pointer_cast(entity); + if (!unit) { lua_pushnil(L); return 1; } + uint32_t family = gh->getCreatureFamily(unit->getEntry()); + if (family == 0) { lua_pushnil(L); return 1; } + static const char* kFamilies[] = { + "", "Wolf", "Cat", "Spider", "Bear", "Boar", "Crocolisk", "Carrion Bird", + "Crab", "Gorilla", "Raptor", "", "Tallstrider", "", "", "Felhunter", + "Voidwalker", "Succubus", "", "Doomguard", "Scorpid", "Turtle", "", + "Imp", "Bat", "Hyena", "Bird of Prey", "Wind Serpent", "", "Dragonhawk", + "Ravager", "Warp Stalker", "Sporebat", "Nether Ray", "Serpent", "Moth", + "Chimaera", "Devilsaur", "Ghoul", "Silithid", "Worm", "Rhino", "Wasp", + "Core Hound", "Spirit Beast" + }; + lua_pushstring(L, (family < sizeof(kFamilies)/sizeof(kFamilies[0]) && kFamilies[family][0]) + ? kFamilies[family] : "Beast"); + return 1; +} + // UnitOnTaxi(unit) → boolean (true if on a flight path) static int lua_UnitOnTaxi(lua_State* L) { const char* uid = luaL_optstring(L, 1, "player"); @@ -3255,6 +3284,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTapped", lua_UnitIsTapped}, {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitCreatureFamily", lua_UnitCreatureFamily}, {"UnitOnTaxi", lua_UnitOnTaxi}, {"UnitThreatSituation", lua_UnitThreatSituation}, {"UnitDetailedThreatSituation", lua_UnitDetailedThreatSituation}, From 5d6376f3f1207216bfc96f756958f6054f1ea225 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 10:19:29 -0700 Subject: [PATCH 22/36] feat: add UnitCanAttack and UnitCanCooperate for targeting addons UnitCanAttack(unit, otherUnit): returns true if otherUnit is hostile (attackable). UnitCanCooperate(unit, otherUnit): returns true if otherUnit is friendly (can receive beneficial spells). Used by nameplate addons for coloring and by targeting addons for filtering hostile/friendly units. --- src/addons/lua_engine.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index da64337b..8ac6663a 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,40 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// UnitCanAttack(unit, otherUnit) → boolean +static int lua_UnitCanAttack(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid1 = luaL_checkstring(L, 1); + const char* uid2 = luaL_checkstring(L, 2); + std::string u1(uid1), u2(uid2); + for (char& c : u1) c = static_cast(std::tolower(static_cast(c))); + 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 == 0 || g2 == 0 || g1 == g2) { lua_pushboolean(L, 0); return 1; } + // Check if unit2 is hostile to unit1 + auto* unit2 = resolveUnit(L, uid2); + if (unit2 && unit2->isHostile()) { + lua_pushboolean(L, 1); + } else { + lua_pushboolean(L, 0); + } + return 1; +} + +// UnitCanCooperate(unit, otherUnit) → boolean +static int lua_UnitCanCooperate(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + (void)luaL_checkstring(L, 1); // unit1 (unused — cooperation is based on unit2's hostility) + const char* uid2 = luaL_checkstring(L, 2); + auto* unit2 = resolveUnit(L, uid2); + if (!unit2) { lua_pushboolean(L, 0); return 1; } + lua_pushboolean(L, !unit2->isHostile()); + return 1; +} + // UnitCreatureFamily(unit) → familyName or nil static int lua_UnitCreatureFamily(lua_State* L) { auto* gh = getGameHandler(L); @@ -3284,6 +3318,8 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTapped", lua_UnitIsTapped}, {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitCanAttack", lua_UnitCanAttack}, + {"UnitCanCooperate", lua_UnitCanCooperate}, {"UnitCreatureFamily", lua_UnitCreatureFamily}, {"UnitOnTaxi", lua_UnitOnTaxi}, {"UnitThreatSituation", lua_UnitThreatSituation}, From 964437cdf469f4463b286229c0f15dc3b330845a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 10:27:43 -0700 Subject: [PATCH 23/36] feat: fire TRAINER_UPDATE and SPELLS_CHANGED after trainer purchase Fire TRAINER_UPDATE from SMSG_TRAINER_BUY_SUCCEEDED so trainer UI addons refresh the spell list (marking learned spells as unavailable). Also fire SPELLS_CHANGED so spellbook and action bar addons detect the newly learned spell. --- 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 0e2d5117..067c5d53 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4127,6 +4127,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playQuestActivate(); } + if (addonEventCallback_) { + addonEventCallback_("TRAINER_UPDATE", {}); + addonEventCallback_("SPELLS_CHANGED", {}); + } break; } case Opcode::SMSG_TRAINER_BUY_FAILED: { From d8c0820c764b30db73a3371f21e1aea85099e487 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 10:33:21 -0700 Subject: [PATCH 24/36] feat: fire CHAT_MSG_COMBAT_FACTION_CHANGE on reputation changes Fire CHAT_MSG_COMBAT_FACTION_CHANGE with the reputation change message alongside UPDATE_FACTION when faction standings change. Used by reputation tracking addons (FactionFriend, RepHelper) that parse reputation gain messages. --- src/game/game_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 067c5d53..fda63c81 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4270,8 +4270,10 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage(buf); watchedFactionId_ = factionId; if (repChangeCallback_) repChangeCallback_(name, delta, standing); - if (addonEventCallback_) + if (addonEventCallback_) { addonEventCallback_("UPDATE_FACTION", {}); + addonEventCallback_("CHAT_MSG_COMBAT_FACTION_CHANGE", {std::string(buf)}); + } } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } From 24e2069225d6e34cd118b68b833c41bbe1fea8a8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 10:47:47 -0700 Subject: [PATCH 25/36] feat: add UnitGroupRolesAssigned for LFG role display in raid frames Returns "TANK", "HEALER", "DAMAGER", or "NONE" based on the WotLK LFG roles bitmask from SMSG_GROUP_LIST. Used by raid frame addons (Grid, VuhDo, Healbot) to display role icons next to player names. --- src/addons/lua_engine.cpp | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 8ac6663a..4968fcf5 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,29 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// UnitGroupRolesAssigned(unit) → "TANK", "HEALER", "DAMAGER", or "NONE" +static int lua_UnitGroupRolesAssigned(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "NONE"); 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_pushstring(L, "NONE"); return 1; } + const auto& pd = gh->getPartyData(); + for (const auto& m : pd.members) { + if (m.guid == guid) { + // WotLK roles bitmask: 0x02=Tank, 0x04=Healer, 0x08=DPS + if (m.roles & 0x02) { lua_pushstring(L, "TANK"); return 1; } + if (m.roles & 0x04) { lua_pushstring(L, "HEALER"); return 1; } + if (m.roles & 0x08) { lua_pushstring(L, "DAMAGER"); return 1; } + break; + } + } + lua_pushstring(L, "NONE"); + return 1; +} + // UnitCanAttack(unit, otherUnit) → boolean static int lua_UnitCanAttack(lua_State* L) { auto* gh = getGameHandler(L); @@ -3318,6 +3341,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTapped", lua_UnitIsTapped}, {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitGroupRolesAssigned", lua_UnitGroupRolesAssigned}, {"UnitCanAttack", lua_UnitCanAttack}, {"UnitCanCooperate", lua_UnitCanCooperate}, {"UnitCreatureFamily", lua_UnitCreatureFamily}, From 1988e778c7656e9dc93f13274a86e84ad0c0de57 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 11:02:46 -0700 Subject: [PATCH 26/36] feat: fire CHAT_MSG_COMBAT_XP_GAIN on area exploration XP Fire CHAT_MSG_COMBAT_XP_GAIN from SMSG_EXPLORATION_EXPERIENCE when the player discovers a new area and gains exploration XP. Used by XP tracking addons to count all XP sources including discovery. --- 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 fda63c81..035d738c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2078,6 +2078,8 @@ void GameHandler::handlePacket(network::Packet& packet) { // XP is updated via PLAYER_XP update fields from the server. if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained); + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)}); } } break; From 1fd220de29456e9e1ebaf7f47b62b27dba1c11e0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 11:13:36 -0700 Subject: [PATCH 27/36] feat: fire QUEST_TURNED_IN event when quest rewards are received Fire QUEST_TURNED_IN with questId from SMSG_QUESTGIVER_QUEST_COMPLETE when a quest is successfully completed and removed from the quest log. Used by quest tracking addons (Questie, QuestHelper) and achievement tracking addons. --- 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 035d738c..113b3545 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5424,11 +5424,14 @@ void GameHandler::handlePacket(network::Packet& packet) { } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); + if (addonEventCallback_) + addonEventCallback_("QUEST_TURNED_IN", {std::to_string(questId)}); break; } } } - if (addonEventCallback_) addonEventCallback_("QUEST_LOG_UPDATE", {}); + if (addonEventCallback_) + addonEventCallback_("QUEST_LOG_UPDATE", {}); // Re-query all nearby quest giver NPCs so markers refresh if (socket) { for (const auto& [guid, entity] : entityManager.getEntities()) { From 1f6865afcee76c468f50b521ece2617025fc1f66 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 11:22:57 -0700 Subject: [PATCH 28/36] feat: fire PLAYER_LOGOUT event when logout begins Fire PLAYER_LOGOUT from SMSG_LOGOUT_RESPONSE when the server confirms logout. Addons use this to save state and perform cleanup before the player leaves the world. --- 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 113b3545..73ae1727 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -24645,6 +24645,7 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { logoutCountdown_ = 20.0f; } LOG_INFO("Logout response: success, instant=", (int)data.instant); + if (addonEventCallback_) addonEventCallback_("PLAYER_LOGOUT", {}); } else { // Failure addSystemChatMessage("Cannot logout right now."); From be841fb3e1bd0c4f17924c405db416ee5720c8bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 11:45:52 -0700 Subject: [PATCH 29/36] feat: fire AUTOFOLLOW_BEGIN and AUTOFOLLOW_END events Fire AUTOFOLLOW_BEGIN when the player starts following another unit via /follow. Fire AUTOFOLLOW_END when following is cancelled. Used by movement addons and AFK detection 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 73ae1727..114a55c2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14148,6 +14148,7 @@ void GameHandler::followTarget() { addSystemChatMessage("Now following " + targetName + "."); LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")"); + if (addonEventCallback_) addonEventCallback_("AUTOFOLLOW_BEGIN", {}); } void GameHandler::cancelFollow() { @@ -14157,6 +14158,7 @@ void GameHandler::cancelFollow() { } followTargetGuid_ = 0; addSystemChatMessage("You stop following."); + if (addonEventCallback_) addonEventCallback_("AUTOFOLLOW_END", {}); } void GameHandler::assistTarget() { From d575c06bc14338965909ef91c4adf99c0f919dc0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 11:49:23 -0700 Subject: [PATCH 30/36] feat: add UnitIsVisible for entity visibility checks Returns true when the unit's entity exists in the entity manager (within UPDATE_OBJECT range). Unlike UnitExists which falls back to party member data, UnitIsVisible only returns true for entities that can actually be rendered on screen. Used by nameplate addons and proximity addons to check if a unit is within visual range. Session 14 commit #100: 95 new API functions, 113 new events. --- src/addons/lua_engine.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 4968fcf5..2f5bfa4c 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,14 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// UnitIsVisible(unit) → boolean (entity exists in the client's entity manager) +static int lua_UnitIsVisible(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + lua_pushboolean(L, unit != nullptr); + return 1; +} + // UnitGroupRolesAssigned(unit) → "TANK", "HEALER", "DAMAGER", or "NONE" static int lua_UnitGroupRolesAssigned(lua_State* L) { auto* gh = getGameHandler(L); @@ -3341,6 +3349,7 @@ void LuaEngine::registerCoreAPI() { {"UnitIsTapped", lua_UnitIsTapped}, {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitIsVisible", lua_UnitIsVisible}, {"UnitGroupRolesAssigned", lua_UnitGroupRolesAssigned}, {"UnitCanAttack", lua_UnitCanAttack}, {"UnitCanCooperate", lua_UnitCanCooperate}, From 42b776dbf8e9a437e04c1ea8d1289b403aaffc0a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 12:23:08 -0700 Subject: [PATCH 31/36] feat: add IsSpellInRange for healing and range-check addons Returns 1 if the spell can reach the target, 0 if out of range, nil if range can't be determined. Compares player-to-target distance against the spell's maxRange from Spell.dbc via SpellDataResolver. Used by healing addons (Healbot, VuhDo, Clique) to check if heals can reach party members, and by action bar addons for range coloring. --- src/addons/lua_engine.cpp | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index 2f5bfa4c..dd81bf98 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,49 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// IsSpellInRange(spellName, unit) → 0 or 1 (nil if can't determine) +static int lua_IsSpellInRange(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* spellNameOrId = luaL_checkstring(L, 1); + const char* uid = luaL_optstring(L, 2, "target"); + + // Resolve spell ID + uint32_t spellId = 0; + if (spellNameOrId[0] >= '0' && spellNameOrId[0] <= '9') { + spellId = static_cast(strtoul(spellNameOrId, nullptr, 10)); + } else { + std::string nameLow(spellNameOrId); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + if (spellId == 0) { lua_pushnil(L); return 1; } + + // Get spell max range from DBC + auto data = gh->getSpellData(spellId); + if (data.maxRange <= 0.0f) { lua_pushnil(L); return 1; } + + // Resolve target position + 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; } + auto targetEnt = gh->getEntityManager().getEntity(guid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushnil(L); return 1; } + + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + lua_pushnumber(L, dist <= data.maxRange ? 1 : 0); + return 1; +} + // UnitIsVisible(unit) → boolean (entity exists in the client's entity manager) static int lua_UnitIsVisible(lua_State* L) { const char* uid = luaL_optstring(L, 1, "target"); @@ -3371,6 +3414,7 @@ void LuaEngine::registerCoreAPI() { {"IsSpellKnown", lua_IsSpellKnown}, {"GetSpellCooldown", lua_GetSpellCooldown}, {"GetSpellPowerCost", lua_GetSpellPowerCost}, + {"IsSpellInRange", lua_IsSpellInRange}, {"HasTarget", lua_HasTarget}, {"TargetUnit", lua_TargetUnit}, {"ClearTarget", lua_ClearTarget}, From c836b421fc1da16552f946fb38ea2f49b926f487 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 12:33:39 -0700 Subject: [PATCH 32/36] feat: add CheckInteractDistance for proximity-based addon checks Returns true if the player is within interaction distance of a unit: - Index 1: Inspect range (28 yards) - Index 2: Trade range (11 yards) - Index 3: Duel range (10 yards) - Index 4: Follow range (28 yards) Used by trade addons, inspect addons, and proximity detection addons. --- src/addons/lua_engine.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index dd81bf98..bf1c8c43 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,35 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// CheckInteractDistance(unit, distIndex) → boolean +// distIndex: 1=inspect(28yd), 2=trade(11yd), 3=duel(10yd), 4=follow(28yd) +static int lua_CheckInteractDistance(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid = luaL_checkstring(L, 1); + int distIdx = static_cast(luaL_optnumber(L, 2, 4)); + 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 targetEnt = gh->getEntityManager().getEntity(guid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushboolean(L, 0); return 1; } + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + float maxDist = 28.0f; // default: follow/inspect range + switch (distIdx) { + case 1: maxDist = 28.0f; break; // inspect + case 2: maxDist = 11.11f; break; // trade + case 3: maxDist = 9.9f; break; // duel + case 4: maxDist = 28.0f; break; // follow + } + lua_pushboolean(L, dist <= maxDist); + return 1; +} + // IsSpellInRange(spellName, unit) → 0 or 1 (nil if can't determine) static int lua_IsSpellInRange(lua_State* L) { auto* gh = getGameHandler(L); @@ -3415,6 +3444,7 @@ void LuaEngine::registerCoreAPI() { {"GetSpellCooldown", lua_GetSpellCooldown}, {"GetSpellPowerCost", lua_GetSpellPowerCost}, {"IsSpellInRange", lua_IsSpellInRange}, + {"CheckInteractDistance", lua_CheckInteractDistance}, {"HasTarget", lua_HasTarget}, {"TargetUnit", lua_TargetUnit}, {"ClearTarget", lua_ClearTarget}, From afc5266acfea3e56d3898c1c94501d12384fbfdd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 12:52:56 -0700 Subject: [PATCH 33/36] feat: add UnitDistanceSquared for proximity and range check addons Returns squared distance between the player and a unit, plus a boolean indicating whether the calculation was possible. Squared distance avoids sqrt for efficient range comparisons. Used by DBM, BigWigs, and proximity warning addons for raid encounter range checks (e.g., "spread 10 yards" mechanics). --- src/addons/lua_engine.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp index bf1c8c43..6148ddd0 100644 --- a/src/addons/lua_engine.cpp +++ b/src/addons/lua_engine.cpp @@ -540,6 +540,26 @@ static int lua_UnitDetailedThreatSituation(lua_State* L) { return 5; } +// UnitDistanceSquared(unit) → distSq, canCalculate +static int lua_UnitDistanceSquared(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushboolean(L, 0); return 2; } + 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 || guid == gh->getPlayerGuid()) { lua_pushnumber(L, 0); lua_pushboolean(L, 0); return 2; } + auto targetEnt = gh->getEntityManager().getEntity(guid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushnumber(L, 0); lua_pushboolean(L, 0); return 2; } + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + lua_pushnumber(L, dx*dx + dy*dy + dz*dz); + lua_pushboolean(L, 1); + return 2; +} + // CheckInteractDistance(unit, distIndex) → boolean // distIndex: 1=inspect(28yd), 2=trade(11yd), 3=duel(10yd), 4=follow(28yd) static int lua_CheckInteractDistance(lua_State* L) { @@ -3444,6 +3464,7 @@ void LuaEngine::registerCoreAPI() { {"GetSpellCooldown", lua_GetSpellCooldown}, {"GetSpellPowerCost", lua_GetSpellPowerCost}, {"IsSpellInRange", lua_IsSpellInRange}, + {"UnitDistanceSquared", lua_UnitDistanceSquared}, {"CheckInteractDistance", lua_CheckInteractDistance}, {"HasTarget", lua_HasTarget}, {"TargetUnit", lua_TargetUnit}, From a4c8fd621d7dd2d934649b82c026a3c9d056acec Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 13:53:02 -0700 Subject: [PATCH 34/36] fix: use geoset 503 for bare shins to reduce knee width discontinuity Change the bare shin (no boots) default from geoset 502 to 503 across all four code paths (character creation, character preview, equipment update, NPC rendering). Geoset 503 has Y width ~0.44 which better matches the thigh mesh width (~0.42) than 502's width (~0.39), reducing the visible gap at the knee joint where lower and upper leg meshes meet. --- src/core/application.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 63b2c2f9..c5e7dccb 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3933,7 +3933,7 @@ void Application::spawnPlayerCharacter() { // Facial hair geoset: group 2 = 200 + variation + 1 activeGeosets.insert(static_cast(200 + facialId + 1)); activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 - activeGeosets.insert(502); // Bare shins (no boots) — group 5 + activeGeosets.insert(503); // Bare shins (no boots) — group 5 activeGeosets.insert(702); // Ears: default activeGeosets.insert(801); // Bare wrists (no chest armor sleeves) — group 8 activeGeosets.insert(902); // Kneepads: default — group 9 @@ -6464,7 +6464,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x }; uint16_t geosetGloves = pickGeoset(401, 4); // Bare gloves/forearms (group 4) - uint16_t geosetBoots = pickGeoset(502, 5); // Bare boots/shins (group 5) + uint16_t geosetBoots = pickGeoset(503, 5); // Bare boots/shins (group 5) uint16_t geosetSleeves = pickGeoset(801, 8); // Bare wrists (group 8, controlled by chest) uint16_t geosetPants = pickGeoset(1301, 13); // Bare legs (group 13) uint16_t geosetCape = 0; // Group 15 disabled unless cape is equipped @@ -7276,7 +7276,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, activeGeosets.insert(static_cast(100 + hairStyleId + 1)); activeGeosets.insert(static_cast(200 + facialFeatures + 1)); activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 - activeGeosets.insert(502); // Bare shins (no boots) — group 5 + activeGeosets.insert(503); // Bare shins (no boots) — group 5 activeGeosets.insert(702); // Ears activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8 activeGeosets.insert(902); // Kneepads — group 9 @@ -7385,7 +7385,7 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, // Per-group defaults — overridden below when equipment provides a geoset value. uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves) - uint16_t geosetBoots = 502; // Bare shins (group 5, no boots) + uint16_t geosetBoots = 503; // Bare shins (group 5, no boots) uint16_t geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves) uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings) From 42222e4095b47aec6531c46a3204b196ae06b5af Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 14:08:47 -0700 Subject: [PATCH 35/36] feat: handle MSG_CORPSE_QUERY for server-authoritative corpse position Parse MSG_CORPSE_QUERY server response to get the exact corpse location and map ID. Also send the query after releasing spirit so the minimap corpse marker points to the correct position even when the player died in an instance and releases to an outdoor graveyard. Previously the corpse position was only set from the entity death location, which could be wrong for cross-map ghost runs. --- src/game/game_handler.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 114a55c2..1d6f7365 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2780,6 +2780,25 @@ void GameHandler::handlePacket(network::Packet& packet) { barberShopOpen_ = true; if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_OPEN", {}); break; + case Opcode::MSG_CORPSE_QUERY: { + // Server response: uint8 found + (if found) uint32 mapId + float x + float y + float z + uint32 corpseMapId + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t found = packet.readUInt8(); + if (found && packet.getSize() - packet.getReadPos() >= 20) { + /*uint32_t mapId =*/ packet.readUInt32(); + float cx = packet.readFloat(); + float cy = packet.readFloat(); + float cz = packet.readFloat(); + uint32_t corpseMapId = packet.readUInt32(); + // Server coords: x=west, y=north (opposite of canonical) + corpseX_ = cx; + corpseY_ = cy; + corpseZ_ = cz; + corpseMapId_ = corpseMapId; + LOG_INFO("MSG_CORPSE_QUERY: corpse at (", cx, ",", cy, ",", cz, ") map=", corpseMapId); + } + break; + } case Opcode::SMSG_FEIGN_DEATH_RESISTED: addUIError("Your Feign Death was resisted."); addSystemChatMessage("Your Feign Death attempt was resisted."); @@ -14728,6 +14747,9 @@ void GameHandler::releaseSpirit() { repopPending_ = true; lastRepopRequestMs_ = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); + // Query server for authoritative corpse position (response updates corpseX_/Y_/Z_) + network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); + socket->send(cq); } } From 3103662528748220f2fad551e9126b08f7535738 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Mar 2026 14:13:03 -0700 Subject: [PATCH 36/36] fix: query corpse position on ghost login for accurate minimap marker When logging in while already dead (reconnect/crash recovery), send MSG_CORPSE_QUERY to get the server-authoritative corpse location. Without this, the minimap corpse marker would be missing or point to the wrong position after reconnecting as a ghost. --- 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 1d6f7365..f81f0ef0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12050,6 +12050,11 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem playerDead_ = true; LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); if (ghostStateCallback_) ghostStateCallback_(true); + // Query corpse position so minimap marker is accurate on reconnect + if (socket) { + network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); + socket->send(cq); + } } } // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create