Compare commits

...

36 commits

Author SHA1 Message Date
Kelsi
3103662528 fix: query corpse position on ghost login for accurate minimap marker
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
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.
2026-03-21 14:13:03 -07:00
Kelsi
42222e4095 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.
2026-03-21 14:08:47 -07:00
Kelsi
a4c8fd621d 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.
2026-03-21 13:53:02 -07:00
Kelsi
afc5266acf 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).
2026-03-21 12:52:56 -07:00
Kelsi
c836b421fc 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.
2026-03-21 12:33:39 -07:00
Kelsi
42b776dbf8 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.
2026-03-21 12:23:08 -07:00
Kelsi
d575c06bc1 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.
2026-03-21 11:49:23 -07:00
Kelsi
be841fb3e1 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.
2026-03-21 11:45:52 -07:00
Kelsi
1f6865afce 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.
2026-03-21 11:22:57 -07:00
Kelsi
1fd220de29 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.
2026-03-21 11:13:36 -07:00
Kelsi
1988e778c7 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.
2026-03-21 11:02:46 -07:00
Kelsi
24e2069225 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.
2026-03-21 10:47:47 -07:00
Kelsi
d8c0820c76 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.
2026-03-21 10:33:21 -07:00
Kelsi
964437cdf4 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.
2026-03-21 10:27:43 -07:00
Kelsi
5d6376f3f1 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.
2026-03-21 10:19:29 -07:00
Kelsi
a4ff315c81 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.
2026-03-21 10:13:04 -07:00
Kelsi
3ad917bd95 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.
2026-03-21 10:02:34 -07:00
Kelsi
9b2f100387 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.
2026-03-21 09:53:32 -07:00
Kelsi
c97898712b 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.
2026-03-21 09:38:41 -07:00
Kelsi
8dca33e5cc 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.
2026-03-21 09:32:40 -07:00
Kelsi
b1171327cb 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.
2026-03-21 09:23:20 -07:00
Kelsi
4364fa7bbe 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.
2026-03-21 09:18:25 -07:00
Kelsi
9267aec0b0 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.
2026-03-21 09:12:59 -07:00
Kelsi
ac9214c03f feat: fire UNIT_THREAT_LIST_UPDATE event on threat changes
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.
2026-03-21 09:08:02 -07:00
Kelsi
f580fd7e6b feat: add UnitDetailedThreatSituation for detailed threat queries
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.
2026-03-21 09:02:47 -07:00
Kelsi
dcd78f4f28 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.
2026-03-21 08:57:38 -07:00
Kelsi
4af9838ab4 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.
2026-03-21 08:48:58 -07:00
Kelsi
aebc905261 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.
2026-03-21 08:42:56 -07:00
Kelsi
57ccee2c28 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.
2026-03-21 08:37:39 -07:00
Kelsi
586e9e74ff 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.
2026-03-21 08:33:54 -07:00
Kelsi
82990f5891 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.
2026-03-21 08:22:52 -07:00
Kelsi
e7be60c624 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.
2026-03-21 08:17:38 -07:00
Kelsi
2c6a345e32 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.
2026-03-21 08:13:47 -07:00
Kelsi
a4d54e83bc 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.
2026-03-21 08:07:39 -07:00
Kelsi
a73c680190 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.
2026-03-21 07:58:38 -07:00
Kelsi
2da0883544 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.
2026-03-21 07:54:30 -07:00
5 changed files with 527 additions and 22 deletions

View file

@ -1300,7 +1300,7 @@ public:
// Barber shop // Barber shop
bool isBarberShopOpen() const { return barberShopOpen_; } 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); void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair);
// Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK) // Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK)

View file

