Compare commits

...

74 commits

Author SHA1 Message Date
Kelsi
5ee2b55f4b feat: fire MIRROR_TIMER_START and MIRROR_TIMER_STOP events
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
Fire MIRROR_TIMER_START with type, value, maxValue, scale, and paused
args when breath/fatigue/fire timers begin. Fire MIRROR_TIMER_STOP with
type when they end. Timer types: 0=fatigue, 1=breath, 2=fire.

Used by timer bar addons to display breath/fatigue countdown overlays.
2026-03-21 07:48:06 -07:00
Kelsi
774f9bf214 feat: fire BAG_UPDATE and UNIT_INVENTORY_CHANGED on item received
Fire BAG_UPDATE and UNIT_INVENTORY_CHANGED from SMSG_ITEM_PUSH_RESULT
when any item is received (loot, quest reward, trade, mail). Bag addons
(Bagnon, AdiBags) immediately show new items, and equipment tracking
addons detect inventory changes.
2026-03-21 07:33:22 -07:00
Kelsi
c6a6849c86 feat: fire PET_BAR_UPDATE event when pet action bar changes
Fire PET_BAR_UPDATE when:
- Pet is summoned (SMSG_PET_SPELLS with new spell list)
- Pet learns a new spell (SMSG_PET_LEARNED_SPELL)

Used by pet action bar addons to refresh their display when the pet's
available abilities change.
2026-03-21 07:30:01 -07:00
Kelsi
6ab1a189c7 feat: fire GUILD_INVITE_REQUEST event with inviter and guild names
Fire GUILD_INVITE_REQUEST when another player invites the local player
to a guild. Includes inviterName and guildName as arguments. Used by
auto-accept guild addons and invitation notification addons.
2026-03-21 07:23:15 -07:00
Kelsi
d24d12fb8f feat: fire PARTY_INVITE_REQUEST event with inviter name
Fire PARTY_INVITE_REQUEST when another player invites the local player
to a group. Used by auto-accept group addons and invite notification
addons. Includes the inviter's name as the first argument.
2026-03-21 07:17:20 -07:00
Kelsi
70a50e45f5 feat: fire CONFIRM_TALENT_WIPE event with respec cost
Fire CONFIRM_TALENT_WIPE with the gold cost when the trainer offers
to reset talents (MSG_TALENT_WIPE_CONFIRM). Used by talent management
addons to show the respec cost dialog.
2026-03-21 07:12:38 -07:00
Kelsi
df79e08788 fix: fire GROUP_ROSTER_UPDATE when group is destroyed
SMSG_GROUP_DESTROYED clears all party state but wasn't firing addon
events. Raid frame addons (Grid, VuhDo, Healbot) now properly hide
when the group disbands. Also fires PARTY_MEMBERS_CHANGED for compat.
2026-03-21 07:07:32 -07:00
Kelsi
12fb8f73f7 feat: fire BAG_UPDATE and PLAYER_MONEY events after selling items
SMSG_SELL_ITEM success now fires BAG_UPDATE so bag addons refresh
their display, and PLAYER_MONEY so gold tracking addons see the
sell price income immediately.
2026-03-21 06:52:41 -07:00
Kelsi
b407d5d632 feat: fire RESURRECT_REQUEST event when another player offers resurrection
Fire RESURRECT_REQUEST with the caster's name from SMSG_RESURRECT_REQUEST
when a player/NPC offers to resurrect the dead player. Used by auto-accept
resurrection addons and death tracking addons.
2026-03-21 06:47:32 -07:00
Kelsi
5d93108a88 feat: fire CONFIRM_SUMMON event on warlock/meeting stone summons
Fire CONFIRM_SUMMON from SMSG_SUMMON_REQUEST when another player
summons via warlock portal or meeting stone. Used by auto-accept
summon addons and summon notification addons.
2026-03-21 06:42:59 -07:00
Kelsi
de903e363c feat: fire PLAYER_STARTED_MOVING and PLAYER_STOPPED_MOVING events
Track horizontal movement flag transitions (forward/backward/strafe)
and fire events when the player starts or stops moving. Used by
Healbot, cast-while-moving addons, and nameplate addons that track
player movement state for positioning optimization.
2026-03-21 06:38:48 -07:00
Kelsi
d36172fc90 fix: fire PLAYER_MONEY event from SMSG_LOOT_MONEY_NOTIFY
The loot money handler directly updates playerMoneyCopper_ but wasn't
firing PLAYER_MONEY. The update object path fires it when the coinage
field changes, but there can be a delay. Now gold-tracking addons
(Accountant, GoldTracker) immediately see looted money.
2026-03-21 06:32:41 -07:00
Kelsi
b3ad64099b feat: fire UNIT_PET event when pet is summoned, dismissed, or dies
Fire UNIT_PET with "player" as arg from SMSG_PET_SPELLS when:
- Pet is cleared (dismissed/dies) — both size-based and guid=0 paths
- Pet is summoned (new pet GUID received with spell list)

Used by pet frame addons and unit frame addons to show/hide pet
frames and update pet action bars when pet state changes.
2026-03-21 06:27:55 -07:00
Kelsi
5ab6286f7e fix: include pet unit ID in UNIT_HEALTH/POWER events from dedicated packets
SMSG_HEALTH_UPDATE and SMSG_POWER_UPDATE were not checking for the pet
GUID when dispatching addon events. Pet health/power bar addons now
properly receive UNIT_HEALTH and UNIT_POWER with unitId="pet".

The UPDATE_OBJECT and UNIT_AURA paths already had the pet check.
2026-03-21 06:23:03 -07:00
Kelsi
b04e36aaa4 fix: include pet unit ID in all spellcast addon events
Add pet GUID check to all spellcast event dispatchers:
- UNIT_SPELLCAST_START
- UNIT_SPELLCAST_SUCCEEDED
- UNIT_SPELLCAST_CHANNEL_START
- UNIT_SPELLCAST_CHANNEL_STOP
- UNIT_SPELLCAST_INTERRUPTED + STOP

Previously these events only fired for player/target/focus, meaning
pet cast bars and pet spell tracking addons wouldn't work. Now pet
spellcasts properly fire with unitId="pet".
2026-03-21 06:19:14 -07:00
Kelsi
6687c617d9 fix: display Lua errors from OnUpdate, executeFile, executeString as UI errors
Extend the Lua error UI display to cover all error paths:
- OnUpdate frame callbacks (previously only logged)
- executeFile loading errors (now also shown as UI error)
- executeString /run errors (now also shown as UI error)

This ensures addon developers see ALL Lua errors in-game, not just
event handler errors from the previous commit.
2026-03-21 06:08:17 -07:00
Kelsi
900626f5fe feat: fire UPDATE_BATTLEFIELD_STATUS event on BG queue/join/leave
Fire UPDATE_BATTLEFIELD_STATUS with the status code when battlefield
status changes (queued, ready to join, in progress, waiting to leave).
Used by BG queue addons and PvP addons to track battleground state.
2026-03-21 06:02:28 -07:00
Kelsi
19b8d31da2 feat: display Lua addon errors as in-game UI errors
Previously Lua addon errors only logged to the log file. Now they
display as red UI error text to the player (same as spell errors and
game warnings), helping addon developers debug issues in real-time.

Add LuaErrorCallback to LuaEngine, fire it from event handler and
frame OnEvent pcall error paths. Wire the callback to GameHandler's
addUIError in application.cpp.
2026-03-21 06:00:06 -07:00
Kelsi
64c0c75bbf feat: fire CHAT_MSG_COMBAT_HONOR_GAIN event on PvP honor kills
Fire CHAT_MSG_COMBAT_HONOR_GAIN from SMSG_PVP_CREDIT with the honor
message text. Used by PvP addons (HonorSpy, HonorTracker) to track
honor gains and kill counts.
2026-03-21 05:47:22 -07:00
Kelsi
d20357415b feat: fire PLAYER_CONTROL_LOST/GAINED on movement control changes
Fire PLAYER_CONTROL_LOST when SMSG_CLIENT_CONTROL_UPDATE revokes player
movement (stun, fear, mind control, etc.) and PLAYER_CONTROL_GAINED when
movement is restored.

Used by loss-of-control addons and action bar addons to show stun/CC
indicators and disable ability buttons during crowd control.
2026-03-21 05:42:57 -07:00
Kelsi
2e6400f22e feat: fire MAIL_INBOX_UPDATE and UPDATE_PENDING_MAIL events
Fire MAIL_INBOX_UPDATE when the mail list is received/refreshed
(SMSG_MAIL_LIST_RESULT), so mail addons can update their display.

Fire UPDATE_PENDING_MAIL when new mail arrives (SMSG_RECEIVED_MAIL),
enabling minimap mail icon addons and notification addons to react.
2026-03-21 05:38:35 -07:00
Kelsi
2560bd1307 feat: fire QUEST_WATCH_UPDATE on kill and item objective progress
Fire QUEST_WATCH_UPDATE (with quest ID for kills) and QUEST_LOG_UPDATE
when quest objectives progress:
- Kill objectives: when SMSG_QUESTUPDATE_ADD_KILL updates a kill count
- Item objectives: when SMSG_QUESTUPDATE_ADD_ITEM updates an item count

Used by quest tracker addons (Questie, QuestHelper) and the built-in
quest tracker to refresh objective display when progress changes.
2026-03-21 05:33:29 -07:00
Kelsi
b5f7659db5 feat: fire MERCHANT_UPDATE and BAG_UPDATE events after purchase
Fire MERCHANT_UPDATE after a successful SMSG_BUY_ITEM so vendor addons
refresh their stock display. Also fire BAG_UPDATE so bag addons show
the newly purchased item immediately.
2026-03-21 05:27:34 -07:00
Kelsi
a02e021730 fix: fire UNIT_SPELLCAST_FAILED/STOP for other units on SPELL_FAILED_OTHER
SMSG_SPELL_FAILED_OTHER was clearing the unit cast state but not firing
addon events. Cast bar addons (Quartz, ClassicCastbars) showing target/
focus cast bars need UNIT_SPELLCAST_FAILED and UNIT_SPELLCAST_STOP to
clear the bar when another unit's cast fails.

Now fires both events for target and focus units, matching the behavior
already implemented for the player's own cast failures.
2026-03-21 05:22:17 -07:00
Kelsi
7f0d9fe432 feat: fire PLAYER_GUILD_UPDATE event on guild join/disband
Fire PLAYER_GUILD_UPDATE when the player's guild membership changes:
- When guild name is first resolved (player joins guild/logs in)
- When guild is disbanded

Used by guild frame addons and guild info display to update when
guild status changes.
2026-03-21 05:17:40 -07:00
Kelsi
82d3abe5da feat: add GetAddOnMetadata for reading TOC directives from Lua
Implement GetAddOnMetadata(addonNameOrIndex, key) which reads arbitrary
TOC file directives. All directives are now stored in the addon info
registry table under a "metadata" sub-table.

This enables addons to read their own version, author, X-* custom
fields, and other TOC metadata at runtime. Used by addon managers,
version checkers, and self-updating addons.
2026-03-21 05:13:28 -07:00
Kelsi
d4c1eda22b feat: fire PARTY_LEADER_CHANGED event on leader changes
Fire PARTY_LEADER_CHANGED (with GROUP_ROSTER_UPDATE) from both:
- SMSG_GROUP_SET_LEADER: when a new leader is named by string
- SMSG_REAL_GROUP_UPDATE: when leader GUID changes via group update

Used by raid frame addons to update leader crown icons and by
group management addons to track leadership changes.
2026-03-21 05:03:03 -07:00
Kelsi
494175e2a7 feat: add remaining CHAT_MSG_* event mappings
Map 5 previously unmapped chat types to addon events:
- CHAT_MSG_MONSTER_PARTY (NPC party chat in dungeons/scripted events)
- CHAT_MSG_AFK (player AFK auto-reply messages)
- CHAT_MSG_DND (player DND auto-reply messages)
- CHAT_MSG_LOOT (loot roll/distribution messages)
- CHAT_MSG_SKILL (skill-up messages)

All WoW chat types in the ChatType enum are now mapped to addon events.
2026-03-21 04:57:19 -07:00
Kelsi
f99f4a732a feat: fire INSPECT_READY event from both WotLK and Classic inspect paths
Fire INSPECT_READY with the inspected player's GUID when inspection
results are received. Fires from both:
- WotLK SMSG_TALENTS_INFO type=1 (talent + gear inspect)
- Classic SMSG_INSPECT (gear-only inspect)

Used by GearScore, TacoTip, and other inspection addons that need
to know when inspect data is available for a specific player.
2026-03-21 04:53:08 -07:00
Kelsi
74d7e969ab feat: add action bar constants and functions for Bartender/Dominos compat
Add essential WoW action bar globals and functions that action bar
addons (Bartender4, Dominos, CT_BarMod) require on initialization:

Constants: NUM_ACTIONBAR_BUTTONS, NUM_ACTIONBAR_PAGES, NUM_PET_ACTION_SLOTS
Functions: GetActionBarPage, ChangeActionBarPage, GetBonusBarOffset,
  GetActionText, GetActionCount
Binding: GetBindingKey, GetBindingAction, SetBinding, SaveBindings
Macro: GetNumMacros, GetMacroInfo, GetMacroBody, GetMacroIndexByName
Stance: GetNumShapeshiftForms, GetShapeshiftFormInfo
Pet: GetPetActionInfo, GetPetActionsUsable

These prevent nil-reference errors during addon initialization and
enable basic action bar addon functionality.
2026-03-21 04:48:06 -07:00
Kelsi
1f3e362512 feat: add RAID_TARGET_UPDATE event and GetRaidTargetIndex/SetRaidTarget
Fire RAID_TARGET_UPDATE event when raid markers (skull, cross, etc.)
are set or cleared on targets. Add two Lua API functions:
- GetRaidTargetIndex(unit) returns marker index 1-8 (or nil)
- SetRaidTarget(unit, index) sets marker 1-8 (or 0 to clear)

Enables raid marking addons and nameplate addons that display raid
icons to react to marker changes in real-time.
2026-03-21 04:43:42 -07:00
Kelsi
8e51754615 feat: fire READY_CHECK, READY_CHECK_CONFIRM, READY_CHECK_FINISHED events
Fire addon events for the raid ready check system:
- READY_CHECK fires when a ready check is initiated, with initiator name
- READY_CHECK_CONFIRM fires for each player's response, with GUID and
  ready state (1=ready, 0=not ready)
- READY_CHECK_FINISHED fires when the ready check period ends

These events are used by raid frame addons (Grid, VuhDo, Healbot) to
show ready check status on unit frames, and by raid management addons
to track responsiveness.
2026-03-21 04:38:35 -07:00
Kelsi
70a5d3240c feat: add ACHIEVEMENT_EARNED event and 15 missing CHAT_MSG_* events
Fire ACHIEVEMENT_EARNED event when a player earns an achievement,
enabling achievement tracking addons.

Add 15 previously unmapped chat type → addon event mappings:
- CHAT_MSG_ACHIEVEMENT, CHAT_MSG_GUILD_ACHIEVEMENT
- CHAT_MSG_WHISPER_INFORM (echo of sent whispers)
- CHAT_MSG_RAID_LEADER, CHAT_MSG_BATTLEGROUND_LEADER
- CHAT_MSG_MONSTER_SAY/YELL/EMOTE/WHISPER
- CHAT_MSG_RAID_BOSS_EMOTE/WHISPER
- CHAT_MSG_BG_SYSTEM_NEUTRAL/ALLIANCE/HORDE

These events are needed by boss mod addons (DBM, BigWigs) to detect
boss emotes, by achievement trackers, and by chat filter addons that
process all message types.
2026-03-21 04:28:15 -07:00
Kelsi
6a0e86efe8 fix: IsUsableAction now checks spell power cost from DBC
IsUsableAction previously always returned notEnoughMana=false. Now it
checks the spell's mana cost from SpellDataResolver against the player's
current power, matching the same fix applied to IsUsableSpell.

This fixes action bar addons (Bartender, Dominos) incorrectly showing
abilities as usable when the player lacks mana/rage/energy.
2026-03-21 04:23:07 -07:00
Kelsi
91794f421e feat: add spell power cost to SpellDataResolver; fix IsUsableSpell mana check
Extend SpellDataInfo with manaCost and powerType fields, extracted from
Spell.dbc ManaCost and PowerType columns. This enables IsUsableSpell()
to properly check if the player has enough mana/rage/energy to cast.

Previously IsUsableSpell always returned notEnoughMana=false since cost
data wasn't available. Now it compares the spell's DBC mana cost against
the player's current power, returning accurate usability and mana state.

This fixes action bar addons showing abilities as usable when the player
lacks sufficient power, and enables OmniCC-style cooldown text to
properly dim insufficient-power abilities.
2026-03-21 04:20:58 -07:00
Kelsi
c7e16646fc feat: resolve spell cast time and range from DBC for GetSpellInfo
Add SpellDataResolver that lazily loads Spell.dbc, SpellCastTimes.dbc,
and SpellRange.dbc to provide cast time and range data. GetSpellInfo()
now returns real castTime (ms), minRange, and maxRange instead of
hardcoded 0 values.

This enables spell tooltip addons, cast bar addons (Quartz), and range
check addons to display accurate spell information. The DBC chain is:
  Spell.dbc[CastingTimeIndex] → SpellCastTimes.dbc[Base ms]
  Spell.dbc[RangeIndex] → SpellRange.dbc[MinRange, MaxRange]

Follows the same lazy-loading pattern as SpellIconPathResolver and
ItemIconPathResolver.
2026-03-21 04:16:12 -07:00
Kelsi
cfb9e09e1d feat: cache player class/race from name queries for UnitClass/UnitRace
Add playerClassRaceCache_ that stores classId and raceId from
SMSG_NAME_QUERY_RESPONSE. This enables UnitClass and UnitRace to return
correct data for players who were previously seen but are now out of
UPDATE_OBJECT range.

Fallback chain for UnitClass/UnitRace is now:
1. Entity update fields (UNIT_FIELD_BYTES_0) — for nearby entities
2. Name query cache — for previously queried players
3. getPlayerClass/Race() — for the local player

This improves class-colored names in chat, unit frames, and nameplates
for players who move out of view range.
2026-03-21 04:11:48 -07:00
Kelsi
d6a25ca8f2 fix: unit API functions return data for out-of-range party members
Previously UnitHealth, UnitHealthMax, UnitPower, UnitPowerMax, UnitLevel,
UnitName, and UnitExists returned 0/"Unknown"/false for party members in
other zones because the entity doesn't exist in the entity manager.

Now these functions fall back to SMSG_PARTY_MEMBER_STATS data stored in
GroupMember structs, which provides health, power, level, and name for
all party members regardless of distance. UnitName also falls back to
the player name cache.

This fixes raid frame addons (Grid, Healbot, VuhDo) showing blank/zero
data for party members who are out of UPDATE_OBJECT range.
2026-03-21 04:04:39 -07:00
Kelsi
61b54cfa74 feat: add unit state query functions and fix UnitAffectingCombat
Add 6 commonly needed unit state functions:
- UnitIsGhost(unit) checks ghost flag from UNIT_FIELD_FLAGS
- UnitIsDeadOrGhost(unit) combines dead + ghost checks
- UnitIsAFK(unit) / UnitIsDND(unit) check player flags
- UnitPlayerControlled(unit) true for players and player pets
- UnitSex(unit) reads gender from UNIT_FIELD_BYTES_0 byte 2

Fix UnitAffectingCombat to check UNIT_FLAG_IN_COMBAT (0x00080000)
from entity update fields for any unit, not just "player". Previously
returned false for all non-player units.

These functions are needed by unit frame addons (SUF, Pitbull, oUF)
to properly display ghost state, AFK/DND status, and combat state.
2026-03-21 03:59:04 -07:00
Kelsi
ec082e029c fix: UnitClass and UnitRace now work for target, focus, party, and all units
Previously UnitClass() only returned the correct class for "player" and
returned "Unknown" for all other units (target, focus, party1-4, etc.).
UnitRace() had the same bug.

Now both functions read UNIT_FIELD_BYTES_0 from the entity's update
fields to resolve class (byte 1) and race (byte 0) for any unit. This
fixes unit frame addons, class-colored names, and race-based logic for
all unit IDs.

Also fix UnitRace to return 3 values (localized, English, raceId) to
match WoW's API signature — previously it only returned 1.
2026-03-21 03:55:23 -07:00
Kelsi
8229a963d1 feat: add player name tab-completion in chat input
When typing commands like /w, /whisper, /invite, /trade, /duel, /follow,
/inspect, etc., pressing Tab now cycles through matching player names.

Name sources (in priority order):
1. Last whisper sender (most likely target for /r follow-ups)
2. Party/raid members
3. Friends list
4. Nearby visible players

Tab cycles through all matches; single match auto-appends a space.
Complements the existing slash-command tab-completion.
2026-03-21 03:49:02 -07:00
Kelsi
0d49cc8b94 fix: handle NPC facing-only rotation in SMSG_MONSTER_MOVE
Fix bug where NPCs receiving moveType=4 (FacingAngle) or moveType=3
(FacingTarget) monster move packets with zero waypoints would not
rotate in place. The handler only processed orientation when hasDest
was true, but facing-only updates have no destination waypoints.

Now NPCs properly rotate when:
- moveType=4: server specifies an exact facing angle (e.g., NPC turns
  to face the player during dialogue or scripted events)
- moveType=3: NPC should face a specific target entity

This fixes NPCs appearing frozen/unresponsive during scripted events,
quest interactions, and patrol waypoint facing changes.
2026-03-21 03:38:17 -07:00
Kelsi
a63f980e02 feat: add guild roster Lua API for guild management addons
Implement 5 guild-related WoW Lua API functions:
- IsInGuild() returns whether the player is in a guild
- GetGuildInfo("player") returns guildName, rankName, rankIndex
- GetNumGuildMembers() returns totalMembers, onlineMembers
- GetGuildRosterInfo(index) returns full 11-value tuple: name, rank,
  rankIndex, level, class, zone, note, officerNote, online, status, classId
- GetGuildRosterMOTD() returns the guild message of the day

Data sourced from SMSG_GUILD_ROSTER and SMSG_GUILD_QUERY_RESPONSE.
Enables guild management addons (GreenWall, officer tools, roster UIs).
2026-03-21 03:34:31 -07:00
Kelsi
7807058f9c feat: add SendAddonMessage and RegisterAddonMessagePrefix for addon comms
Implement the addon messaging API used by virtually every multiplayer
addon (DBM, BigWigs, EPGP, RC Loot Council, WeakAuras, etc.):

- SendAddonMessage(prefix, text, chatType, target) sends an addon
  message encoded as "prefix\ttext" via the appropriate chat channel
- RegisterAddonMessagePrefix(prefix) registers a prefix for filtering
  incoming addon messages
- IsAddonMessagePrefixRegistered(prefix) checks registration status
- C_ChatInfo table with aliases for the above functions (newer API compat)

Without these functions, all inter-addon communication between players
fails, breaking boss mods, loot distribution, and group coordination.
2026-03-21 03:31:54 -07:00
Kelsi
b2826ce589 feat: fire PLAYER_UPDATE_RESTING event on rest state changes
Fire PLAYER_UPDATE_RESTING when the player enters or leaves a resting
area (inn/capital city). Fires from both the SET_REST_START packet and
the QUEST_FORCE_REMOVE rest-state update path. Used by XP bar addons
and rest state indicator addons.
2026-03-21 03:27:09 -07:00
Kelsi
e64f9f4585 fix: add mail, auction, quest, and trade windows to Escape key chain
The Escape key now properly closes these windows before showing the
escape menu:
- Mail window (closeMailbox)
- Auction house (closeAuctionHouse)
- Quest details dialog (declineQuest)
- Quest offer reward dialog (closeQuestOfferReward)
- Quest request items dialog (closeQuestRequestItems)
- Trade window (cancelTrade)

Previously these windows required clicking their close button since
Escape would skip directly to the escape menu.
2026-03-21 03:24:23 -07:00
Kelsi
a39acd71ba feat: apply M2 color alpha and transparency tracks to batch opacity
Apply at-rest values from M2 color alpha and transparency animation
tracks to batch rendering opacity. This fixes models that should render
as semi-transparent (ghosts, ethereal effects, fading doodads) but were
previously rendering at full opacity.