@ -268,7 +268,17 @@ static int lua_UnitExists(lua_State* L) {
static int lua_UnitIsDead(lua_State* L) { static int lua_UnitIsDead(lua_State* L) {
const char* uid = luaL_optstring(L, 1, "player"); const char* uid = luaL_optstring(L, 1, "player");
auto* unit = resolveUnit(L, uid); 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<char>(std::tolower(static_cast<unsigned char>(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; return 1;
} }
@ -413,6 +423,324 @@ static int lua_UnitPlayerControlled(lua_State* L) {
return 1; 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;
}
// 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<char>(std::tolower(static_cast<unsigned char>(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<char>(std::tolower(static_cast<unsigned char>(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<uint64_t>(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;
}
// 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<char>(std::tolower(static_cast<unsigned char>(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<char>(std::tolower(static_cast<unsigned char>(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<uint64_t>(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;
}
// 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<char>(std::tolower(static_cast<unsigned char>(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) {
auto* gh = getGameHandler(L);
if (!gh) { lua_pushboolean(L, 0); return 1; }
const char* uid = luaL_checkstring(L, 1);
int distIdx = static_cast<int>(luaL_optnumber(L, 2, 4));
std::string uidStr(uid);
for (char& c : uidStr) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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);
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<uint32_t>(strtoul(spellNameOrId, nullptr, 10));
} else {
std::string nameLow(spellNameOrId);
for (char& c : nameLow) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
for (uint32_t sid : gh->getKnownSpells()) {
std::string sn = gh->getSpellName(sid);
for (char& c : sn) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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<char>(std::tolower(static_cast<unsigned char>(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");
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);
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<char>(std::tolower(static_cast<unsigned char>(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);
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<char>(std::tolower(static_cast<unsigned char>(c)));
for (char& c : u2) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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);
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<char>(std::tolower(static_cast<unsigned char>(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<game::Unit>(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");
auto* gh = getGameHandler(L);
if (!gh) { lua_pushboolean(L, 0); return 1; }
std::string uidStr(uid);
for (char& c : uidStr) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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 // UnitSex(unit) → 1=unknown, 2=male, 3=female
static int lua_UnitSex(lua_State* L) { static int lua_UnitSex(lua_State* L) {
const char* uid = luaL_optstring(L, 1, "player"); const char* uid = luaL_optstring(L, 1, "player");
@ -502,10 +830,22 @@ static int lua_UnitRace(lua_State* L) {
static int lua_UnitPowerType(lua_State* L) { static int lua_UnitPowerType(lua_State* L) {
const char* uid = luaL_optstring(L, 1, "player"); const char* uid = luaL_optstring(L, 1, "player");
auto* unit = resolveUnit(L, uid); auto* unit = resolveUnit(L, uid);
if (unit) {
lua_pushnumber(L, unit->getPowerType());
static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"}; static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"};
if (unit) {
uint8_t pt = unit->getPowerType(); 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<char>(std::tolower(static_cast<unsigned char>(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"); lua_pushstring(L, (pt < 7) ? kPowerNames[pt] : "MANA");
return 2; return 2;
} }
@ -1011,6 +1351,27 @@ static int lua_SetRaidTarget(lua_State* L) {
return 0; 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<uint32_t>(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 / GetSpellTexture ---
// GetSpellInfo(spellIdOrName) -> name, rank, icon, castTime, minRange, maxRange, spellId // GetSpellInfo(spellIdOrName) -> name, rank, icon, castTime, minRange, maxRange, spellId
static int lua_GetSpellInfo(lua_State* L) { static int lua_GetSpellInfo(lua_State* L) {
@ -3077,6 +3438,17 @@ void LuaEngine::registerCoreAPI() {
{"UnitIsAFK", lua_UnitIsAFK}, {"UnitIsAFK", lua_UnitIsAFK},
{"UnitIsDND", lua_UnitIsDND}, {"UnitIsDND", lua_UnitIsDND},
{"UnitPlayerControlled", lua_UnitPlayerControlled}, {"UnitPlayerControlled", lua_UnitPlayerControlled},
{"UnitIsTapped", lua_UnitIsTapped},
{"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer},
{"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList},
{"UnitIsVisible", lua_UnitIsVisible},
{"UnitGroupRolesAssigned", lua_UnitGroupRolesAssigned},
{"UnitCanAttack", lua_UnitCanAttack},
{"UnitCanCooperate", lua_UnitCanCooperate},
{"UnitCreatureFamily", lua_UnitCreatureFamily},
{"UnitOnTaxi", lua_UnitOnTaxi},
{"UnitThreatSituation", lua_UnitThreatSituation},
{"UnitDetailedThreatSituation", lua_UnitDetailedThreatSituation},
{"UnitSex", lua_UnitSex}, {"UnitSex", lua_UnitSex},
{"UnitClass", lua_UnitClass}, {"UnitClass", lua_UnitClass},
{"GetMoney", lua_GetMoney}, {"GetMoney", lua_GetMoney},
@ -3090,6 +3462,10 @@ void LuaEngine::registerCoreAPI() {
{"CastSpellByName", lua_CastSpellByName}, {"CastSpellByName", lua_CastSpellByName},
{"IsSpellKnown", lua_IsSpellKnown}, {"IsSpellKnown", lua_IsSpellKnown},
{"GetSpellCooldown", lua_GetSpellCooldown}, {"GetSpellCooldown", lua_GetSpellCooldown},
{"GetSpellPowerCost", lua_GetSpellPowerCost},
{"IsSpellInRange", lua_IsSpellInRange},
{"UnitDistanceSquared", lua_UnitDistanceSquared},
{"CheckInteractDistance", lua_CheckInteractDistance},
{"HasTarget", lua_HasTarget}, {"HasTarget", lua_HasTarget},
{"TargetUnit", lua_TargetUnit}, {"TargetUnit", lua_TargetUnit},
{"ClearTarget", lua_ClearTarget}, {"ClearTarget", lua_ClearTarget},
@ -3542,13 +3918,22 @@ void LuaEngine::registerCoreAPI() {
"function StaticPopup_Show() end\n" "function StaticPopup_Show() end\n"
"function StaticPopup_Hide() end\n" "function StaticPopup_Hide() end\n"
// CreateTexture/CreateFontString are now C frame methods in the metatable // CreateTexture/CreateFontString are now C frame methods in the metatable
"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" " 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" " WARRIOR=cc(0.78,0.61,0.43), PALADIN=cc(0.96,0.55,0.73),\n"
" HUNTER={r=0.67,g=0.83,b=0.45}, ROGUE={r=1.0,g=0.96,b=0.41},\n" " HUNTER=cc(0.67,0.83,0.45), ROGUE=cc(1.0,0.96,0.41),\n"
" PRIEST={r=1.0,g=1.0,b=1.0}, DEATHKNIGHT={r=0.77,g=0.12,b=0.23},\n" " PRIEST=cc(1.0,1.0,1.0), DEATHKNIGHT=cc(0.77,0.12,0.23),\n"
" SHAMAN={r=0.0,g=0.44,b=0.87}, MAGE={r=0.41,g=0.80,b=0.94},\n" " SHAMAN=cc(0.0,0.44,0.87), MAGE=cc(0.41,0.80,0.94),\n"
" WARLOCK={r=0.58,g=0.51,b=0.79}, DRUID={r=1.0,g=0.49,b=0.04},\n" " WARLOCK=cc(0.58,0.51,0.79), DRUID=cc(1.0,0.49,0.04),\n"
" }\n" " }\n"
"end\n"
// Money formatting utility // Money formatting utility
"function GetCoinTextureString(copper)\n" "function GetCoinTextureString(copper)\n"
" if not copper or copper == 0 then return '0c' end\n" " if not copper or copper == 0 then return '0c' end\n"
@ -3663,6 +4048,21 @@ void LuaEngine::registerCoreAPI() {
"function GetShapeshiftFormInfo(index) return nil, nil, nil, nil end\n" "function GetShapeshiftFormInfo(index) return nil, nil, nil, nil end\n"
// Pet action bar // Pet action bar
"NUM_PET_ACTION_SLOTS = 10\n" "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 GetPetActionInfo(slot) return nil end\n"
"function GetPetActionsUsable() return false end\n" "function GetPetActionsUsable() return false end\n"
); );

View file

@ -3933,7 +3933,7 @@ void Application::spawnPlayerCharacter() {
// Facial hair geoset: group 2 = 200 + variation + 1 // Facial hair geoset: group 2 = 200 + variation + 1
activeGeosets.insert(static_cast<uint16_t>(200 + facialId + 1)); activeGeosets.insert(static_cast<uint16_t>(200 + facialId + 1));
activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 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(702); // Ears: default
activeGeosets.insert(801); // Bare wrists (no chest armor sleeves) — group 8 activeGeosets.insert(801); // Bare wrists (no chest armor sleeves) — group 8
activeGeosets.insert(902); // Kneepads: default — group 9 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 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 geosetSleeves = pickGeoset(801, 8); // Bare wrists (group 8, controlled by chest)
uint16_t geosetPants = pickGeoset(1301, 13); // Bare legs (group 13) uint16_t geosetPants = pickGeoset(1301, 13); // Bare legs (group 13)
uint16_t geosetCape = 0; // Group 15 disabled unless cape is equipped 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<uint16_t>(100 + hairStyleId + 1)); activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
activeGeosets.insert(static_cast<uint16_t>(200 + facialFeatures + 1)); activeGeosets.insert(static_cast<uint16_t>(200 + facialFeatures + 1));
activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 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(702); // Ears
activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8 activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8
activeGeosets.insert(902); // Kneepads — group 9 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. // Per-group defaults — overridden below when equipment provides a geoset value.
uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves) 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 geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves)
uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings) uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings)

View file

@ -2078,6 +2078,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
// XP is updated via PLAYER_XP update fields from the server. // XP is updated via PLAYER_XP update fields from the server.
if (areaDiscoveryCallback_) if (areaDiscoveryCallback_)
areaDiscoveryCallback_(areaName, xpGained); areaDiscoveryCallback_(areaName, xpGained);
if (addonEventCallback_)
addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)});
} }
} }
break; break;
@ -2211,6 +2213,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t value = packet.readUInt32(); uint32_t value = packet.readUInt32();
worldStates_[field] = value; worldStates_[field] = value;
LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value);
if (addonEventCallback_)
addonEventCallback_("UPDATE_WORLD_STATES", {});
break; break;
} }
case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: { case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: {
@ -2302,6 +2306,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint8_t paused = packet.readUInt8(); uint8_t paused = packet.readUInt8();
if (type < 3) { if (type < 3) {
mirrorTimers_[type].paused = (paused != 0); mirrorTimers_[type].paused = (paused != 0);
if (addonEventCallback_)
addonEventCallback_("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"});
} }
break; break;
} }
@ -2772,7 +2778,27 @@ void GameHandler::handlePacket(network::Packet& packet) {
// Sent by server when player sits in barber chair — triggers barber shop UI // Sent by server when player sits in barber chair — triggers barber shop UI
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
barberShopOpen_ = true; barberShopOpen_ = true;
if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_OPEN", {});
break; 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: case Opcode::SMSG_FEIGN_DEATH_RESISTED:
addUIError("Your Feign Death was resisted."); addUIError("Your Feign Death was resisted.");
addSystemChatMessage("Your Feign Death attempt was resisted."); addSystemChatMessage("Your Feign Death attempt was resisted.");
@ -2845,6 +2871,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
// All threat dropped on the local player (e.g. Vanish, Feign Death) // All threat dropped on the local player (e.g. Vanish, Feign Death)
threatLists_.clear(); threatLists_.clear();
LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped");
if (addonEventCallback_) addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {});
break; break;
case Opcode::SMSG_THREAT_REMOVE: { case Opcode::SMSG_THREAT_REMOVE: {
// packed_guid (unit) + packed_guid (victim whose threat was removed) // packed_guid (unit) + packed_guid (victim whose threat was removed)
@ -2888,6 +2915,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::sort(list.begin(), list.end(), std::sort(list.begin(), list.end(),
[](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; });
threatLists_[unitGuid] = std::move(list); threatLists_[unitGuid] = std::move(list);
if (addonEventCallback_)
addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {});
break; break;
} }
@ -4119,6 +4148,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (auto* sfx = renderer->getUiSoundManager()) if (auto* sfx = renderer->getUiSoundManager())
sfx->playQuestActivate(); sfx->playQuestActivate();
} }
if (addonEventCallback_) {
addonEventCallback_("TRAINER_UPDATE", {});
addonEventCallback_("SPELLS_CHANGED", {});
}
break; break;
} }
case Opcode::SMSG_TRAINER_BUY_FAILED: { case Opcode::SMSG_TRAINER_BUY_FAILED: {
@ -4258,8 +4291,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
addSystemChatMessage(buf); addSystemChatMessage(buf);
watchedFactionId_ = factionId; watchedFactionId_ = factionId;
if (repChangeCallback_) repChangeCallback_(name, delta, standing); if (repChangeCallback_) repChangeCallback_(name, delta, standing);
if (addonEventCallback_) if (addonEventCallback_) {
addonEventCallback_("UPDATE_FACTION", {}); addonEventCallback_("UPDATE_FACTION", {});
addonEventCallback_("CHAT_MSG_COMBAT_FACTION_CHANGE", {std::string(buf)});
}
} }
LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing);
} }
@ -5125,6 +5160,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (result == 0) { if (result == 0) {
addSystemChatMessage("Hairstyle changed."); addSystemChatMessage("Hairstyle changed.");
barberShopOpen_ = false; barberShopOpen_ = false;
if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_CLOSE", {});
} else { } else {
const char* msg = (result == 1) ? "Not enough money for new hairstyle." const char* msg = (result == 1) ? "Not enough money for new hairstyle."
: (result == 2) ? "You are not at a barber shop." : (result == 2) ? "You are not at a barber shop."
@ -5407,11 +5443,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
} }
questLog_.erase(it); questLog_.erase(it);
LOG_INFO(" Removed quest ", questId, " from quest log"); LOG_INFO(" Removed quest ", questId, " from quest log");
if (addonEventCallback_)
addonEventCallback_("QUEST_TURNED_IN", {std::to_string(questId)});
break; break;
} }
} }
} }
if (addonEventCallback_) addonEventCallback_("QUEST_LOG_UPDATE", {}); if (addonEventCallback_)
addonEventCallback_("QUEST_LOG_UPDATE", {});
// Re-query all nearby quest giver NPCs so markers refresh // Re-query all nearby quest giver NPCs so markers refresh
if (socket) { if (socket) {
for (const auto& [guid, entity] : entityManager.getEntities()) { for (const auto& [guid, entity] : entityManager.getEntities()) {
@ -11900,11 +11939,42 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
} else if (key == ufMaxHealth) { unit->setMaxHealth(val); } } else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
else if (key == ufLevel) { else if (key == ufLevel) {
unit->setLevel(val); unit->setLevel(val);
} else if (key == ufFaction) { unit->setFactionTemplate(val); } } else if (key == ufFaction) {
else if (key == ufFlags) { unit->setUnitFlags(val); } 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);
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) { else if (key == ufBytes0) {
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF)); unit->setPowerType(static_cast<uint8_t>((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 == ufNpcFlags) { unit->setNpcFlags(val); }
else if (key == ufDynFlags) { else if (key == ufDynFlags) {
unit->setDynamicFlags(val); unit->setDynamicFlags(val);
@ -11924,6 +11994,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
uint32_t old = currentMountDisplayId_; uint32_t old = currentMountDisplayId_;
currentMountDisplayId_ = val; currentMountDisplayId_ = val;
if (val != old && mountCallback_) mountCallback_(val); if (val != old && mountCallback_) mountCallback_(val);
if (val != old && addonEventCallback_)
addonEventCallback_("UNIT_MODEL_CHANGED", {"player"});
if (old == 0 && val != 0) { if (old == 0 && val != 0) {
// Just mounted — find the mount aura (indefinite duration, self-cast) // Just mounted — find the mount aura (indefinite duration, self-cast)
mountAuraSpellId_ = 0; mountAuraSpellId_ = 0;
@ -11978,6 +12050,11 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
playerDead_ = true; playerDead_ = true;
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
if (ghostStateCallback_) ghostStateCallback_(true); 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 // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create
@ -14095,6 +14172,7 @@ void GameHandler::followTarget() {
addSystemChatMessage("Now following " + targetName + "."); addSystemChatMessage("Now following " + targetName + ".");
LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")"); LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
if (addonEventCallback_) addonEventCallback_("AUTOFOLLOW_BEGIN", {});
} }
void GameHandler::cancelFollow() { void GameHandler::cancelFollow() {
@ -14104,6 +14182,7 @@ void GameHandler::cancelFollow() {
} }
followTargetGuid_ = 0; followTargetGuid_ = 0;
addSystemChatMessage("You stop following."); addSystemChatMessage("You stop following.");
if (addonEventCallback_) addonEventCallback_("AUTOFOLLOW_END", {});
} }
void GameHandler::assistTarget() { void GameHandler::assistTarget() {
@ -14673,6 +14752,9 @@ void GameHandler::releaseSpirit() {
repopPending_ = true; repopPending_ = true;
lastRepopRequestMs_ = static_cast<uint64_t>(now); lastRepopRequestMs_ = static_cast<uint64_t>(now);
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); 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);
} }
} }
@ -24592,6 +24674,7 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) {
logoutCountdown_ = 20.0f; logoutCountdown_ = 20.0f;
} }
LOG_INFO("Logout response: success, instant=", (int)data.instant); LOG_INFO("Logout response: success, instant=", (int)data.instant);
if (addonEventCallback_) addonEventCallback_("PLAYER_LOGOUT", {});
} else { } else {
// Failure // Failure
addSystemChatMessage("Cannot logout right now."); addSystemChatMessage("Cannot logout right now.");

View file

@ -4237,6 +4237,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
if (u->getHealth() == 0 && u->getMaxHealth() > 0) { if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
} else if (u->isHostile()) { } 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 // WoW level-based color for hostile mobs
uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel(); 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 hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy
} }
} }
} // end tapped else
} else { } else {
hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Friendly hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Friendly
} }
@ -5181,6 +5188,12 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) {
if (u->getHealth() == 0 && u->getMaxHealth() > 0) { if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
} else if (u->isHostile()) { } 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 playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel(); uint32_t mobLv = u->getLevel();
if (mobLv == 0) { if (mobLv == 0) {
@ -5198,6 +5211,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) {
else else
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} }
} // end tapped else
} else { } else {
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} }
@ -11695,8 +11709,16 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
barColor = IM_COL32(140, 140, 140, A(200)); barColor = IM_COL32(140, 140, 140, A(200));
bgColor = IM_COL32(70, 70, 70, A(160)); bgColor = IM_COL32(70, 70, 70, A(160));
} else if (unit->isHostile()) { } else if (unit->isHostile()) {
// 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)); barColor = IM_COL32(220, 60, 60, A(200));
bgColor = IM_COL32(100, 25, 25, A(160)); bgColor = IM_COL32(100, 25, 25, A(160));
}
} else if (isPlayer) { } else if (isPlayer) {
// Player nameplates: use class color for easy identification // Player nameplates: use class color for easy identification
uint8_t cid = entityClassId(unit); uint8_t cid = entityClassId(unit);