The fix multiplies colorAlphas[batch.colorIndex] and
textureWeights[batch.transparencyIndex] into batchOpacity during model
setup. Zero values are skipped to avoid the edge case where animated
tracks start at 0 (invisible) and animate up — baking that first
keyframe would make the entire batch permanently invisible.
2026-03-21 03:14:57 -07:00
Kelsi
4f4c169825 feat: add GetNumFriends/GetFriendInfo/GetNumIgnores/GetIgnoreName API
Implement friend and ignore list query functions for social addons:
- GetNumFriends() returns friend count from contacts list
- GetFriendInfo(index) returns 7-value tuple: name, level, class, area,
  connected, status (AFK/DND), note
- GetNumIgnores() returns ignore count
- GetIgnoreName(index) returns ignored player's name

Data sourced from the contacts list populated by SMSG_FRIEND_LIST and
SMSG_CONTACT_LIST. Area names resolved from AreaTable.dbc.
2026-03-21 03:08:37 -07:00
Kelsi
b7e5034f27 feat: fire GUILD_ROSTER_UPDATE and GUILD_MOTD events for guild addons
Fire GUILD_ROSTER_UPDATE from SMSG_GUILD_ROSTER and from guild events
(member join/leave/kick, promotions, leader changes, online/offline,
disbanded). Fire GUILD_MOTD with the MOTD text when received.

These events are needed by guild management addons (GuildGreet,
GuildRoster replacements, officer tools) to refresh their UI.
2026-03-21 03:04:59 -07:00
Kelsi
b8d92b5ff2 feat: fire FRIENDLIST_UPDATE and IGNORELIST_UPDATE events
Fire FRIENDLIST_UPDATE from all three friend list packet handlers:
- SMSG_FRIEND_LIST (Classic format)
- SMSG_CONTACT_LIST (WotLK format)
- SMSG_FRIEND_STATUS (add/remove/online/offline updates)

Fire IGNORELIST_UPDATE when SMSG_CONTACT_LIST includes ignore entries.

These events are used by social addons to refresh their UI when the
friend/ignore list changes.
2026-03-21 03:01:55 -07:00
Kelsi
8f2a2dfbb4 feat: fire UNIT_NAME_UPDATE event when player names are resolved
Fire UNIT_NAME_UPDATE for target/focus/player when SMSG_NAME_QUERY_RESPONSE
resolves a player's name. Nameplate and unit frame addons use this event
to update displayed names when they become available asynchronously.
2026-03-21 02:58:55 -07:00
Kelsi
3b8165cbef feat: fire events for loot rolls, trade windows, and duels
Add missing addon events for three gameplay systems:

Loot rolls:
- START_LOOT_ROLL fires on SMSG_LOOT_START_ROLL with slot and countdown
- LOOT_SLOT_CLEARED fires when a loot item is removed (SMSG_LOOT_REMOVED)

Trade:
- TRADE_REQUEST when another player initiates a trade
- TRADE_SHOW when the trade window opens
- TRADE_CLOSED when trade is cancelled, declined, or completed
- TRADE_ACCEPT_UPDATE when the trade partner accepts

Duels:
- DUEL_REQUESTED with challenger name on incoming duel challenge
- DUEL_FINISHED when a duel completes or is cancelled
2026-03-21 02:57:00 -07:00
Kelsi
7105672f06 feat: resolve item icon paths from ItemDisplayInfo.dbc for Lua API
Add ItemIconPathResolver that lazily loads ItemDisplayInfo.dbc to map
displayInfoId → icon texture path. This fixes three Lua API functions
that previously returned nil for item icons:

- GetItemInfo() field 10 (texture) now returns the icon path
- GetActionTexture() for item-type action bar slots now returns icons
- GetLootSlotInfo() field 1 (texture) now returns proper item icons
  instead of incorrectly using the spell icon resolver

Follows the same lazy-loading pattern as SpellIconPathResolver. The DBC
is loaded once on first query and cached for all subsequent lookups.
2026-03-21 02:53:07 -07:00
Kelsi
e21f808714 feat: support SavedVariablesPerCharacter for per-character addon data
Implement the SavedVariablesPerCharacter TOC directive that many addons
use to store different settings per character (Bartender, Dominos,
MoveAnything, WeakAuras, etc.). Without this, all characters share the
same addon data file.

Per-character files are stored as <AddonName>.<CharacterName>.lua.saved
alongside the existing account-wide <AddonName>.lua.saved files. The
character name is resolved from the player GUID at world entry time.

Changes:
- TocFile::getSavedVariablesPerCharacter() parses the TOC directive
- AddonManager loads/saves per-character vars alongside account-wide vars
- Character name set from game handler before addon loading
2026-03-21 02:46:21 -07:00
Kelsi
0d2fd02dca feat: add 40+ frame metatable methods to prevent addon nil-reference errors
Add commonly called frame methods as no-ops or with basic state tracking
on the frame metatable, so any CreateFrame result supports them:

Layout: SetFrameLevel/Get, SetFrameStrata/Get, SetScale/Get/GetEffective,
  ClearAllPoints, SetID/GetID, GetLeft/Right/Top/Bottom, GetNumPoints,
  GetPoint, SetHitRectInsets
Behavior: EnableMouse, EnableMouseWheel, SetMovable, SetResizable,
  RegisterForDrag, SetClampedToScreen, SetToplevel, Raise, Lower,
  StartMoving, StopMovingOrSizing, RegisterForClicks, IsMouseOver
Visual: SetBackdrop, SetBackdropColor, SetBackdropBorderColor
Scripting: HookScript (chains with existing SetScript handlers),
  SetAttribute/GetAttribute, GetObjectType
Sizing: SetMinResize, SetMaxResize

These prevent the most common addon errors when addons call standard
WoW frame methods on CreateFrame results.
2026-03-21 02:39:44 -07:00
Kelsi
b99bf7021b feat: add WoW table/string/math/bit utility functions for addon compat
Add commonly used WoW global utility functions that many addons depend on:

Table: tContains, tInvert, CopyTable, tDeleteItem
String: strupper, strlower, strfind, strsub, strlen, strrep, strbyte,
  strchar, strrev, gsub, gmatch, strjoin
Math: Clamp, Round
Bit ops: bit.band, bit.bor, bit.bxor, bit.bnot, bit.lshift, bit.rshift
  (pure Lua implementation for Lua 5.1 which lacks native bit ops)

These prevent nil-reference errors and missing-function crashes in
addons that use standard WoW utility globals.
2026-03-21 02:37:56 -07:00
Kelsi
154140f185 feat: add UIDropDownMenu framework, font objects, and UI global stubs
Add the UIDropDownMenu compatibility framework used by virtually every
addon with settings or selection menus: UIDropDownMenu_Initialize,
CreateInfo, AddButton, SetWidth, SetText, GetText, SetSelectedID, etc.

Add global font object stubs (GameFontNormal, GameFontHighlight, etc.)
referenced by CreateFontString template arguments.

Add UISpecialFrames table, InterfaceOptionsFrame for addon panels,
InterfaceOptions_AddCategory, and common font color constants
(GRAY_FONT_COLOR, NORMAL_FONT_COLOR, etc.).

These globals prevent nil-reference errors in most popular addons.
2026-03-21 02:36:06 -07:00
Kelsi
760c6a2790 feat: fire PLAYER_ENTER_COMBAT and PLAYER_LEAVE_COMBAT events
Fire PLAYER_ENTER_COMBAT when the player's auto-attack starts
(SMSG_ATTACKSTART) and PLAYER_LEAVE_COMBAT when auto-attack stops.
These events are distinct from PLAYER_REGEN_DISABLED/ENABLED — they
specifically track physical melee combat state and are used by
combat-aware addons for weapon swing timers and attack state tracking.
2026-03-21 02:31:59 -07:00
Kelsi
60904e2e15 fix: fire talent/spell events correctly when learning talents
Fix bug where learning a talent caused an early return before firing
LEARNED_SPELL_IN_TAB and SPELLS_CHANGED events, leaving talent addons
unaware of changes. Now talent learning fires CHARACTER_POINTS_CHANGED,
PLAYER_TALENT_UPDATE, LEARNED_SPELL_IN_TAB, and SPELLS_CHANGED.

Also fire CHARACTER_POINTS_CHANGED, ACTIVE_TALENT_GROUP_CHANGED, and
PLAYER_TALENT_UPDATE from handleTalentsInfo (SMSG_TALENTS_INFO), so
talent addons update when the full talent state is received from the
server (login, spec switch, respec).

Also fire UNIT_HEALTH/UNIT_POWER events from SMSG_HEALTH_UPDATE and
SMSG_POWER_UPDATE packets for real-time unit frame updates.
2026-03-21 02:29:48 -07:00
Kelsi
d75f2c62e5 feat: fire UNIT_HEALTH/UNIT_POWER events from dedicated update packets
SMSG_HEALTH_UPDATE and SMSG_POWER_UPDATE are high-frequency WotLK
packets that update entity health/power values but weren't firing
addon events. Unit frame addons (Pitbull, oUF, SUF) depend on these
events to update health/mana bars in real-time.

Now fire UNIT_HEALTH for player/target/focus on SMSG_HEALTH_UPDATE
and UNIT_POWER on SMSG_POWER_UPDATE, matching the events already
fired from the UPDATE_OBJECT path.
2026-03-21 02:26:44 -07:00
Kelsi
55ef607093 feat: add talent tree Lua API for talent inspection addons
Implement 5 talent-related WoW Lua API functions:
- GetNumTalentTabs() returns class-specific talent tree count (usually 3)
- GetTalentTabInfo(tab) returns name, icon, pointsSpent, background
- GetNumTalents(tab) returns talent count in a specific tree
- GetTalentInfo(tab, index) returns full 8-value tuple with name, tier,
  column, current rank, max rank, and availability
- GetActiveTalentGroup() returns active spec (1 or 2)

Data sourced from Talent.dbc, TalentTab.dbc, and the server-sent talent
info packet. Enables talent addons and spec display addons.
2026-03-21 02:22:35 -07:00
Kelsi
0a6fdfb8b1 feat: add GetNumSkillLines and GetSkillLineInfo for profession addons
Implement skill line API functions that profession and tradeskill addons
need to display player skills:
- GetNumSkillLines() returns count of player skills
- GetSkillLineInfo(index) returns full 12-value tuple: name, isHeader,
  isExpanded, rank, tempPoints, modifier, maxRank, isAbandonable, etc.

Data comes from SMSG_SKILLS_INFO update fields and SkillLine.dbc names.
2026-03-21 02:18:25 -07:00
Kelsi
855f00c5b5 feat: add LibStub and CallbackHandler-1.0 for Ace3 addon compatibility
Implement LibStub — the universal library version management system that
virtually every WoW addon framework depends on (Ace3, LibDataBroker,
LibSharedMedia, etc.). Without LibStub, most popular addons fail to load.

Also implement CallbackHandler-1.0 — the standard event callback library
used by Ace3-based addons for inter-module communication. Supports
RegisterCallback, UnregisterCallback, UnregisterAllCallbacks, and Fire.

These two libraries unlock the entire Ace3 addon ecosystem.
2026-03-21 02:15:50 -07:00
Kelsi
c20db42479 feat: fire UNIT_SPELLCAST_SENT and UNIT_SPELLCAST_STOP events
Fire UNIT_SPELLCAST_SENT when the player initiates a spell cast (before
server confirms), enabling cast bar addons like Quartz to show latency.
Includes target name and spell ID as arguments.

Fire UNIT_SPELLCAST_STOP whenever a cast bar should disappear:
- On successful cast completion (SMSG_SPELL_GO)
- On cast failure (SMSG_CAST_RESULT with error)
- On spell interrupt (SMSG_SPELL_FAILURE/SMSG_SPELL_FAILED_OTHER)
- On manual cast cancel

These events are essential for cast bar replacement addons to properly
track when casts begin and end.
2026-03-21 02:10:09 -07:00
Kelsi
6e863a323a feat: add UseAction, CancelUnitBuff, and CastSpellByID Lua functions
Implement 3 critical gameplay Lua API functions:
- UseAction(slot) activates an action bar slot (spell/item), enabling
  action bar addons like Bartender/Dominos to fire abilities
- CancelUnitBuff("player", index) cancels a buff by index, enabling
  auto-cancel and buff management addons
- CastSpellByID(id) casts a spell by numeric ID, enabling macro addons
  and spell queuing systems
2026-03-21 02:03:51 -07:00
Kelsi
45850c5aa9 feat: add targeting Lua API functions for addon and macro support
Implement 8 targeting functions commonly used by unit frame addons,
targeting macros, and click-casting addons:
- TargetUnit(unitId) / ClearTarget()
- FocusUnit(unitId) / ClearFocus()
- AssistUnit(unitId) — target the given unit's target
- TargetLastTarget() — return to previous target
- TargetNearestEnemy() / TargetNearestFriend() — tab-targeting
2026-03-21 01:58:03 -07:00
Kelsi
3ae18f03a1 feat: fire UNIT_HEALTH/POWER/AURA events for party members; fix closeLoot event
Fire UNIT_HEALTH, UNIT_POWER, and UNIT_AURA events from
SMSG_PARTY_MEMBER_STATS with proper unit IDs (party1..4, raid1..40).
Previously, health/power changes for party members via the stats packet
were silent — raid frame addons never got notified.

Also fix closeLoot() not firing LOOT_CLOSED event when the loot window
is closed by the player (only handleLootReleaseResponse fired it).
2026-03-21 01:55:30 -07:00
Kelsi
00a97aae3f fix: remove Lua stubs overriding C implementations; add GameTooltip and frame factories
Fix GetScreenWidth/GetScreenHeight/GetNumLootItems/GetFramerate being
overridden by hardcoded Lua stubs that ran after the C functions were
registered. Now the real C implementations correctly take effect.

Add GameTooltip global frame with 20+ methods (SetOwner, ClearLines,
AddLine, AddDoubleLine, SetText, NumLines, GetText, SetHyperlink, etc.)
and ShoppingTooltip1/2 — critical for virtually all WoW addons.

Add frame:CreateTexture() and frame:CreateFontString() methods returning
stub objects with common API methods, enabling UI creation addons.

Add real GetFramerate() returning actual FPS from ImGui.
2026-03-21 01:52:59 -07:00
Kelsi
ce26284b90 feat: add GetCursorPosition, screen size queries, and frame positioning methods
Add global Lua API functions:
- GetCursorPosition() returns mouse x,y screen coordinates
- GetScreenWidth()/GetScreenHeight() return window dimensions

Add frame methods for UI layout:
- SetPoint, SetSize, SetWidth, SetHeight, GetWidth, GetHeight, GetCenter
- SetAlpha, GetAlpha, SetParent, GetParent

These enable UI customization addons to query cursor position, screen
dimensions, and manage frame layout — fundamental for unit frames,
action bars, and tooltip addons.
2026-03-21 01:44:59 -07:00
Kelsi
8555c80aa2 feat: add loot window Lua API for addon compatibility
Implement 6 loot-related WoW Lua API functions:
- GetNumLootItems() returns count of items in loot window
- GetLootSlotInfo(slot) returns texture, name, quantity, quality, locked
- GetLootSlotLink(slot) returns item link string
- LootSlot(slot) takes an item from loot
- CloseLoot() closes the loot window
- GetLootMethod() returns current group loot method

These pair with the LOOT_OPENED/LOOT_CLOSED events to enable loot
addons (AutoLoot, loot filters, master loot helpers).
2026-03-21 01:42:03 -07:00
Kelsi
7459f27771 feat: add targettarget, focustarget, pettarget, mouseovertarget unit IDs
Support compound unit IDs that resolve an entity's current target via
UNIT_FIELD_TARGET_LO/HI update fields. This enables addons to query
target-of-target info (e.g., UnitName("targettarget"), UnitHealth("focustarget"))
which is essential for threat meters and unit frame addons.
2026-03-21 01:37:44 -07:00
Kelsi
74125b7340 feat: fire LOOT/GOSSIP/QUEST/TRAINER addon events on window open/close
Fire the following events for addon compatibility:
- LOOT_OPENED, LOOT_CLOSED on loot window open/close
- GOSSIP_SHOW, GOSSIP_CLOSED on gossip/quest-list window open/close
- QUEST_DETAIL when quest details are shown to the player
- QUEST_COMPLETE when quest offer reward dialog opens
- TRAINER_SHOW, TRAINER_CLOSED on trainer window open/close
2026-03-21 01:35:18 -07:00
Kelsi
fe8950bd4b feat: add action bar, combo points, reaction, and connection Lua API functions
Implement 10 new WoW Lua API functions for addon compatibility:
- GetComboPoints, UnitReaction, UnitIsConnected for unit frames/raid addons
- HasAction, GetActionTexture, IsCurrentAction, IsUsableAction, GetActionCooldown
  for action bar addons (Bartender, Dominos, etc.)
- UnitMana/UnitManaMax as Classic-era aliases for UnitPower/UnitPowerMax
2026-03-21 01:31:34 -07:00
Kelsi
32a51aa93d feat: add mouseover unit ID support and fire UPDATE_MOUSEOVER_UNIT/PLAYER_FOCUS_CHANGED events
Add "mouseover" as a valid unit ID in resolveUnitGuid so Lua API functions
like UnitName("mouseover"), UnitHealth("mouseover") etc. work for addons.
Fire UPDATE_MOUSEOVER_UNIT event when the mouseover target changes, and
PLAYER_FOCUS_CHANGED event when focus is set or cleared.
2026-03-21 01:26:37 -07:00
12 changed files with 2441 additions and 52 deletions

View file

@ -26,6 +26,7 @@ public:
bool isInitialized() const { return luaEngine_.isInitialized(); }
void saveAllSavedVariables();
void setCharacterName(const std::string& name) { characterName_ = name; }
/// Re-initialize the Lua VM and reload all addons (used by /reload).
bool reload();
@ -38,6 +39,8 @@ private:
bool loadAddon(const TocFile& addon);
std::string getSavedVariablesPath(const TocFile& addon) const;
std::string getSavedVariablesPerCharacterPath(const TocFile& addon) const;
std::string characterName_;
};
} // namespace wowee::addons

View file

@ -1,5 +1,6 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
@ -47,9 +48,14 @@ public:
lua_State* getState() { return L_; }
bool isInitialized() const { return L_ != nullptr; }
// Optional callback for Lua errors (displayed as UI errors to the player)
using LuaErrorCallback = std::function<void(const std::string&)>;
void setLuaErrorCallback(LuaErrorCallback cb) { luaErrorCallback_ = std::move(cb); }
private:
lua_State* L_ = nullptr;
game::GameHandler* gameHandler_ = nullptr;
LuaErrorCallback luaErrorCallback_;
void registerCoreAPI();
void registerEventAPI();

View file

@ -18,6 +18,7 @@ struct TocFile {
std::string getInterface() const;
bool isLoadOnDemand() const;
std::vector<std::string> getSavedVariables() const;
std::vector<std::string> getSavedVariablesPerCharacter() const;
};
std::optional<TocFile> parseTocFile(const std::string& tocPath);

View file

@ -294,6 +294,21 @@ public:
return spellIconPathResolver_ ? spellIconPathResolver_(spellId) : std::string{};
}
// Spell data resolver: spellId -> {castTimeMs, minRange, maxRange}
struct SpellDataInfo { uint32_t castTimeMs = 0; float minRange = 0; float maxRange = 0; uint32_t manaCost = 0; uint8_t powerType = 0; };
using SpellDataResolver = std::function<SpellDataInfo(uint32_t)>;
void setSpellDataResolver(SpellDataResolver r) { spellDataResolver_ = std::move(r); }
SpellDataInfo getSpellData(uint32_t spellId) const {
return spellDataResolver_ ? spellDataResolver_(spellId) : SpellDataInfo{};
}
// Item icon path resolver: displayInfoId -> texture path (e.g., "Interface\\Icons\\INV_Sword_04")
using ItemIconPathResolver = std::function<std::string(uint32_t)>;
void setItemIconPathResolver(ItemIconPathResolver r) { itemIconPathResolver_ = std::move(r); }
std::string getItemIconPath(uint32_t displayInfoId) const {
return itemIconPathResolver_ ? itemIconPathResolver_(displayInfoId) : std::string{};
}
// Random property/suffix name resolver: randomPropertyId -> suffix name (e.g., "of the Eagle")
// Positive IDs → ItemRandomProperties.dbc; negative IDs → ItemRandomSuffix.dbc (abs value)
using RandomPropertyNameResolver = std::function<std::string(int32_t)>;
@ -403,7 +418,7 @@ public:
bool hasFocus() const { return focusGuid != 0; }
// Mouseover targeting — set each frame by the nameplate renderer
void setMouseoverGuid(uint64_t guid) { mouseoverGuid_ = guid; }
void setMouseoverGuid(uint64_t guid);
uint64_t getMouseoverGuid() const { return mouseoverGuid_; }
// Advanced targeting
@ -1228,6 +1243,16 @@ public:
// Player GUID
uint64_t getPlayerGuid() const { return playerGuid; }
// Look up class/race for a player GUID from name query cache. Returns 0 if unknown.
uint8_t lookupPlayerClass(uint64_t guid) const {
auto it = playerClassRaceCache_.find(guid);
return it != playerClassRaceCache_.end() ? it->second.classId : 0;
}
uint8_t lookupPlayerRace(uint64_t guid) const {
auto it = playerClassRaceCache_.find(guid);
return it != playerClassRaceCache_.end() ? it->second.raceId : 0;
}
// Look up a display name for any guid: checks playerNameCache then entity manager.
// Returns empty string if unknown. Used by chat display to resolve names at render time.
const std::string& lookupName(uint64_t guid) const {
@ -2662,6 +2687,8 @@ private:
AddonChatCallback addonChatCallback_;
AddonEventCallback addonEventCallback_;
SpellIconPathResolver spellIconPathResolver_;
ItemIconPathResolver itemIconPathResolver_;
SpellDataResolver spellDataResolver_;
RandomPropertyNameResolver randomPropertyNameResolver_;
EmoteAnimCallback emoteAnimCallback_;
@ -2702,6 +2729,9 @@ private:
// ---- Phase 1: Name caches ----
std::unordered_map<uint64_t, std::string> playerNameCache;
// Class/race cache from SMSG_NAME_QUERY_RESPONSE (guid → {classId, raceId})
struct PlayerClassRace { uint8_t classId = 0; uint8_t raceId = 0; };
std::unordered_map<uint64_t, PlayerClassRace> playerClassRaceCache_;
std::unordered_set<uint64_t> pendingNameQueries;
std::unordered_map<uint32_t, CreatureQueryResponseData> creatureInfoCache;
std::unordered_set<uint32_t> pendingCreatureQueries;

View file

@ -62,7 +62,10 @@ private:
// Populated by the SpellCastFailedCallback; queried during action bar button rendering.
std::unordered_map<uint32_t, float> actionFlashEndTimes_;
// Tab-completion state for slash commands
// Cached game handler for input callbacks (set each frame in render)
game::GameHandler* cachedGameHandler_ = nullptr;
// Tab-completion state for slash commands and player names
std::string chatTabPrefix_; // prefix captured on first Tab press
std::vector<std::string> chatTabMatches_; // matching command list
int chatTabMatchIdx_ = -1; // active match index (-1 = inactive)

View file

@ -68,6 +68,11 @@ std::string AddonManager::getSavedVariablesPath(const TocFile& addon) const {
return addon.basePath + "/" + addon.addonName + ".lua.saved";
}
std::string AddonManager::getSavedVariablesPerCharacterPath(const TocFile& addon) const {
if (characterName_.empty()) return "";
return addon.basePath + "/" + addon.addonName + "." + characterName_ + ".lua.saved";
}
bool AddonManager::loadAddon(const TocFile& addon) {
// Load SavedVariables before addon code (so globals are available at load time)
auto savedVars = addon.getSavedVariables();
@ -76,6 +81,15 @@ bool AddonManager::loadAddon(const TocFile& addon) {
luaEngine_.loadSavedVariables(svPath);
LOG_DEBUG("AddonManager: loaded saved variables for '", addon.addonName, "'");
}
// Load per-character SavedVariables
auto savedVarsPC = addon.getSavedVariablesPerCharacter();
if (!savedVarsPC.empty()) {
std::string svpcPath = getSavedVariablesPerCharacterPath(addon);
if (!svpcPath.empty()) {
luaEngine_.loadSavedVariables(svpcPath);
LOG_DEBUG("AddonManager: loaded per-character saved variables for '", addon.addonName, "'");
}
}
bool success = true;
for (const auto& filename : addon.files) {
@ -120,6 +134,13 @@ void AddonManager::saveAllSavedVariables() {
std::string svPath = getSavedVariablesPath(addon);
luaEngine_.saveSavedVariables(svPath, savedVars);
}
auto savedVarsPC = addon.getSavedVariablesPerCharacter();
if (!savedVarsPC.empty()) {
std::string svpcPath = getSavedVariablesPerCharacterPath(addon);
if (!svpcPath.empty()) {
luaEngine_.saveSavedVariables(svpcPath, savedVarsPC);
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -19,17 +19,12 @@ bool TocFile::isLoadOnDemand() const {
return (it != directives.end()) && it->second == "1";
}
std::vector<std::string> TocFile::getSavedVariables() const {
static std::vector<std::string> parseVarList(const std::string& val) {
std::vector<std::string> result;
auto it = directives.find("SavedVariables");
if (it == directives.end()) return result;
// Parse comma-separated variable names
std::string val = it->second;
size_t pos = 0;
while (pos <= val.size()) {
size_t comma = val.find(',', pos);
std::string name = (comma != std::string::npos) ? val.substr(pos, comma - pos) : val.substr(pos);
// Trim whitespace
size_t start = name.find_first_not_of(" \t");
size_t end = name.find_last_not_of(" \t");
if (start != std::string::npos)
@ -40,6 +35,16 @@ std::vector<std::string> TocFile::getSavedVariables() const {
return result;
}
std::vector<std::string> TocFile::getSavedVariables() const {
auto it = directives.find("SavedVariables");
return (it != directives.end()) ? parseVarList(it->second) : std::vector<std::string>{};
}
std::vector<std::string> TocFile::getSavedVariablesPerCharacter() const {
auto it = directives.find("SavedVariablesPerCharacter");
return (it != directives.end()) ? parseVarList(it->second) : std::vector<std::string>{};
}
std::optional<TocFile> parseTocFile(const std::string& tocPath) {
std::ifstream f(tocPath);
if (!f.is_open()) return std::nullopt;

View file

@ -335,6 +335,10 @@ bool Application::initialize() {
if (addonManager_->initialize(gameHandler.get())) {
std::string addonsDir = assetPath + "/interface/AddOns";
addonManager_->scanAddons(addonsDir);
// Wire Lua errors to UI error display
addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) {
if (gh) gh->addUIError(err);
});
// Wire chat messages to addon event dispatch
gameHandler->setAddonChatCallback([this](const game::MessageChatData& msg) {
if (!addonManager_ || !addonsLoaded_) return;
@ -354,6 +358,25 @@ bool Application::initialize() {
case game::ChatType::CHANNEL: eventName = "CHAT_MSG_CHANNEL"; break;
case game::ChatType::EMOTE:
case game::ChatType::TEXT_EMOTE: eventName = "CHAT_MSG_EMOTE"; break;
case game::ChatType::ACHIEVEMENT: eventName = "CHAT_MSG_ACHIEVEMENT"; break;
case game::ChatType::GUILD_ACHIEVEMENT: eventName = "CHAT_MSG_GUILD_ACHIEVEMENT"; break;
case game::ChatType::WHISPER_INFORM: eventName = "CHAT_MSG_WHISPER_INFORM"; break;
case game::ChatType::RAID_LEADER: eventName = "CHAT_MSG_RAID_LEADER"; break;
case game::ChatType::BATTLEGROUND_LEADER: eventName = "CHAT_MSG_BATTLEGROUND_LEADER"; break;
case game::ChatType::MONSTER_SAY: eventName = "CHAT_MSG_MONSTER_SAY"; break;
case game::ChatType::MONSTER_YELL: eventName = "CHAT_MSG_MONSTER_YELL"; break;
case game::ChatType::MONSTER_EMOTE: eventName = "CHAT_MSG_MONSTER_EMOTE"; break;
case game::ChatType::MONSTER_WHISPER: eventName = "CHAT_MSG_MONSTER_WHISPER"; break;
case game::ChatType::RAID_BOSS_EMOTE: eventName = "CHAT_MSG_RAID_BOSS_EMOTE"; break;
case game::ChatType::RAID_BOSS_WHISPER: eventName = "CHAT_MSG_RAID_BOSS_WHISPER"; break;
case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break;
case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break;
case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break;
case game::ChatType::MONSTER_PARTY: eventName = "CHAT_MSG_MONSTER_PARTY"; break;
case game::ChatType::AFK: eventName = "CHAT_MSG_AFK"; break;
case game::ChatType::DND: eventName = "CHAT_MSG_DND"; break;
case game::ChatType::LOOT: eventName = "CHAT_MSG_LOOT"; break;
case game::ChatType::SKILL: eventName = "CHAT_MSG_SKILL"; break;
default: break;
}
if (eventName) {
@ -413,6 +436,119 @@ bool Application::initialize() {
return pit->second;
});
}
// Wire item icon path resolver: displayInfoId -> "Interface\\Icons\\INV_..."
{
auto iconNames = std::make_shared<std::unordered_map<uint32_t, std::string>>();
auto loaded = std::make_shared<bool>(false);
auto* am = assetManager.get();
gameHandler->setItemIconPathResolver([iconNames, loaded, am](uint32_t displayInfoId) -> std::string {
if (!am || displayInfoId == 0) return {};
if (!*loaded) {
*loaded = true;
auto dbc = am->loadDBC("ItemDisplayInfo.dbc");
const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
if (dbc && dbc->isLoaded()) {
uint32_t iconField = dispL ? (*dispL)["InventoryIcon"] : 5;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
uint32_t id = dbc->getUInt32(i, 0); // field 0 = ID
std::string name = dbc->getString(i, iconField);
if (id > 0 && !name.empty()) (*iconNames)[id] = name;
}
LOG_INFO("Loaded ", iconNames->size(), " item icon names from ItemDisplayInfo.dbc");
}
}
auto it = iconNames->find(displayInfoId);
if (it == iconNames->end()) return {};
return "Interface\\Icons\\" + it->second;
});
}
// Wire spell data resolver: spellId -> {castTimeMs, minRange, maxRange}
{
auto castTimeMap = std::make_shared<std::unordered_map<uint32_t, uint32_t>>();
auto rangeMap = std::make_shared<std::unordered_map<uint32_t, std::pair<float,float>>>();
auto spellCastIdx = std::make_shared<std::unordered_map<uint32_t, uint32_t>>(); // spellId→castTimeIdx
auto spellRangeIdx = std::make_shared<std::unordered_map<uint32_t, uint32_t>>(); // spellId→rangeIdx
struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; };
auto spellCostMap = std::make_shared<std::unordered_map<uint32_t, SpellCostEntry>>();
auto loaded = std::make_shared<bool>(false);
auto* am = assetManager.get();
gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo {
if (!am) return {};
if (!*loaded) {
*loaded = true;
// Load SpellCastTimes.dbc
auto ctDbc = am->loadDBC("SpellCastTimes.dbc");
if (ctDbc && ctDbc->isLoaded()) {
for (uint32_t i = 0; i < ctDbc->getRecordCount(); ++i) {
uint32_t id = ctDbc->getUInt32(i, 0);
int32_t base = static_cast<int32_t>(ctDbc->getUInt32(i, 1));
if (id > 0 && base > 0) (*castTimeMap)[id] = static_cast<uint32_t>(base);
}
}
// Load SpellRange.dbc
const auto* srL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr;
uint32_t minRField = srL ? (*srL)["MinRange"] : 1;
uint32_t maxRField = srL ? (*srL)["MaxRange"] : 4;
auto rDbc = am->loadDBC("SpellRange.dbc");
if (rDbc && rDbc->isLoaded()) {
for (uint32_t i = 0; i < rDbc->getRecordCount(); ++i) {
uint32_t id = rDbc->getUInt32(i, 0);
float minR = rDbc->getFloat(i, minRField);
float maxR = rDbc->getFloat(i, maxRField);
if (id > 0) (*rangeMap)[id] = {minR, maxR};
}
}
// Load Spell.dbc: extract castTimeIndex and rangeIndex per spell
auto sDbc = am->loadDBC("Spell.dbc");
const auto* spL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
if (sDbc && sDbc->isLoaded()) {
uint32_t idF = spL ? (*spL)["ID"] : 0;
uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default
uint32_t rF = spL ? (*spL)["RangeIndex"] : 132;
uint32_t ptF = UINT32_MAX, mcF = UINT32_MAX;
if (spL) {
try { ptF = (*spL)["PowerType"]; } catch (...) {}
try { mcF = (*spL)["ManaCost"]; } catch (...) {}
}
uint32_t fc = sDbc->getFieldCount();
for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) {
uint32_t id = sDbc->getUInt32(i, idF);
if (id == 0) continue;
uint32_t ct = sDbc->getUInt32(i, ctF);
uint32_t ri = sDbc->getUInt32(i, rF);
if (ct > 0) (*spellCastIdx)[id] = ct;
if (ri > 0) (*spellRangeIdx)[id] = ri;
// Extract power cost
uint32_t mc = (mcF < fc) ? sDbc->getUInt32(i, mcF) : 0;
uint8_t pt = (ptF < fc) ? static_cast<uint8_t>(sDbc->getUInt32(i, ptF)) : 0;
if (mc > 0) (*spellCostMap)[id] = {mc, pt};
}
}
LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ",
spellRangeIdx->size(), " range indices");
}
game::GameHandler::SpellDataInfo info;
auto ciIt = spellCastIdx->find(spellId);
if (ciIt != spellCastIdx->end()) {
auto ctIt = castTimeMap->find(ciIt->second);
if (ctIt != castTimeMap->end()) info.castTimeMs = ctIt->second;
}
auto riIt = spellRangeIdx->find(spellId);
if (riIt != spellRangeIdx->end()) {
auto rIt = rangeMap->find(riIt->second);
if (rIt != rangeMap->end()) {
info.minRange = rIt->second.first;
info.maxRange = rIt->second.second;
}
}
auto mcIt = spellCostMap->find(spellId);
if (mcIt != spellCostMap->end()) {
info.manaCost = mcIt->second.manaCost;
info.powerType = mcIt->second.powerType;
}
return info;
});
}
// Wire random property/suffix name resolver for item display
{
auto propNames = std::make_shared<std::unordered_map<int32_t, std::string>>();
@ -5182,6 +5318,21 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// Load addons once per session on first world entry
if (addonManager_ && !addonsLoaded_) {
// Set character name for per-character SavedVariables
if (gameHandler) {
const std::string& charName = gameHandler->lookupName(gameHandler->getPlayerGuid());
if (!charName.empty()) {
addonManager_->setCharacterName(charName);
} else {
// Fallback: find name from character list
for (const auto& c : gameHandler->getCharacters()) {
if (c.guid == gameHandler->getPlayerGuid()) {
addonManager_->setCharacterName(c.name);
break;
}
}
}
}
addonManager_->loadAllAddons();
addonsLoaded_ = true;
addonManager_->fireEvent("VARIABLES_LOADED");

View file

@ -2010,6 +2010,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
pendingItemPushNotifs_.push_back({itemId, count});
}
}
// Fire bag/inventory events for all item receipts (not just chat-visible ones)
if (addonEventCallback_) {
addonEventCallback_("BAG_UPDATE", {});
addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"});
}
LOG_INFO("Item push: itemId=", itemId, " count=", count,
" showInChat=", static_cast<int>(showInChat));
}
@ -2160,6 +2165,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
unit->setHealth(hp);
}
if (addonEventCallback_ && guid != 0) {
std::string unitId;
if (guid == playerGuid) unitId = "player";
else if (guid == targetGuid) unitId = "target";
else if (guid == focusGuid) unitId = "focus";
else if (guid == petGuid_) unitId = "pet";
if (!unitId.empty())
addonEventCallback_("UNIT_HEALTH", {unitId});
}
break;
}
case Opcode::SMSG_POWER_UPDATE: {
@ -2177,6 +2191,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
unit->setPowerByType(powerType, value);
}
if (addonEventCallback_ && guid != 0) {
std::string unitId;
if (guid == playerGuid) unitId = "player";
else if (guid == targetGuid) unitId = "target";
else if (guid == focusGuid) unitId = "focus";
else if (guid == petGuid_) unitId = "pet";
if (!unitId.empty())
addonEventCallback_("UNIT_POWER", {unitId});
}
break;
}
@ -2213,6 +2236,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (pvpHonorCallback_) {
pvpHonorCallback_(honor, victimGuid, rank);
}
if (addonEventCallback_)
addonEventCallback_("CHAT_MSG_COMBAT_HONOR_GAIN", {msg});
}
break;
}
@ -2250,6 +2275,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
mirrorTimers_[type].scale = scale;
mirrorTimers_[type].paused = (paused != 0);
mirrorTimers_[type].active = true;
if (addonEventCallback_)
addonEventCallback_("MIRROR_TIMER_START", {
std::to_string(type), std::to_string(value),
std::to_string(maxV), std::to_string(scale),
paused ? "1" : "0"});
}
break;
}
@ -2260,6 +2290,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (type < 3) {
mirrorTimers_[type].active = false;
mirrorTimers_[type].value = 0;
if (addonEventCallback_)
addonEventCallback_("MIRROR_TIMER_STOP", {std::to_string(type)});
}
break;
}
@ -2304,8 +2336,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
addUIError(errMsg);
if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId);
if (addonEventCallback_)
if (addonEventCallback_) {
addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)});
addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)});
}
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
@ -2326,6 +2360,16 @@ void GameHandler::handlePacket(network::Packet& packet) {
: UpdateObjectParser::readPackedGuid(packet);
if (failOtherGuid != 0 && failOtherGuid != playerGuid) {
unitCastStates_.erase(failOtherGuid);
// Fire cast failure events so cast bar addons clear the bar
if (addonEventCallback_) {
std::string unitId;
if (failOtherGuid == targetGuid) unitId = "target";
else if (failOtherGuid == focusGuid) unitId = "focus";
if (!unitId.empty()) {
addonEventCallback_("UNIT_SPELLCAST_FAILED", {unitId});
addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId});
}
}
}
packet.setReadPos(packet.getSize());
break;
@ -2402,6 +2446,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName,
") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec);
if (addonEventCallback_)
addonEventCallback_("START_LOOT_ROLL", {std::to_string(slot), std::to_string(countdown)});
break;
}
@ -3265,8 +3311,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
sendMovement(Opcode::MSG_MOVE_STOP_TURN);
sendMovement(Opcode::MSG_MOVE_STOP_SWIM);
addSystemChatMessage("Movement disabled by server.");
if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_LOST", {});
} else if (changed && allowMovement) {
addSystemChatMessage("Movement re-enabled.");
if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_GAINED", {});
}
}
break;
@ -3418,8 +3466,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (failGuid == playerGuid || failGuid == 0) unitId = "player";
else if (failGuid == targetGuid) unitId = "target";
else if (failGuid == focusGuid) unitId = "focus";
if (!unitId.empty())
else if (failGuid == petGuid_) unitId = "pet";
if (!unitId.empty()) {
addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId});
addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId});
}
}
if (failGuid == playerGuid || failGuid == 0) {
// Player's own cast failed — clear gather-node loot target so the
@ -3711,6 +3762,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
addUIError("Your party has been disbanded.");
addSystemChatMessage("Your party has been disbanded.");
LOG_INFO("SMSG_GROUP_DESTROYED: party cleared");
if (addonEventCallback_) {
addonEventCallback_("GROUP_ROSTER_UPDATE", {});
addonEventCallback_("PARTY_MEMBERS_CHANGED", {});
}
break;
case Opcode::SMSG_GROUP_CANCEL:
// Group invite was cancelled before being accepted.
@ -3754,6 +3809,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
? "Ready check initiated!"
: readyCheckInitiator_ + " initiated a ready check!");
LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_);
if (addonEventCallback_)
addonEventCallback_("READY_CHECK", {readyCheckInitiator_});
break;
}
case Opcode::MSG_RAID_READY_CHECK_CONFIRM: {
@ -3782,6 +3839,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready");
addSystemChatMessage(rbuf);
}
if (addonEventCallback_) {
char guidBuf[32];
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)respGuid);
addonEventCallback_("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"});
}
break;
}
case Opcode::MSG_RAID_READY_CHECK_FINISHED: {
@ -3794,6 +3856,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
readyCheckReadyCount_ = 0;
readyCheckNotReadyCount_ = 0;
readyCheckResults_.clear();
if (addonEventCallback_)
addonEventCallback_("READY_CHECK_FINISHED", {});
break;
}
case Opcode::SMSG_RAID_INSTANCE_INFO:
@ -4008,6 +4072,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : "";
}
resurrectRequestPending_ = true;
if (addonEventCallback_)
addonEventCallback_("RESURRECT_REQUEST", {resurrectCasterName_});
}
break;
}
@ -4736,6 +4802,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
recentLootMoneyAnnounceCooldowns_[notifyGuid] = 1.5f;
}
}
if (addonEventCallback_) addonEventCallback_("PLAYER_MONEY", {});
}
break;
}
@ -4757,6 +4824,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playDropOnGround();
}
if (addonEventCallback_) {
addonEventCallback_("BAG_UPDATE", {});
addonEventCallback_("PLAYER_MONEY", {});
}
} else {
bool removedPending = false;
auto it = pendingSellToBuyback_.find(itemGuid);
@ -4991,6 +5062,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
}
LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast<int>(rtuType));
if (addonEventCallback_)
addonEventCallback_("RAID_TARGET_UPDATE", {});
break;
}
case Opcode::SMSG_BUY_ITEM: {
@ -5020,6 +5093,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
pendingBuyItemId_ = 0;
pendingBuyItemSlot_ = 0;
if (addonEventCallback_) {
addonEventCallback_("MERCHANT_UPDATE", {});
addonEventCallback_("BAG_UPDATE", {});
}
}
break;
}
@ -5402,6 +5479,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (questProgressCallback_) {
questProgressCallback_(quest.title, creatureName, count, reqCount);
}
if (addonEventCallback_) {
addonEventCallback_("QUEST_WATCH_UPDATE", {std::to_string(questId)});
addonEventCallback_("QUEST_LOG_UPDATE", {});
}
LOG_INFO("Updated kill count for quest ", questId, ": ",
count, "/", reqCount);
@ -5479,6 +5560,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
}
if (addonEventCallback_ && updatedAny) {
addonEventCallback_("QUEST_WATCH_UPDATE", {});
addonEventCallback_("QUEST_LOG_UPDATE", {});
}
LOG_INFO("Quest item update: itemId=", itemId, " count=", count,
" trackedQuestsUpdated=", updatedAny);
}
@ -5542,6 +5627,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
isResting_ = nowResting;
addSystemChatMessage(isResting_ ? "You are now resting."
: "You are no longer resting.");
if (addonEventCallback_)
addonEventCallback_("PLAYER_UPDATE_RESTING", {});
}
break;
}
@ -5708,6 +5795,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (!leaderName.empty())
addSystemChatMessage(leaderName + " is now the group leader.");
LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName);
if (addonEventCallback_) {
addonEventCallback_("PARTY_LEADER_CHANGED", {});
addonEventCallback_("GROUP_ROSTER_UPDATE", {});
}
}
break;
}
@ -6072,6 +6163,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
talentWipePending_ = true;
LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_,
std::dec, " cost=", talentWipeCost_);
if (addonEventCallback_)
addonEventCallback_("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)});
break;
}
@ -6411,6 +6504,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
isResting_ = (restTrigger > 0);
addSystemChatMessage(isResting_ ? "You are now resting."
: "You are no longer resting.");
if (addonEventCallback_)
addonEventCallback_("PLAYER_UPDATE_RESTING", {});
}
break;
}
@ -7398,6 +7493,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (chanCaster == playerGuid) unitId = "player";
else if (chanCaster == targetGuid) unitId = "target";
else if (chanCaster == focusGuid) unitId = "focus";
else if (chanCaster == petGuid_) unitId = "pet";
if (!unitId.empty())
addonEventCallback_("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)});
}
@ -7434,6 +7530,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (chanCaster2 == playerGuid) unitId = "player";
else if (chanCaster2 == targetGuid) unitId = "target";
else if (chanCaster2 == focusGuid) unitId = "focus";
else if (chanCaster2 == petGuid_) unitId = "pet";
if (!unitId.empty())
addonEventCallback_("UNIT_SPELLCAST_CHANNEL_STOP", {unitId});
}
@ -7679,6 +7776,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast<int>(newGroupType),
" memberFlags=0x", std::hex, newMemberFlags, std::dec,
" leaderGuid=", newLeaderGuid);
if (addonEventCallback_) {
addonEventCallback_("PARTY_LEADER_CHANGED", {});
addonEventCallback_("GROUP_ROSTER_UPDATE", {});
}
break;
}
@ -7904,6 +8005,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
const std::string& sname = getSpellName(spellId);
addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + "."));
LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId);
if (addonEventCallback_) addonEventCallback_("PET_BAR_UPDATE", {});
}
packet.setReadPos(packet.getSize());
break;
@ -8023,6 +8125,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ",
std::count_if(items.begin(), items.end(),
[](uint32_t e) { return e != 0; }), "/19 slots");
if (addonEventCallback_) {
char guidBuf[32];
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid);
addonEventCallback_("INSPECT_READY", {guidBuf});
}
break;
}
@ -11091,6 +11198,13 @@ void GameHandler::sendMovement(Opcode opcode) {
}
}
// Track movement state transition for PLAYER_STARTED/STOPPED_MOVING events
const uint32_t kMoveMask = static_cast<uint32_t>(MovementFlags::FORWARD) |
static_cast<uint32_t>(MovementFlags::BACKWARD) |
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
const bool wasMoving = (movementInfo.flags & kMoveMask) != 0;
// Cancel any timed (non-channeled) cast the moment the player starts moving.
// Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server.
// Turning (MSG_MOVE_START_TURN_*) is allowed while casting.
@ -11195,6 +11309,15 @@ void GameHandler::sendMovement(Opcode opcode) {
break;
}
// Fire PLAYER_STARTED/STOPPED_MOVING on movement state transitions
{
const bool isMoving = (movementInfo.flags & kMoveMask) != 0;
if (isMoving && !wasMoving && addonEventCallback_)
addonEventCallback_("PLAYER_STARTED_MOVING", {});
else if (!isMoving && wasMoving && addonEventCallback_)
addonEventCallback_("PLAYER_STOPPED_MOVING", {});
}
if (opcode == Opcode::MSG_MOVE_SET_FACING) {
lastFacingSendTimeMs_ = movementInfo.time;
lastFacingSentOrientation_ = movementInfo.orientation;
@ -13534,6 +13657,7 @@ std::shared_ptr<Entity> GameHandler::getTarget() const {
void GameHandler::setFocus(uint64_t guid) {
focusGuid = guid;
if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {});
if (guid != 0) {
auto entity = entityManager.getEntity(guid);
if (entity) {
@ -13559,6 +13683,14 @@ void GameHandler::clearFocus() {
LOG_INFO("Focus cleared");
}
focusGuid = 0;
if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {});
}
void GameHandler::setMouseoverGuid(uint64_t guid) {
if (mouseoverGuid_ != guid) {
mouseoverGuid_ = guid;
if (addonEventCallback_) addonEventCallback_("UPDATE_MOUSEOVER_UNIT", {});
}
}
std::shared_ptr<Entity> GameHandler::getFocus() const {
@ -14232,6 +14364,7 @@ void GameHandler::handleDuelRequested(network::Packet& packet) {
}
LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_,
" flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_);
if (addonEventCallback_) addonEventCallback_("DUEL_REQUESTED", {duelChallengerName_});
}
void GameHandler::handleDuelComplete(network::Packet& packet) {
@ -14244,6 +14377,7 @@ void GameHandler::handleDuelComplete(network::Packet& packet) {
addSystemChatMessage("The duel was cancelled.");
}
LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast<int>(started));
if (addonEventCallback_) addonEventCallback_("DUEL_FINISHED", {});
}
void GameHandler::handleDuelWinner(network::Packet& packet) {
@ -14782,6 +14916,10 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) {
if (data.isValid()) {
playerNameCache[data.guid] = data.name;
// Cache class/race from name query for UnitClass/UnitRace fallback
if (data.classId != 0 || data.race != 0) {
playerClassRaceCache_[data.guid] = {data.classId, data.race};
}
// Update entity name
auto entity = entityManager.getEntity(data.guid);
if (entity && entity->getType() == ObjectType::PLAYER) {
@ -14808,6 +14946,16 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) {
if (friendGuids_.count(data.guid)) {
friendsCache[data.name] = data.guid;
}
// Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available
if (addonEventCallback_) {
std::string unitId;
if (data.guid == targetGuid) unitId = "target";
else if (data.guid == focusGuid) unitId = "focus";
else if (data.guid == playerGuid) unitId = "player";
if (!unitId.empty())
addonEventCallback_("UNIT_NAME_UPDATE", {unitId});
}
}
}
@ -15183,6 +15331,11 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ",
unspentTalents, " unspent, ", (int)talentGroupCount, " specs");
if (addonEventCallback_) {
char guidBuf[32];
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid);
addonEventCallback_("INSPECT_READY", {guidBuf});
}
}
uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const {
@ -16014,6 +16167,8 @@ void GameHandler::stopAutoAttack() {
socket->send(packet);
}
LOG_INFO("Stopping auto-attack");
if (addonEventCallback_)
addonEventCallback_("PLAYER_LEAVE_COMBAT", {});
}
void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType,
@ -16135,6 +16290,8 @@ void GameHandler::handleAttackStart(network::Packet& packet) {
autoAttacking = true;
autoAttackRetryPending_ = false;
autoAttackTarget = data.victimGuid;
if (addonEventCallback_)
addonEventCallback_("PLAYER_ENTER_COMBAT", {});
} else if (data.victimGuid == playerGuid && data.attackerGuid != 0) {
hostileAttackers_.insert(data.attackerGuid);
autoTargetAttacker(data.attackerGuid);
@ -16693,6 +16850,8 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
LOG_INFO("Battlefield status: unknown (", statusId, ") for ", bgName);
break;
}
if (addonEventCallback_)
addonEventCallback_("UPDATE_BATTLEFIELD_STATUS", {std::to_string(statusId)});
}
void GameHandler::handleBattlefieldList(network::Packet& packet) {
@ -18313,6 +18472,27 @@ void GameHandler::handleMonsterMove(network::Packet& packet) {
creatureMoveCallback_(data.guid,
posCanonical.x, posCanonical.y, posCanonical.z, 0);
}
} else if (data.moveType == 4) {
// FacingAngle without movement — rotate NPC in place
float orientation = core::coords::serverToCanonicalYaw(data.facingAngle);
glm::vec3 posCanonical = core::coords::serverToCanonical(
glm::vec3(data.x, data.y, data.z));
entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, orientation);
if (creatureMoveCallback_) {
creatureMoveCallback_(data.guid,
posCanonical.x, posCanonical.y, posCanonical.z, 0);
}
} else if (data.moveType == 3 && data.facingTarget != 0) {
// FacingTarget without movement — rotate NPC to face a target
auto target = entityManager.getEntity(data.facingTarget);
if (target) {
float dx = target->getX() - entity->getX();
float dy = target->getY() - entity->getY();
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
float orientation = std::atan2(-dy, dx);
entity->setOrientation(orientation);
}
}
}
}
@ -18719,6 +18899,13 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
// Fire UNIT_SPELLCAST_SENT for cast bar addons (fires on client intent, before server confirms)
if (addonEventCallback_) {
std::string targetName;
if (target != 0) targetName = lookupName(target);
addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)});
}
// Optimistically start GCD immediately on cast, but do not restart it while
// already active (prevents timeout animation reset on repeated key presses).
if (!isGCDActive()) {
@ -18747,6 +18934,8 @@ void GameHandler::cancelCast() {
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (addonEventCallback_)
addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"});
}
void GameHandler::startCraftQueue(uint32_t spellId, int count) {
@ -18789,6 +18978,7 @@ void GameHandler::handlePetSpells(network::Packet& packet) {
petAutocastSpells_.clear();
memset(petActionSlots_, 0, sizeof(petActionSlots_));
LOG_INFO("SMSG_PET_SPELLS: pet cleared");
if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"});
return;
}
@ -18798,6 +18988,7 @@ void GameHandler::handlePetSpells(network::Packet& packet) {
petAutocastSpells_.clear();
memset(petActionSlots_, 0, sizeof(petActionSlots_));
LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)");
if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"});
return;
}
@ -18839,6 +19030,10 @@ done:
LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec,
" react=", (int)petReact_, " command=", (int)petCommand_,
" spells=", petSpellList_.size());
if (addonEventCallback_) {
addonEventCallback_("UNIT_PET", {"player"});
addonEventCallback_("PET_BAR_UPDATE", {});
}
}
void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) {
@ -19167,6 +19362,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
if (data.casterUnit == playerGuid) unitId = "player";
else if (data.casterUnit == targetGuid) unitId = "target";
else if (data.casterUnit == focusGuid) unitId = "focus";
else if (data.casterUnit == petGuid_) unitId = "pet";
if (!unitId.empty())
addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)});
}
@ -19246,6 +19442,10 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
spellCastAnimCallback_(playerGuid, false, false);
}
// Fire UNIT_SPELLCAST_STOP — cast bar should disappear
if (addonEventCallback_)
addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)});
// Spell queue: fire the next queued spell now that casting has ended
if (queuedSpellId_ != 0) {
uint32_t nextSpell = queuedSpellId_;
@ -19312,6 +19512,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
if (data.casterUnit == playerGuid) unitId = "player";
else if (data.casterUnit == targetGuid) unitId = "target";
else if (data.casterUnit == focusGuid) unitId = "focus";
else if (data.casterUnit == petGuid_) unitId = "pet";
if (!unitId.empty())
addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)});
}
@ -19482,6 +19683,7 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) {
LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : "");
// Check if this spell corresponds to a talent rank
bool isTalentSpell = false;
for (const auto& [talentId, talent] : talentCache_) {
for (int rank = 0; rank < 5; ++rank) {
if (talent.rankSpells[rank] == spellId) {
@ -19490,9 +19692,15 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) {
learnedTalents_[activeTalentSpec_][talentId] = newRank;
LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank,
" (spell ", spellId, ") in spec ", (int)activeTalentSpec_);
return;
isTalentSpell = true;
if (addonEventCallback_) {
addonEventCallback_("CHARACTER_POINTS_CHANGED", {});
addonEventCallback_("PLAYER_TALENT_UPDATE", {});
}
break;
}
}
if (isTalentSpell) break;
}
// Fire LEARNED_SPELL_IN_TAB / SPELLS_CHANGED for Lua addons
@ -19501,6 +19709,8 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) {
addonEventCallback_("SPELLS_CHANGED", {});
}
if (isTalentSpell) return; // talent spells don't show chat message
// Show chat message for non-talent spells, but only if not already announced by
// SMSG_TRAINER_BUY_SUCCEEDED (which pre-inserts into knownSpells).
if (!alreadyKnown) {
@ -19678,6 +19888,13 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) {
" groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup,
" learned=", learnedTalents_[activeTalentGroup].size());
// Fire talent-related events for addons
if (addonEventCallback_) {
addonEventCallback_("CHARACTER_POINTS_CHANGED", {});
addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {});
addonEventCallback_("PLAYER_TALENT_UPDATE", {});
}
if (!talentsInitialized_) {
talentsInitialized_ = true;
if (unspentTalents > 0) {
@ -19827,6 +20044,8 @@ void GameHandler::handleGroupInvite(network::Packet& packet) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playTargetSelect();
}
if (addonEventCallback_)
addonEventCallback_("PARTY_INVITE_REQUEST", {data.inviterName});
}
void GameHandler::handleGroupDecline(network::Packet& packet) {
@ -20121,6 +20340,40 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) {
LOG_DEBUG("Party member stats for ", member->name,
": HP=", member->curHealth, "/", member->maxHealth,
" Level=", member->level);
// Fire addon events for party/raid member health/power/aura changes
if (addonEventCallback_) {
// Resolve unit ID for this member (party1..4 or raid1..40)
std::string unitId;
if (partyData.groupType == 1) {
// Raid: find 1-based index
for (size_t i = 0; i < partyData.members.size(); ++i) {
if (partyData.members[i].guid == memberGuid) {
unitId = "raid" + std::to_string(i + 1);
break;
}
}
} else {
// Party: find 1-based index excluding self
int found = 0;
for (const auto& m : partyData.members) {
if (m.guid == playerGuid) continue;
++found;
if (m.guid == memberGuid) {
unitId = "party" + std::to_string(found);
break;
}
}
}
if (!unitId.empty()) {
if (updateFlags & (0x0002 | 0x0004)) // CUR_HP or MAX_HP
addonEventCallback_("UNIT_HEALTH", {unitId});
if (updateFlags & (0x0010 | 0x0020)) // CUR_POWER or MAX_POWER
addonEventCallback_("UNIT_POWER", {unitId});
if (updateFlags & 0x0200) // AURAS
addonEventCallback_("UNIT_AURA", {unitId});
}
}
}
// ============================================================
@ -20435,6 +20688,7 @@ void GameHandler::handleGuildRoster(network::Packet& packet) {
guildRoster_ = std::move(data);
hasGuildRoster_ = true;
LOG_INFO("Guild roster received: ", guildRoster_.members.size(), " members");
if (addonEventCallback_) addonEventCallback_("GUILD_ROSTER_UPDATE", {});
}
void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
@ -20460,8 +20714,10 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
guildRankNames_.push_back(data.rankNames[i]);
}
LOG_INFO("Guild name set to: ", guildName_);
if (wasUnknown && !guildName_.empty())
if (wasUnknown && !guildName_.empty()) {
addSystemChatMessage("Guild: <" + guildName_ + ">");
if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {});
}
} else {
LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName);
}
@ -20511,6 +20767,7 @@ void GameHandler::handleGuildEvent(network::Packet& packet) {
guildRankNames_.clear();
guildRoster_ = GuildRosterData{};
hasGuildRoster_ = false;
if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {});
break;
case GuildEvent::SIGNED_ON:
if (data.numStrings >= 1)
@ -20533,6 +20790,28 @@ void GameHandler::handleGuildEvent(network::Packet& packet) {
addLocalChatMessage(chatMsg);
}
// Fire addon events for guild state changes
if (addonEventCallback_) {
switch (data.eventType) {
case GuildEvent::MOTD:
addonEventCallback_("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""});
break;
case GuildEvent::SIGNED_ON:
case GuildEvent::SIGNED_OFF:
case GuildEvent::PROMOTION:
case GuildEvent::DEMOTION:
case GuildEvent::JOINED:
case GuildEvent::LEFT:
case GuildEvent::REMOVED:
case GuildEvent::LEADER_CHANGED:
case GuildEvent::DISBANDED:
addonEventCallback_("GUILD_ROSTER_UPDATE", {});
break;
default:
break;
}
}
// Auto-refresh roster after membership/rank changes
switch (data.eventType) {
case GuildEvent::PROMOTION:
@ -20557,6 +20836,8 @@ void GameHandler::handleGuildInvite(network::Packet& packet) {
pendingGuildInviteGuildName_ = data.guildName;
LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName);
addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + ".");
if (addonEventCallback_)
addonEventCallback_("GUILD_INVITE_REQUEST", {data.inviterName, data.guildName});
}
void GameHandler::handleGuildCommandResult(network::Packet& packet) {
@ -20651,6 +20932,7 @@ void GameHandler::lootItem(uint8_t slotIndex) {
void GameHandler::closeLoot() {
if (!lootWindowOpen) return;
lootWindowOpen = false;
if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {});
masterLootCandidates_.clear();
if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) {
clearTarget();
@ -21101,6 +21383,7 @@ void GameHandler::handleQuestDetails(network::Packet& packet) {
// Delay opening the window slightly to allow item queries to complete
questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
gossipWindowOpen = false;
if (addonEventCallback_) addonEventCallback_("QUEST_DETAIL", {});
}
bool GameHandler::hasQuestInLog(uint32_t questId) const {
@ -21546,6 +21829,7 @@ void GameHandler::handleQuestOfferReward(network::Packet& packet) {
gossipWindowOpen = false;
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
if (addonEventCallback_) addonEventCallback_("QUEST_COMPLETE", {});
// Query item names for reward items
for (const auto& item : data.choiceRewards)
@ -21604,6 +21888,7 @@ void GameHandler::closeQuestOfferReward() {
void GameHandler::closeGossip() {
gossipWindowOpen = false;
if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {});
currentGossip = GossipMessageData{};
}
@ -22112,6 +22397,7 @@ void GameHandler::handleLootResponse(network::Packet& packet) {
return;
}
lootWindowOpen = true;
if (addonEventCallback_) addonEventCallback_("LOOT_OPENED", {});
lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo
pendingGameObjectLootOpens_.erase(
std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(),
@ -22156,6 +22442,7 @@ void GameHandler::handleLootReleaseResponse(network::Packet& packet) {
(void)packet;
localLootState_.erase(currentLoot.lootGuid);
lootWindowOpen = false;
if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {});
currentLoot = LootResponseData{};
}
@ -22178,6 +22465,8 @@ void GameHandler::handleLootRemoved(network::Packet& packet) {
sfx->playLootItem();
}
currentLoot.items.erase(it);
if (addonEventCallback_)
addonEventCallback_("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)});
break;
}
}
@ -22189,6 +22478,7 @@ void GameHandler::handleGossipMessage(network::Packet& packet) {
if (!ok) return;
if (questDetailsOpen) return; // Don't reopen gossip while viewing quest
gossipWindowOpen = true;
if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {});
vendorWindowOpen = false; // Close vendor if gossip opens
// Update known quest-log entries based on gossip quests.
@ -22302,6 +22592,7 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) {
currentGossip = std::move(data);
gossipWindowOpen = true;
if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {});
vendorWindowOpen = false;
bool hasAvailableQuest = false;
@ -22352,6 +22643,7 @@ void GameHandler::handleGossipComplete(network::Packet& packet) {
}
gossipWindowOpen = false;
if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {});
currentGossip = GossipMessageData{};
}
@ -22480,6 +22772,7 @@ void GameHandler::handleTrainerList(network::Packet& packet) {
if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return;
trainerWindowOpen_ = true;
gossipWindowOpen = false;
if (addonEventCallback_) addonEventCallback_("TRAINER_SHOW", {});
LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells");
LOG_DEBUG("Known spells count: ", knownSpells.size());
@ -22537,6 +22830,7 @@ void GameHandler::trainSpell(uint32_t spellId) {
void GameHandler::closeTrainer() {
trainerWindowOpen_ = false;
if (addonEventCallback_) addonEventCallback_("TRAINER_CLOSED", {});
currentTrainerList_ = TrainerListData{};
trainerTabs_.clear();
}
@ -24092,6 +24386,7 @@ void GameHandler::handleFriendList(network::Packet& packet) {
entry.classId = classId;
contacts_.push_back(std::move(entry));
}
if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {});
}
void GameHandler::handleContactList(network::Packet& packet) {
@ -24155,6 +24450,11 @@ void GameHandler::handleContactList(network::Packet& packet) {
}
LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_,
" count=", lastContactListCount_);
if (addonEventCallback_) {
addonEventCallback_("FRIENDLIST_UPDATE", {});
if (lastContactListMask_ & 0x2) // ignore list
addonEventCallback_("IGNORELIST_UPDATE", {});
}
}
void GameHandler::handleFriendStatus(network::Packet& packet) {
@ -24238,6 +24538,7 @@ void GameHandler::handleFriendStatus(network::Packet& packet) {
}
LOG_INFO("Friend status update: ", playerName, " status=", (int)data.status);
if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {});
}
void GameHandler::handleRandomRoll(network::Packet& packet) {
@ -25017,6 +25318,7 @@ void GameHandler::handleMailListResult(network::Packet& packet) {
selectedMailIndex_ = -1;
showMailCompose_ = false;
}
if (addonEventCallback_) addonEventCallback_("MAIL_INBOX_UPDATE", {});
}
void GameHandler::handleSendMailResult(network::Packet& packet) {
@ -25091,6 +25393,7 @@ void GameHandler::handleReceivedMail(network::Packet& packet) {
LOG_INFO("SMSG_RECEIVED_MAIL: New mail arrived!");
hasNewMail_ = true;
addSystemChatMessage("New mail has arrived.");
if (addonEventCallback_) addonEventCallback_("UPDATE_PENDING_MAIL", {});
// If mailbox is open, refresh
if (mailboxOpen_) {
refreshMailList();
@ -25584,6 +25887,8 @@ void GameHandler::handleSummonRequest(network::Packet& packet) {
addSystemChatMessage(msg);
LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_,
" zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s");
if (addonEventCallback_)
addonEventCallback_("CONFIRM_SUMMON", {});
}
void GameHandler::acceptSummon() {
@ -25642,6 +25947,7 @@ void GameHandler::handleTradeStatus(network::Packet& packet) {
}
tradeStatus_ = TradeStatus::PendingIncoming;
addSystemChatMessage(tradePeerName_ + " wants to trade with you.");
if (addonEventCallback_) addonEventCallback_("TRADE_REQUEST", {});
break;
}
case 2: // OPEN_WINDOW
@ -25651,22 +25957,27 @@ void GameHandler::handleTradeStatus(network::Packet& packet) {
peerTradeGold_ = 0;
tradeStatus_ = TradeStatus::Open;
addSystemChatMessage("Trade window opened.");
if (addonEventCallback_) addonEventCallback_("TRADE_SHOW", {});
break;
case 3: // CANCELLED
case 12: // CLOSE_WINDOW
resetTradeState();
addSystemChatMessage("Trade cancelled.");
if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {});
break;
case 9: // REJECTED — other player clicked Decline
resetTradeState();
addSystemChatMessage("Trade declined.");
if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {});
break;
case 4: // ACCEPTED (partner accepted)
tradeStatus_ = TradeStatus::Accepted;
addSystemChatMessage("Trade accepted. Awaiting other player...");
if (addonEventCallback_) addonEventCallback_("TRADE_ACCEPT_UPDATE", {});
break;
case 8: // COMPLETE
addSystemChatMessage("Trade complete!");
if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {});
resetTradeState();
break;
case 7: // BACK_TO_TRADE (unaccepted after a change)
@ -26124,6 +26435,8 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) {
LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec,
" achievementId=", achievementId, " self=", isSelf,
achName.empty() ? "" : " name=", achName);
if (addonEventCallback_)
addonEventCallback_("ACHIEVEMENT_EARNED", {std::to_string(achievementId)});
}
// ---------------------------------------------------------------------------

View file

@ -1579,12 +1579,26 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
// since we don't have the full combo table — dual-UV effects are rare edge cases.
bgpu.textureUnit = 0;
// Batch is hidden only when its named texture failed to load (avoids white shell artifacts).
// Do NOT bake transparency/color animation tracks here — they animate over time and
// baking the first keyframe value causes legitimate meshes to become invisible.
// Keep terrain clutter visible even when source texture paths are malformed.
// Start at full opacity; hide only if texture failed to load.
bgpu.batchOpacity = (texFailed && !groundDetailModel) ? 0.0f : 1.0f;
// Apply at-rest transparency and color alpha from the M2 animation tracks.
// These provide per-batch opacity for ghosts, ethereal effects, fading doodads, etc.
// Skip zero values: some animated tracks start at 0 and animate up, and baking
// that first keyframe would make the entire batch permanently invisible.
if (bgpu.batchOpacity > 0.0f) {
float animAlpha = 1.0f;
if (batch.colorIndex < model.colorAlphas.size()) {
float ca = model.colorAlphas[batch.colorIndex];
if (ca > 0.001f) animAlpha *= ca;
}
if (batch.transparencyIndex < model.textureWeights.size()) {
float tw = model.textureWeights[batch.transparencyIndex];
if (tw > 0.001f) animAlpha *= tw;
}
bgpu.batchOpacity *= animAlpha;
}
// Compute batch center and radius for glow sprite positioning
if ((bgpu.blendMode >= 3 || bgpu.colorKeyBlack) && batch.indexCount > 0) {
glm::vec3 sum(0.0f);

View file

@ -268,6 +268,7 @@ static std::string evaluateMacroConditionals(const std::string& rawArg,
static std::string getMacroShowtooltipArg(const std::string& macroText);
void GameScreen::render(game::GameHandler& gameHandler) {
cachedGameHandler_ = &gameHandler;
// Set up chat bubble callback (once)
if (!chatBubbleCallbackSet_) {
gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) {
@ -2674,6 +2675,107 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, newBuf.c_str());
}
} else if (data->BufTextLen > 0) {
// Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel
// Also works for plain text (completes nearby player names)
std::string fullBuf(data->Buf, data->BufTextLen);
size_t spacePos = fullBuf.find(' ');
bool isNameCommand = false;
std::string namePrefix;
size_t replaceStart = 0;
if (fullBuf[0] == '/' && spacePos != std::string::npos) {
std::string cmd = fullBuf.substr(0, spacePos);
for (char& c : cmd) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
// Commands that take a player name as the first argument after the command
if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" ||
cmd == "/trade" || cmd == "/duel" || cmd == "/follow" ||
cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" ||
cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" ||
cmd == "/t" || cmd == "/target" || cmd == "/kick" ||
cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") {
// Extract the partial name after the space
namePrefix = fullBuf.substr(spacePos + 1);
// Only complete the first word after the command
size_t nameSpace = namePrefix.find(' ');
if (nameSpace == std::string::npos) {
isNameCommand = true;
replaceStart = spacePos + 1;
}
}
}
if (isNameCommand && !namePrefix.empty()) {
std::string lowerPrefix = namePrefix;
for (char& c : lowerPrefix) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerPrefix) {
self->chatTabPrefix_ = lowerPrefix;
self->chatTabMatches_.clear();
// Search player name cache and nearby entities
auto* gh = self->cachedGameHandler_;
// Party/raid members
for (const auto& m : gh->getPartyData().members) {
if (m.name.empty()) continue;
std::string lname = m.name;
for (char& c : lname) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0)
self->chatTabMatches_.push_back(m.name);
}
// Friends
for (const auto& c : gh->getContacts()) {
if (!c.isFriend() || c.name.empty()) continue;
std::string lname = c.name;
for (char& cc : lname) cc = static_cast<char>(std::tolower(static_cast<unsigned char>(cc)));
if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) {
// Avoid duplicates from party
bool dup = false;
for (const auto& em : self->chatTabMatches_)
if (em == c.name) { dup = true; break; }
if (!dup) self->chatTabMatches_.push_back(c.name);
}
}
// Nearby visible players
for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) {
if (!entity || entity->getType() != game::ObjectType::PLAYER) continue;
auto player = std::static_pointer_cast<game::Player>(entity);
if (player->getName().empty()) continue;
std::string lname = player->getName();
for (char& cc : lname) cc = static_cast<char>(std::tolower(static_cast<unsigned char>(cc)));
if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) {
bool dup = false;
for (const auto& em : self->chatTabMatches_)
if (em == player->getName()) { dup = true; break; }
if (!dup) self->chatTabMatches_.push_back(player->getName());
}
}
// Last whisper sender
if (!gh->getLastWhisperSender().empty()) {
std::string lname = gh->getLastWhisperSender();
for (char& cc : lname) cc = static_cast<char>(std::tolower(static_cast<unsigned char>(cc)));
if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) {
bool dup = false;
for (const auto& em : self->chatTabMatches_)
if (em == gh->getLastWhisperSender()) { dup = true; break; }
if (!dup) self->chatTabMatches_.insert(self->chatTabMatches_.begin(), gh->getLastWhisperSender());
}
}
self->chatTabMatchIdx_ = 0;
} else {
++self->chatTabMatchIdx_;
if (self->chatTabMatchIdx_ >= static_cast<int>(self->chatTabMatches_.size()))
self->chatTabMatchIdx_ = 0;
}
if (!self->chatTabMatches_.empty()) {
std::string match = self->chatTabMatches_[self->chatTabMatchIdx_];
std::string prefix = fullBuf.substr(0, replaceStart);
std::string newBuf = prefix + match;
if (self->chatTabMatches_.size() == 1) newBuf += ' ';
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, newBuf.c_str());
}
}
}
return 0;
}
@ -2787,6 +2889,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
gameHandler.closeBank();
} else if (gameHandler.isTrainerWindowOpen()) {
gameHandler.closeTrainer();
} else if (gameHandler.isMailboxOpen()) {
gameHandler.closeMailbox();
} else if (gameHandler.isAuctionHouseOpen()) {
gameHandler.closeAuctionHouse();
} else if (gameHandler.isQuestDetailsOpen()) {
gameHandler.declineQuest();
} else if (gameHandler.isQuestOfferRewardOpen()) {
gameHandler.closeQuestOfferReward();
} else if (gameHandler.isQuestRequestItemsOpen()) {
gameHandler.closeQuestRequestItems();
} else if (gameHandler.isTradeOpen()) {
gameHandler.cancelTrade();
} else if (showWhoWindow_) {
showWhoWindow_ = false;
} else if (showCombatLog_) {