Kelsidavis-WoWee/src/game/spell_handler.cpp
Paul 5af9f7aa4b chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.

AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
  emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()

Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
  directly, grouped by access pattern:
  - UIServices: settings_panel, game_screen, toast_manager, chat_panel,
    combat_ui, window_manager
  - GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
    social_handler, combat_handler
  - Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
  MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00

3248 lines
140 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "game/spell_handler.hpp"
#include "game/game_handler.hpp"
#include "game/game_utils.hpp"
#include "game/packet_parsers.hpp"
#include "game/entity.hpp"
#include "rendering/renderer.hpp"
#include "audio/audio_coordinator.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/combat_sound_manager.hpp"
#include "core/application.hpp"
#include "core/coordinates.hpp"
#include "core/logger.hpp"
#include "network/world_socket.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_layout.hpp"
#include "audio/ui_sound_manager.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <sstream>
namespace wowee {
namespace game {
// Merge incoming cooldown with local remaining time — keeps local timer when
// a stale/duplicate packet arrives after local countdown has progressed.
static float mergeCooldownSeconds(float current, float incoming) {
constexpr float kEpsilon = 0.05f;
if (incoming <= 0.0f) return 0.0f;
if (current <= 0.0f) return incoming;
if (incoming > current + kEpsilon) return current;
return incoming;
}
static CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) {
switch (missInfo) {
case 0: return CombatTextEntry::MISS;
case 1: return CombatTextEntry::DODGE;
case 2: return CombatTextEntry::PARRY;
case 3: return CombatTextEntry::BLOCK;
case 4: return CombatTextEntry::EVADE;
case 5: return CombatTextEntry::IMMUNE;
case 6: return CombatTextEntry::DEFLECT;
case 7: return CombatTextEntry::ABSORB;
case 8: return CombatTextEntry::RESIST;
case 9:
case 10:
return CombatTextEntry::IMMUNE;
case 11: return CombatTextEntry::REFLECT;
default: return CombatTextEntry::MISS;
}
}
static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) {
if (mask & 0x04) return audio::SpellSoundManager::MagicSchool::FIRE;
if (mask & 0x10) return audio::SpellSoundManager::MagicSchool::FROST;
if (mask & 0x02) return audio::SpellSoundManager::MagicSchool::HOLY;
if (mask & 0x08) return audio::SpellSoundManager::MagicSchool::NATURE;
if (mask & 0x20) return audio::SpellSoundManager::MagicSchool::SHADOW;
if (mask & 0x40) return audio::SpellSoundManager::MagicSchool::ARCANE;
return audio::SpellSoundManager::MagicSchool::ARCANE;
}
static std::string displaySpellName(GameHandler& handler, uint32_t spellId) {
if (spellId == 0) return {};
const std::string& name = handler.getSpellName(spellId);
if (!name.empty()) return name;
return "spell " + std::to_string(spellId);
}
static std::string formatSpellNameList(GameHandler& handler,
const std::vector<uint32_t>& spellIds,
size_t maxShown = 3) {
if (spellIds.empty()) return {};
const size_t shownCount = std::min(spellIds.size(), maxShown);
std::ostringstream oss;
for (size_t i = 0; i < shownCount; ++i) {
if (i > 0) {
if (shownCount == 2) {
oss << " and ";
} else if (i == shownCount - 1) {
oss << ", and ";
} else {
oss << ", ";
}
}
oss << displaySpellName(handler, spellIds[i]);
}
if (spellIds.size() > shownCount) {
oss << ", and " << (spellIds.size() - shownCount) << " more";
}
return oss.str();
}
SpellHandler::SpellHandler(GameHandler& owner)
: owner_(owner) {}
void SpellHandler::registerOpcodes(DispatchTable& table) {
table[Opcode::SMSG_INITIAL_SPELLS] = [this](network::Packet& packet) { handleInitialSpells(packet); };
table[Opcode::SMSG_CAST_FAILED] = [this](network::Packet& packet) { handleCastFailed(packet); };
table[Opcode::SMSG_SPELL_START] = [this](network::Packet& packet) { handleSpellStart(packet); };
table[Opcode::SMSG_SPELL_GO] = [this](network::Packet& packet) { handleSpellGo(packet); };
table[Opcode::SMSG_SPELL_COOLDOWN] = [this](network::Packet& packet) { handleSpellCooldown(packet); };
table[Opcode::SMSG_COOLDOWN_EVENT] = [this](network::Packet& packet) { handleCooldownEvent(packet); };
table[Opcode::SMSG_AURA_UPDATE] = [this](network::Packet& packet) {
handleAuraUpdate(packet, false);
};
table[Opcode::SMSG_AURA_UPDATE_ALL] = [this](network::Packet& packet) {
handleAuraUpdate(packet, true);
};
table[Opcode::SMSG_LEARNED_SPELL] = [this](network::Packet& packet) { handleLearnedSpell(packet); };
table[Opcode::SMSG_SUPERCEDED_SPELL] = [this](network::Packet& packet) { handleSupercededSpell(packet); };
table[Opcode::SMSG_REMOVED_SPELL] = [this](network::Packet& packet) { handleRemovedSpell(packet); };
table[Opcode::SMSG_SEND_UNLEARN_SPELLS] = [this](network::Packet& packet) { handleUnlearnSpells(packet); };
table[Opcode::SMSG_TALENTS_INFO] = [this](network::Packet& packet) { handleTalentsInfo(packet); };
table[Opcode::SMSG_ACHIEVEMENT_EARNED] = [this](network::Packet& packet) {
handleAchievementEarned(packet);
};
// SMSG_EQUIPMENT_SET_LIST — owned by InventoryHandler::registerOpcodes
// ---- Cast result / spell visuals / cooldowns / modifiers ----
table[Opcode::SMSG_CAST_RESULT] = [this](network::Packet& p) { handleCastResult(p); };
table[Opcode::SMSG_SPELL_FAILED_OTHER] = [this](network::Packet& p) { handleSpellFailedOther(p); };
table[Opcode::SMSG_CLEAR_COOLDOWN] = [this](network::Packet& p) { handleClearCooldown(p); };
table[Opcode::SMSG_MODIFY_COOLDOWN] = [this](network::Packet& p) { handleModifyCooldown(p); };
table[Opcode::SMSG_PLAY_SPELL_VISUAL] = [this](network::Packet& p) { handlePlaySpellVisual(p); };
table[Opcode::SMSG_SET_FLAT_SPELL_MODIFIER] = [this](network::Packet& p) { handleSpellModifier(p, true); };
table[Opcode::SMSG_SET_PCT_SPELL_MODIFIER] = [this](network::Packet& p) { handleSpellModifier(p, false); };
table[Opcode::SMSG_SPELL_DELAYED] = [this](network::Packet& p) { handleSpellDelayed(p); };
// ---- Spell log / aura / dispel / totem / channel handlers ----
table[Opcode::SMSG_SPELLLOGMISS] = [this](network::Packet& p) { handleSpellLogMiss(p); };
table[Opcode::SMSG_SPELL_FAILURE] = [this](network::Packet& p) { handleSpellFailure(p); };
table[Opcode::SMSG_ITEM_COOLDOWN] = [this](network::Packet& p) { handleItemCooldown(p); };
table[Opcode::SMSG_DISPEL_FAILED] = [this](network::Packet& p) { handleDispelFailed(p); };
table[Opcode::SMSG_TOTEM_CREATED] = [this](network::Packet& p) { handleTotemCreated(p); };
table[Opcode::SMSG_PERIODICAURALOG] = [this](network::Packet& p) { handlePeriodicAuraLog(p); };
table[Opcode::SMSG_SPELLENERGIZELOG] = [this](network::Packet& p) { handleSpellEnergizeLog(p); };
table[Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE] = [this](network::Packet& p) { handleExtraAuraInfo(p, true); };
table[Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE] = [this](network::Packet& p) { handleExtraAuraInfo(p, false); };
table[Opcode::SMSG_SPELLDISPELLOG] = [this](network::Packet& p) { handleSpellDispelLog(p); };
table[Opcode::SMSG_SPELLSTEALLOG] = [this](network::Packet& p) { handleSpellStealLog(p); };
table[Opcode::SMSG_SPELL_CHANCE_PROC_LOG] = [this](network::Packet& p) { handleSpellChanceProcLog(p); };
table[Opcode::SMSG_SPELLINSTAKILLLOG] = [this](network::Packet& p) { handleSpellInstaKillLog(p); };
table[Opcode::SMSG_SPELLLOGEXECUTE] = [this](network::Packet& p) { handleSpellLogExecute(p); };
table[Opcode::SMSG_CLEAR_EXTRA_AURA_INFO] = [this](network::Packet& p) { handleClearExtraAuraInfo(p); };
table[Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE] = [this](network::Packet& p) { handleItemEnchantTimeUpdate(p); };
table[Opcode::SMSG_RESUME_CAST_BAR] = [this](network::Packet& p) { handleResumeCastBar(p); };
table[Opcode::MSG_CHANNEL_START] = [this](network::Packet& p) { handleChannelStart(p); };
table[Opcode::MSG_CHANNEL_UPDATE] = [this](network::Packet& p) { handleChannelUpdate(p); };
}
// ============================================================
// Public API
// ============================================================
bool SpellHandler::isGameObjectInteractionCasting() const {
return casting_ && currentCastSpellId_ == 0 && owner_.pendingGameObjectInteractGuid_ != 0;
}
bool SpellHandler::isTargetCasting() const {
return getUnitCastState(owner_.targetGuid) != nullptr;
}
uint32_t SpellHandler::getTargetCastSpellId() const {
auto* s = getUnitCastState(owner_.targetGuid);
return s ? s->spellId : 0;
}
float SpellHandler::getTargetCastProgress() const {
auto* s = getUnitCastState(owner_.targetGuid);
return (s && s->timeTotal > 0.0f)
? (s->timeTotal - s->timeRemaining) / s->timeTotal : 0.0f;
}
float SpellHandler::getTargetCastTimeRemaining() const {
auto* s = getUnitCastState(owner_.targetGuid);
return s ? s->timeRemaining : 0.0f;
}
bool SpellHandler::isTargetCastInterruptible() const {
auto* s = getUnitCastState(owner_.targetGuid);
return s ? s->interruptible : true;
}
void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
LOG_DEBUG("castSpell: spellId=", spellId, " target=0x", std::hex, targetGuid, std::dec);
// Attack (6603) routes to auto-attack instead of cast
if (spellId == 6603) {
uint64_t target = targetGuid != 0 ? targetGuid : owner_.targetGuid;
if (target != 0) {
if (owner_.isAutoAttacking()) {
owner_.stopAutoAttack();
} else {
owner_.startAutoAttack(target);
}
}
return;
}
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
// Casting any spell while mounted → dismount instead
if (owner_.isMounted()) {
owner_.dismount();
return;
}
if (casting_) {
// Spell queue: if we're within 400ms of the cast completing (and not channeling),
// store the spell so it fires automatically when the cast finishes.
if (!castIsChannel_ && castTimeRemaining_ > 0.0f && castTimeRemaining_ <= 0.4f) {
queuedSpellId_ = spellId;
queuedSpellTarget_ = targetGuid != 0 ? targetGuid : owner_.targetGuid;
LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining_ * 1000.0f,
"ms remaining)");
}
return;
}
uint64_t target = targetGuid != 0 ? targetGuid : owner_.targetGuid;
// Self-targeted spells like hearthstone should not send a target
if (spellId == 8690) target = 0;
// Track whether a spell-specific block already handled facing so the generic
// facing block below doesn't send redundant SET_FACING packets.
bool facingHandled = false;
// Warrior Charge (ranks 1-3): client-side range check + charge callback
if (spellId == 100 || spellId == 6178 || spellId == 11578) {
if (target == 0) {
owner_.addSystemChatMessage("You have no target.");
return;
}
auto entity = owner_.getEntityManager().getEntity(target);
if (!entity) {
owner_.addSystemChatMessage("You have no target.");
return;
}
float tx = entity->getX(), ty = entity->getY(), tz = entity->getZ();
float dx = tx - owner_.movementInfo.x;
float dy = ty - owner_.movementInfo.y;
float dz = tz - owner_.movementInfo.z;
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist < 8.0f) {
owner_.addSystemChatMessage("Target is too close.");
return;
}
if (dist > 25.0f) {
owner_.addSystemChatMessage("Out of range.");
return;
}
float yaw = std::atan2(-dy, dx);
owner_.movementInfo.orientation = yaw;
owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING);
if (owner_.chargeCallback_) {
owner_.chargeCallback_(target, tx, ty, tz);
}
facingHandled = true;
}
// Instant melee abilities: client-side range + facing check
if (!facingHandled) {
owner_.loadSpellNameCache();
auto cacheIt = owner_.spellNameCache_.find(spellId);
bool isMeleeAbility = (cacheIt != owner_.spellNameCache_.end() && cacheIt->second.schoolMask == 1);
if (isMeleeAbility && target != 0) {
auto entity = owner_.getEntityManager().getEntity(target);
if (entity) {
float dx = entity->getX() - owner_.movementInfo.x;
float dy = entity->getY() - owner_.movementInfo.y;
float dz = entity->getZ() - owner_.movementInfo.z;
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist > 8.0f) {
owner_.addSystemChatMessage("Out of range.");
return;
}
float yaw = std::atan2(-dy, dx);
owner_.movementInfo.orientation = yaw;
owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING);
facingHandled = true;
}
}
}
// Face the target before casting any targeted spell (server checks facing arc).
// Only send if a spell-specific block above didn't already handle facing,
// to avoid redundant SET_FACING packets that waste bandwidth.
if (!facingHandled && target != 0) {
auto entity = owner_.getEntityManager().getEntity(target);
if (entity) {
float dx = entity->getX() - owner_.movementInfo.x;
float dy = entity->getY() - owner_.movementInfo.y;
float lenSq = dx * dx + dy * dy;
if (lenSq > 0.01f) {
float canonYaw = std::atan2(-dy, dx);
owner_.movementInfo.orientation = canonYaw;
owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING);
}
}
}
// Heartbeat ensures the server has the updated orientation before the cast packet.
if (target != 0) {
owner_.sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildCastSpell(spellId, target, ++castCount_)
: CastSpellPacket::build(spellId, target, ++castCount_);
LOG_DEBUG("CMSG_CAST_SPELL: spellId=", spellId, " target=0x", std::hex, target, std::dec,
" castCount=", static_cast<int>(castCount_), " packetSize=", packet.getSize());
owner_.socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
// Fire UNIT_SPELLCAST_SENT for cast bar addons
if (owner_.addonEventCallback_) {
std::string targetName;
if (target != 0) targetName = owner_.lookupName(target);
owner_.addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)});
}
// Optimistically start GCD immediately on cast
if (!isGCDActive()) {
gcdTotal_ = 1.5f;
gcdStartedAt_ = std::chrono::steady_clock::now();
}
}
void SpellHandler::cancelCast() {
if (!casting_) return;
// GameObject interaction cast is client-side timing only.
if (owner_.pendingGameObjectInteractGuid_ == 0 &&
owner_.state == WorldState::IN_WORLD && owner_.socket &&
currentCastSpellId_ != 0) {
auto packet = CancelCastPacket::build(currentCastSpellId_);
owner_.socket->send(packet);
}
owner_.pendingGameObjectInteractGuid_ = 0;
owner_.lastInteractedGoGuid_ = 0;
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (owner_.addonEventCallback_)
owner_.addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"});
}
void SpellHandler::startCraftQueue(uint32_t spellId, int count) {
craftQueueSpellId_ = spellId;
craftQueueRemaining_ = count;
castSpell(spellId, 0);
}
void SpellHandler::cancelCraftQueue() {
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
}
void SpellHandler::cancelAura(uint32_t spellId) {
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
auto packet = CancelAuraPacket::build(spellId);
owner_.socket->send(packet);
}
float SpellHandler::getSpellCooldown(uint32_t spellId) const {
auto it = spellCooldowns_.find(spellId);
return (it != spellCooldowns_.end()) ? it->second : 0.0f;
}
void SpellHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) {
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) {
LOG_WARNING("learnTalent: Not in world or no socket connection");
return;
}
LOG_INFO("Requesting to learn talent: id=", talentId, " rank=", requestedRank);
auto packet = LearnTalentPacket::build(talentId, requestedRank);
owner_.socket->send(packet);
}
void SpellHandler::switchTalentSpec(uint8_t newSpec) {
if (newSpec > 1) {
LOG_WARNING("Invalid talent spec: ", (int)newSpec);
return;
}
if (newSpec == activeTalentSpec_) {
LOG_INFO("Already on spec ", (int)newSpec);
return;
}
if (owner_.state == WorldState::IN_WORLD && owner_.socket) {
auto pkt = ActivateTalentGroupPacket::build(static_cast<uint32_t>(newSpec));
owner_.socket->send(pkt);
LOG_INFO("Sent CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE: group=", (int)newSpec);
}
activeTalentSpec_ = newSpec;
LOG_INFO("Switched to talent spec ", (int)newSpec,
" (unspent=", (int)unspentTalentPoints_[newSpec],
", learned=", learnedTalents_[newSpec].size(), ")");
std::string msg = "Switched to spec " + std::to_string(newSpec + 1);
if (unspentTalentPoints_[newSpec] > 0) {
msg += " (" + std::to_string(unspentTalentPoints_[newSpec]) + " unspent point";
if (unspentTalentPoints_[newSpec] > 1) msg += "s";
msg += ")";
}
owner_.addSystemChatMessage(msg);
}
void SpellHandler::confirmTalentWipe() {
if (!talentWipePending_) return;
talentWipePending_ = false;
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM));
pkt.writeUInt64(talentWipeNpcGuid_);
owner_.socket->send(pkt);
LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec);
owner_.addSystemChatMessage("Talent reset confirmed. The server will update your talents.");
talentWipeNpcGuid_ = 0;
talentWipeCost_ = 0;
}
void SpellHandler::confirmPetUnlearn() {
if (!petUnlearnPending_) return;
petUnlearnPending_ = false;
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS));
owner_.socket->send(pkt);
LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS");
owner_.addSystemChatMessage("Pet talent reset confirmed.");
petUnlearnGuid_ = 0;
petUnlearnCost_ = 0;
}
uint32_t SpellHandler::findOnUseSpellId(uint32_t itemId) const {
if (auto* info = owner_.getItemInfo(itemId)) {
for (const auto& sp : info->spells) {
// spellTrigger 0 = "Use", 5 = "No Delay" — both are player-activated on-use effects
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
return sp.spellId;
}
}
}
return 0;
}
void SpellHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return;
const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return;
uint64_t itemGuid = owner_.backpackSlotGuids_[backpackIndex];
if (itemGuid == 0) {
itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) {
uint32_t useSpellId = findOnUseSpellId(slot.item.itemId);
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId);
owner_.socket->send(packet);
} else if (itemGuid == 0) {
owner_.addSystemChatMessage("Cannot use that item right now.");
}
}
void SpellHandler::useItemInBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return;
if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return;
const auto& slot = owner_.inventory.getBagSlot(bagIndex, slotIndex);
if (slot.empty()) return;
uint64_t itemGuid = 0;
uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex];
if (bagGuid != 0) {
auto it = owner_.containerContents_.find(bagGuid);
if (it != owner_.containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
itemGuid = it->second.slotGuids[slotIndex];
}
}
if (itemGuid == 0) {
itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId);
}
LOG_INFO("useItemInBag: bag=", bagIndex, " slot=", slotIndex, " itemId=", slot.item.itemId,
" itemGuid=0x", std::hex, itemGuid, std::dec);
if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) {
uint32_t useSpellId = findOnUseSpellId(slot.item.itemId);
uint8_t wowBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex);
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex,
" packetSize=", packet.getSize());
owner_.socket->send(packet);
} else if (itemGuid == 0) {
LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex);
owner_.addSystemChatMessage("Cannot use that item right now.");
}
}
void SpellHandler::useItemById(uint32_t itemId) {
if (itemId == 0) return;
LOG_DEBUG("useItemById: searching for itemId=", itemId);
for (int i = 0; i < owner_.inventory.getBackpackSize(); i++) {
const auto& slot = owner_.inventory.getBackpackSlot(i);
if (!slot.empty() && slot.item.itemId == itemId) {
LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i);
useItemBySlot(i);
return;
}
}
for (int bag = 0; bag < owner_.inventory.NUM_BAG_SLOTS; bag++) {
int bagSize = owner_.inventory.getBagSize(bag);
for (int slot = 0; slot < bagSize; slot++) {
const auto& bagSlot = owner_.inventory.getBagSlot(bag, slot);
if (!bagSlot.empty() && bagSlot.item.itemId == itemId) {
LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
useItemInBag(bag, slot);
return;
}
}
}
LOG_WARNING("useItemById: itemId=", itemId, " not found in inventory");
}
const std::vector<SpellHandler::SpellBookTab>& SpellHandler::getSpellBookTabs() {
// Must be an instance member, not static — a static is shared across all
// SpellHandler instances, so switching characters with the same spell count
// would skip the rebuild and return the previous character's tabs.
if (lastSpellCount_ == knownSpells_.size() && !spellBookTabsDirty_)
return spellBookTabs_;
lastSpellCount_ = knownSpells_.size();
spellBookTabsDirty_ = false;
spellBookTabs_.clear();
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
std::map<uint32_t, std::vector<uint32_t>> bySkillLine;
std::vector<uint32_t> general;
for (uint32_t spellId : knownSpells_) {
auto slIt = owner_.spellToSkillLine_.find(spellId);
if (slIt != owner_.spellToSkillLine_.end()) {
uint32_t skillLineId = slIt->second;
auto catIt = owner_.skillLineCategories_.find(skillLineId);
if (catIt != owner_.skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
bySkillLine[skillLineId].push_back(spellId);
continue;
}
}
general.push_back(spellId);
}
auto byName = [this](uint32_t a, uint32_t b) {
return owner_.getSpellName(a) < owner_.getSpellName(b);
};
if (!general.empty()) {
std::sort(general.begin(), general.end(), byName);
spellBookTabs_.push_back({"General", "Interface\\Icons\\INV_Misc_Book_09", std::move(general)});
}
std::vector<std::pair<std::string, std::vector<uint32_t>>> named;
for (auto& [skillLineId, spells] : bySkillLine) {
auto nameIt = owner_.skillLineNames_.find(skillLineId);
std::string tabName = (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : "Unknown";
std::sort(spells.begin(), spells.end(), byName);
named.emplace_back(std::move(tabName), std::move(spells));
}
std::sort(named.begin(), named.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& [name, spells] : named) {
spellBookTabs_.push_back({std::move(name), "Interface\\Icons\\INV_Misc_Book_09", std::move(spells)});
}
return spellBookTabs_;
}
void SpellHandler::loadTalentDbc() {
if (talentDbcLoaded_) return;
talentDbcLoaded_ = true;
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
// Load Talent.dbc
auto talentDbc = am->loadDBC("Talent.dbc");
if (talentDbc && talentDbc->isLoaded()) {
const auto* talL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Talent") : nullptr;
const uint32_t tID = talL ? (*talL)["ID"] : 0;
const uint32_t tTabID = talL ? (*talL)["TabID"] : 1;
const uint32_t tRow = talL ? (*talL)["Row"] : 2;
const uint32_t tCol = talL ? (*talL)["Column"] : 3;
const uint32_t tRank0 = talL ? (*talL)["RankSpell0"] : 4;
const uint32_t tPrereq0 = talL ? (*talL)["PrereqTalent0"] : 9;
const uint32_t tPrereqR0 = talL ? (*talL)["PrereqRank0"] : 12;
uint32_t count = talentDbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
TalentEntry entry;
entry.talentId = talentDbc->getUInt32(i, tID);
if (entry.talentId == 0) continue;
entry.tabId = talentDbc->getUInt32(i, tTabID);
entry.row = static_cast<uint8_t>(talentDbc->getUInt32(i, tRow));
entry.column = static_cast<uint8_t>(talentDbc->getUInt32(i, tCol));
for (int r = 0; r < 5; ++r) {
entry.rankSpells[r] = talentDbc->getUInt32(i, tRank0 + r);
}
for (int p = 0; p < 3; ++p) {
entry.prereqTalent[p] = talentDbc->getUInt32(i, tPrereq0 + p);
entry.prereqRank[p] = static_cast<uint8_t>(talentDbc->getUInt32(i, tPrereqR0 + p));
}
entry.maxRank = 0;
for (int r = 0; r < 5; ++r) {
if (entry.rankSpells[r] != 0) {
entry.maxRank = r + 1;
}
}
talentCache_[entry.talentId] = entry;
}
LOG_INFO("Loaded ", talentCache_.size(), " talents from Talent.dbc");
} else {
LOG_WARNING("Could not load Talent.dbc");
}
// Load TalentTab.dbc
auto tabDbc = am->loadDBC("TalentTab.dbc");
if (tabDbc && tabDbc->isLoaded()) {
const auto* ttL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TalentTab") : nullptr;
// Cache field indices before the loop
const uint32_t ttIdField = ttL ? (*ttL)["ID"] : 0;
const uint32_t ttNameField = ttL ? (*ttL)["Name"] : 1;
const uint32_t ttClassField = ttL ? (*ttL)["ClassMask"] : 20;
const uint32_t ttOrderField = ttL ? (*ttL)["OrderIndex"] : 22;
const uint32_t ttBgField = ttL ? (*ttL)["BackgroundFile"] : 23;
uint32_t count = tabDbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
TalentTabEntry entry;
entry.tabId = tabDbc->getUInt32(i, ttIdField);
if (entry.tabId == 0) continue;
entry.name = tabDbc->getString(i, ttNameField);
entry.classMask = tabDbc->getUInt32(i, ttClassField);
entry.orderIndex = static_cast<uint8_t>(tabDbc->getUInt32(i, ttOrderField));
entry.backgroundFile = tabDbc->getString(i, ttBgField);
talentTabCache_[entry.tabId] = entry;
if (talentTabCache_.size() <= 10) {
LOG_INFO(" Tab ", entry.tabId, ": ", entry.name, " (classMask=0x", std::hex, entry.classMask, std::dec, ")");
}
}
LOG_INFO("Loaded ", talentTabCache_.size(), " talent tabs from TalentTab.dbc");
} else {
LOG_WARNING("Could not load TalentTab.dbc");
}
}
void SpellHandler::updateTimers(float dt) {
// Tick down cast bar
if (casting_ && castTimeRemaining_ > 0.0f) {
castTimeRemaining_ -= dt;
if (castTimeRemaining_ < 0.0f) castTimeRemaining_ = 0.0f;
}
// Tick down spell cooldowns
for (auto it = spellCooldowns_.begin(); it != spellCooldowns_.end(); ) {
it->second -= dt;
if (it->second <= 0.0f) {
it = spellCooldowns_.erase(it);
} else {
++it;
}
}
// Tick down unit cast states
for (auto it = unitCastStates_.begin(); it != unitCastStates_.end(); ) {
if (it->second.casting && it->second.timeRemaining > 0.0f) {
it->second.timeRemaining -= dt;
if (it->second.timeRemaining <= 0.0f) {
it->second.timeRemaining = 0.0f;
it->second.casting = false;
it = unitCastStates_.erase(it);
continue;
}
}
++it;
}
}
// ============================================================
// Packet handlers
// ============================================================
void SpellHandler::handleInitialSpells(network::Packet& packet) {
InitialSpellsData data;
if (!owner_.packetParsers_->parseInitialSpells(packet, data)) return;
knownSpells_ = {data.spellIds.begin(), data.spellIds.end()};
LOG_DEBUG("Initial spells include: 527=", knownSpells_.count(527u),
" 988=", knownSpells_.count(988u), " 1180=", knownSpells_.count(1180u));
// Ensure Attack (6603) and Hearthstone (8690) are always present
knownSpells_.insert(6603u);
knownSpells_.insert(8690u);
// Set initial cooldowns
for (const auto& cd : data.cooldowns) {
uint32_t effectiveMs = std::max(cd.cooldownMs, cd.categoryCooldownMs);
if (effectiveMs > 0) {
spellCooldowns_[cd.spellId] = effectiveMs / 1000.0f;
}
}
// Load saved action bar or use defaults
owner_.actionBar[0].type = ActionBarSlot::SPELL;
owner_.actionBar[0].id = 6603; // Attack
owner_.actionBar[11].type = ActionBarSlot::SPELL;
owner_.actionBar[11].id = 8690; // Hearthstone
owner_.loadCharacterConfig();
// Sync login-time cooldowns into action bar slot overlays
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id != 0) {
auto it = spellCooldowns_.find(slot.id);
if (it != spellCooldowns_.end() && it->second > 0.0f) {
slot.cooldownTotal = it->second;
slot.cooldownRemaining = it->second;
}
} else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) {
const auto* qi = owner_.getItemInfo(slot.id);
if (qi && qi->valid) {
for (const auto& sp : qi->spells) {
if (sp.spellId == 0) continue;
auto it = spellCooldowns_.find(sp.spellId);
if (it != spellCooldowns_.end() && it->second > 0.0f) {
slot.cooldownTotal = it->second;
slot.cooldownRemaining = it->second;
break;
}
}
}
}
}
// Pre-load skill line DBCs
owner_.loadSkillLineDbc();
owner_.loadSkillLineAbilityDbc();
LOG_INFO("Learned ", knownSpells_.size(), " spells");
if (owner_.addonEventCallback_) {
owner_.addonEventCallback_("SPELLS_CHANGED", {});
owner_.addonEventCallback_("LEARNED_SPELL_IN_TAB", {});
}
}
void SpellHandler::handleCastFailed(network::Packet& packet) {
CastFailedData data;
bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseCastFailed(packet, data)
: CastFailedParser::parse(packet, data);
if (!ok) return;
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
owner_.lastInteractedGoGuid_ = 0;
owner_.pendingGameObjectInteractGuid_ = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Stop precast sound
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
ssm->stopPrecast();
}
}
// Show failure reason
int powerType = -1;
auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid);
if (auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity)) {
powerType = playerUnit->getPowerType();
}
const char* reason = getSpellCastResultString(data.result, powerType);
std::string errMsg = reason ? reason
: ("Spell cast failed (error " + std::to_string(data.result) + ")");
owner_.addUIError(errMsg);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = errMsg;
owner_.addLocalChatMessage(msg);
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* sfx = ac->getUiSoundManager())
sfx->playError();
}
if (owner_.addonEventCallback_) {
owner_.addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(data.spellId)});
owner_.addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)});
}
if (owner_.spellCastFailedCallback_) owner_.spellCastFailedCallback_(data.spellId);
}
void SpellHandler::handleSpellStart(network::Packet& packet) {
SpellStartData data;
if (!owner_.packetParsers_->parseSpellStart(packet, data)) {
LOG_WARNING("Failed to parse SMSG_SPELL_START, size=", packet.getSize());
return;
}
LOG_DEBUG("SMSG_SPELL_START: caster=0x", std::hex, data.casterUnit, std::dec,
" spell=", data.spellId, " castTime=", data.castTime);
// Track cast bar for any non-player caster
if (data.casterUnit != owner_.playerGuid && data.castTime > 0) {
auto& s = unitCastStates_[data.casterUnit];
s.casting = true;
s.isChannel = false;
s.spellId = data.spellId;
s.timeTotal = data.castTime / 1000.0f;
s.timeRemaining = s.timeTotal;
s.interruptible = owner_.isSpellInterruptible(data.spellId);
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(data.casterUnit, true, false);
}
}
// Player's own cast
if (data.casterUnit == owner_.playerGuid && data.castTime > 0) {
// Cancel pending GO retries
owner_.pendingGameObjectLootRetries_.erase(
std::remove_if(owner_.pendingGameObjectLootRetries_.begin(), owner_.pendingGameObjectLootRetries_.end(),
[](const GameHandler::PendingLootRetry&) { return true; }),
owner_.pendingGameObjectLootRetries_.end());
casting_ = true;
castIsChannel_ = false;
currentCastSpellId_ = data.spellId;
castTimeTotal_ = data.castTime / 1000.0f;
castTimeRemaining_ = castTimeTotal_;
if (owner_.addonEventCallback_) owner_.addonEventCallback_("CURRENT_SPELL_CAST_CHANGED", {});
// Play precast sound — skip profession/tradeskill spells
if (!owner_.isProfessionSpell(data.spellId)) {
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM);
}
}
}
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(owner_.playerGuid, true, false);
}
// Hearthstone: pre-load terrain at bind point
const bool isHearthstone = (data.spellId == 6948 || data.spellId == 8690);
if (isHearthstone && owner_.hasHomeBind_ && owner_.hearthstonePreloadCallback_) {
owner_.hearthstonePreloadCallback_(owner_.homeBindMapId_, owner_.homeBindPos_.x, owner_.homeBindPos_.y, owner_.homeBindPos_.z);
}
}
// Fire UNIT_SPELLCAST_START
if (owner_.addonEventCallback_) {
std::string unitId = owner_.guidToUnitId(data.casterUnit);
if (!unitId.empty())
owner_.addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)});
}
}
void SpellHandler::handleSpellGo(network::Packet& packet) {
SpellGoData data;
if (!owner_.packetParsers_->parseSpellGo(packet, data)) return;
if (data.casterUnit == owner_.playerGuid) {
// Play cast-complete sound
if (!owner_.isProfessionSpell(data.spellId)) {
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playCast(school);
}
}
}
// Instant melee abilities → trigger attack animation
uint32_t sid = data.spellId;
bool isMeleeAbility = false;
if (!owner_.isProfessionSpell(sid)) {
owner_.loadSpellNameCache();
auto cacheIt = owner_.spellNameCache_.find(sid);
if (cacheIt != owner_.spellNameCache_.end() && cacheIt->second.schoolMask == 1) {
isMeleeAbility = (currentCastSpellId_ != sid);
}
}
if (isMeleeAbility) {
if (owner_.meleeSwingCallback_) owner_.meleeSwingCallback_();
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* csm = ac->getCombatSoundManager()) {
csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false);
csm->playImpact(audio::CombatSoundManager::WeaponSize::MEDIUM,
audio::CombatSoundManager::ImpactType::FLESH, false);
}
}
}
const bool wasInTimedCast = casting_ && (data.spellId == currentCastSpellId_);
LOG_WARNING("[GO-DIAG] SPELL_GO: spellId=", data.spellId,
" casting=", casting_, " currentCast=", currentCastSpellId_,
" wasInTimedCast=", wasInTimedCast,
" lastGoGuid=0x", std::hex, owner_.lastInteractedGoGuid_,
" pendingGoGuid=0x", owner_.pendingGameObjectInteractGuid_, std::dec);
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
// Gather node looting: re-send CMSG_LOOT now that the cast completed.
if (wasInTimedCast && owner_.lastInteractedGoGuid_ != 0) {
LOG_WARNING("[GO-DIAG] Sending CMSG_LOOT for GO 0x", std::hex,
owner_.lastInteractedGoGuid_, std::dec);
owner_.lootTarget(owner_.lastInteractedGoGuid_);
owner_.lastInteractedGoGuid_ = 0;
}
// Clear the GO interaction guard so future cancelCast() calls work
// normally. Without this, pendingGameObjectInteractGuid_ stays stale
// and suppresses CMSG_CANCEL_CAST for ALL subsequent spell casts.
owner_.pendingGameObjectInteractGuid_ = 0;
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(owner_.playerGuid, false, false);
}
if (owner_.addonEventCallback_)
owner_.addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)});
// Spell queue: fire the next queued spell
if (queuedSpellId_ != 0) {
uint32_t nextSpell = queuedSpellId_;
uint64_t nextTarget = queuedSpellTarget_;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
LOG_INFO("Spell queue: firing queued spellId=", nextSpell);
castSpell(nextSpell, nextTarget);
}
} else {
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(data.casterUnit, false, false);
}
bool targetsPlayer = false;
for (const auto& tgt : data.hitTargets) {
if (tgt == owner_.playerGuid) { targetsPlayer = true; break; }
}
if (targetsPlayer) {
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playCast(school);
}
}
}
}
// Clear unit cast bar
unitCastStates_.erase(data.casterUnit);
// Miss combat text
if (!data.missTargets.empty()) {
const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid;
const bool playerIsCaster = (spellCasterGuid == owner_.playerGuid);
for (const auto& m : data.missTargets) {
if (!playerIsCaster && m.targetGuid != owner_.playerGuid) {
continue;
}
CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(m.missType);
owner_.addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid);
}
}
// Impact sound
bool playerIsHit = false;
bool playerHitEnemy = false;
for (const auto& tgt : data.hitTargets) {
if (tgt == owner_.playerGuid) { playerIsHit = true; }
if (data.casterUnit == owner_.playerGuid && tgt != owner_.playerGuid && tgt != 0) { playerHitEnemy = true; }
}
// Fire UNIT_SPELLCAST_SUCCEEDED
if (owner_.addonEventCallback_) {
std::string unitId = owner_.guidToUnitId(data.casterUnit);
if (!unitId.empty())
owner_.addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)});
}
if (playerIsHit || playerHitEnemy) {
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask)
? schoolMaskToMagicSchool(it->second.schoolMask)
: audio::SpellSoundManager::MagicSchool::ARCANE;
ssm->playImpact(school, audio::SpellSoundManager::SpellPower::MEDIUM);
}
}
}
}
void SpellHandler::handleSpellCooldown(network::Packet& packet) {
const bool isClassicFormat = isClassicLikeExpansion();
if (!packet.hasRemaining(8)) return;
/*guid*/ packet.readUInt64();
if (!isClassicFormat) {
if (!packet.hasRemaining(1)) return;
/*flags*/ packet.readUInt8();
}
const size_t entrySize = isClassicFormat ? 12u : 8u;
while (packet.getRemainingSize() >= entrySize) {
uint32_t spellId = packet.readUInt32();
uint32_t cdItemId = 0;
if (isClassicFormat) cdItemId = packet.readUInt32();
uint32_t cooldownMs = packet.readUInt32();
float seconds = cooldownMs / 1000.0f;
// spellId=0 is the Global Cooldown marker
if (spellId == 0 && cooldownMs > 0 && cooldownMs <= 2000) {
gcdTotal_ = seconds;
gcdStartedAt_ = std::chrono::steady_clock::now();
continue;
}
auto it = spellCooldowns_.find(spellId);
if (it == spellCooldowns_.end()) {
spellCooldowns_[spellId] = seconds;
} else {
it->second = mergeCooldownSeconds(it->second, seconds);
}
for (auto& slot : owner_.actionBar) {
bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|| (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId);
if (match) {
float prevRemaining = slot.cooldownRemaining;
float merged = mergeCooldownSeconds(slot.cooldownRemaining, seconds);
slot.cooldownRemaining = merged;
if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) {
slot.cooldownTotal = seconds;
} else {
slot.cooldownTotal = std::max(slot.cooldownTotal, merged);
}
}
}
}
LOG_DEBUG("handleSpellCooldown: parsed for ",
isClassicFormat ? "Classic" : "TBC/WotLK", " format");
if (owner_.addonEventCallback_) {
owner_.addonEventCallback_("SPELL_UPDATE_COOLDOWN", {});
owner_.addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {});
}
}
void SpellHandler::handleCooldownEvent(network::Packet& packet) {
if (!packet.hasRemaining(4)) return;
uint32_t spellId = packet.readUInt32();
if (packet.hasRemaining(8))
packet.readUInt64();
spellCooldowns_.erase(spellId);
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownRemaining = 0.0f;
}
}
if (owner_.addonEventCallback_) {
owner_.addonEventCallback_("SPELL_UPDATE_COOLDOWN", {});
owner_.addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {});
}
}
void SpellHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
AuraUpdateData data;
if (!owner_.packetParsers_->parseAuraUpdate(packet, data, isAll)) return;
std::vector<AuraSlot>* auraList = nullptr;
if (data.guid == owner_.playerGuid) {
auraList = &playerAuras_;
} else if (data.guid == owner_.targetGuid) {
auraList = &targetAuras_;
}
if (data.guid != 0 && data.guid != owner_.playerGuid && data.guid != owner_.targetGuid) {
auraList = &unitAurasCache_[data.guid];
}
if (auraList) {
if (isAll) {
auraList->clear();
}
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (auto [slot, aura] : data.updates) {
if (aura.durationMs >= 0) {
aura.receivedAtMs = nowMs;
}
while (auraList->size() <= slot) {
auraList->push_back(AuraSlot{});
}
(*auraList)[slot] = aura;
}
if (owner_.addonEventCallback_) {
std::string unitId;
if (data.guid == owner_.playerGuid) unitId = "player";
else if (data.guid == owner_.targetGuid) unitId = "target";
else if (data.guid == owner_.focusGuid) unitId = "focus";
else if (data.guid == owner_.petGuid_) unitId = "pet";
if (!unitId.empty())
owner_.addonEventCallback_("UNIT_AURA", {unitId});
}
// Mount aura detection
if (data.guid == owner_.playerGuid && owner_.currentMountDisplayId_ != 0 && owner_.mountAuraSpellId_ == 0) {
for (const auto& [slot, aura] : data.updates) {
if (!aura.isEmpty() && aura.maxDurationMs < 0 && aura.casterGuid == owner_.playerGuid) {
owner_.mountAuraSpellId_ = aura.spellId;
LOG_INFO("Mount aura detected from aura update: spellId=", aura.spellId);
}
}
}
}
}
void SpellHandler::handleLearnedSpell(network::Packet& packet) {
const bool classicSpellId = isClassicLikeExpansion();
const size_t minSz = classicSpellId ? 2u : 4u;
if (packet.getRemainingSize() < minSz) return;
uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32();
const bool alreadyKnown = knownSpells_.count(spellId) > 0;
knownSpells_.insert(spellId);
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) {
uint8_t newRank = rank + 1;
learnedTalents_[activeTalentSpec_][talentId] = newRank;
LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank,
" (spell ", spellId, ") in spec ", (int)activeTalentSpec_);
isTalentSpell = true;
if (owner_.addonEventCallback_) {
owner_.addonEventCallback_("CHARACTER_POINTS_CHANGED", {});
owner_.addonEventCallback_("PLAYER_TALENT_UPDATE", {});
}
break;
}
}
if (isTalentSpell) break;
}
if (!alreadyKnown && owner_.addonEventCallback_) {
owner_.addonEventCallback_("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)});
owner_.addonEventCallback_("SPELLS_CHANGED", {});
}
if (isTalentSpell) return;
if (!alreadyKnown) {
const std::string& name = owner_.getSpellName(spellId);
if (!name.empty()) {
owner_.addSystemChatMessage("You have learned a new spell: " + name + ".");
} else {
owner_.addSystemChatMessage("You have learned a new spell.");
}
}
}
void SpellHandler::handleRemovedSpell(network::Packet& packet) {
const bool classicSpellId = isClassicLikeExpansion();
const size_t minSz = classicSpellId ? 2u : 4u;
if (packet.getRemainingSize() < minSz) return;
uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32();
knownSpells_.erase(spellId);
LOG_INFO("Removed spell: ", spellId);
if (owner_.addonEventCallback_) owner_.addonEventCallback_("SPELLS_CHANGED", {});
const std::string& name = owner_.getSpellName(spellId);
if (!name.empty())
owner_.addSystemChatMessage("You have unlearned: " + name + ".");
else
owner_.addSystemChatMessage("A spell has been removed.");
bool barChanged = false;
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot = ActionBarSlot{};
barChanged = true;
}
}
if (barChanged) owner_.saveCharacterConfig();
}
void SpellHandler::handleSupercededSpell(network::Packet& packet) {
const bool classicSpellId = isClassicLikeExpansion();
const size_t minSz = classicSpellId ? 4u : 8u;
if (packet.getRemainingSize() < minSz) return;
uint32_t oldSpellId = classicSpellId ? packet.readUInt16() : packet.readUInt32();
uint32_t newSpellId = classicSpellId ? packet.readUInt16() : packet.readUInt32();
knownSpells_.erase(oldSpellId);
const bool newSpellAlreadyAnnounced = knownSpells_.count(newSpellId) > 0;
knownSpells_.insert(newSpellId);
LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId);
bool barChanged = false;
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) {
slot.id = newSpellId;
slot.cooldownRemaining = 0.0f;
slot.cooldownTotal = 0.0f;
barChanged = true;
LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId);
}
}
if (barChanged) {
owner_.saveCharacterConfig();
if (owner_.addonEventCallback_) owner_.addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {});
}
if (!newSpellAlreadyAnnounced) {
const std::string& newName = owner_.getSpellName(newSpellId);
if (!newName.empty()) {
owner_.addSystemChatMessage("Upgraded to " + newName);
}
}
}
void SpellHandler::handleUnlearnSpells(network::Packet& packet) {
if (!packet.hasRemaining(4)) return;
uint32_t spellCount = packet.readUInt32();
LOG_INFO("Unlearning ", spellCount, " spells");
bool barChanged = false;
for (uint32_t i = 0; i < spellCount && packet.getRemainingSize() >= 4; ++i) {
uint32_t spellId = packet.readUInt32();
knownSpells_.erase(spellId);
LOG_INFO(" Unlearned spell: ", spellId);
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot = ActionBarSlot{};
barChanged = true;
}
}
}
if (barChanged) owner_.saveCharacterConfig();
if (spellCount > 0) {
owner_.addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells");
}
}
void SpellHandler::handleTalentsInfo(network::Packet& packet) {
if (!packet.hasRemaining(1)) return;
uint8_t talentType = packet.readUInt8();
if (talentType != 0) {
return;
}
if (!packet.hasRemaining(6)) {
LOG_WARNING("handleTalentsInfo: packet too short for header");
return;
}
uint32_t unspentTalents = packet.readUInt32();
uint8_t talentGroupCount = packet.readUInt8();
uint8_t activeTalentGroup = packet.readUInt8();
if (activeTalentGroup > 1) activeTalentGroup = 0;
loadTalentDbc();
activeTalentSpec_ = activeTalentGroup;
for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) {
if (!packet.hasRemaining(1)) break;
uint8_t talentCount = packet.readUInt8();
learnedTalents_[g].clear();
for (uint8_t t = 0; t < talentCount; ++t) {
if (!packet.hasRemaining(5)) break;
uint32_t talentId = packet.readUInt32();
uint8_t rank = packet.readUInt8();
learnedTalents_[g][talentId] = rank + 1u;
}
learnedGlyphs_[g].fill(0);
if (!packet.hasRemaining(1)) break;
uint8_t glyphCount = packet.readUInt8();
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
if (!packet.hasRemaining(2)) break;
uint16_t glyphId = packet.readUInt16();
if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId;
}
}
unspentTalentPoints_[activeTalentGroup] =
static_cast<uint8_t>(unspentTalents > 255 ? 255 : unspentTalents);
LOG_INFO("handleTalentsInfo: unspent=", unspentTalents,
" groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup,
" learned=", learnedTalents_[activeTalentGroup].size());
if (owner_.addonEventCallback_) {
owner_.addonEventCallback_("CHARACTER_POINTS_CHANGED", {});
owner_.addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {});
owner_.addonEventCallback_("PLAYER_TALENT_UPDATE", {});
}
if (!talentsInitialized_) {
talentsInitialized_ = true;
if (unspentTalents > 0) {
owner_.addSystemChatMessage("You have " + std::to_string(unspentTalents)
+ " unspent talent point" + (unspentTalents != 1 ? "s" : "") + ".");
}
}
}
void SpellHandler::handleAchievementEarned(network::Packet& packet) {
size_t remaining = packet.getRemainingSize();
if (remaining < 16) return;
uint64_t guid = packet.readUInt64();
uint32_t achievementId = packet.readUInt32();
uint32_t earnDate = packet.readUInt32();
owner_.loadAchievementNameCache();
auto nameIt = owner_.achievementNameCache_.find(achievementId);
const std::string& achName = (nameIt != owner_.achievementNameCache_.end())
? nameIt->second : std::string();
bool isSelf = (guid == owner_.playerGuid);
if (isSelf) {
char buf[256];
if (!achName.empty()) {
std::snprintf(buf, sizeof(buf), "Achievement earned: %s", achName.c_str());
} else {
std::snprintf(buf, sizeof(buf), "Achievement earned! (ID %u)", achievementId);
}
owner_.addSystemChatMessage(buf);
owner_.earnedAchievements_.insert(achievementId);
owner_.achievementDates_[achievementId] = earnDate;
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* sfx = ac->getUiSoundManager())
sfx->playAchievementAlert();
}
if (owner_.achievementEarnedCallback_) {
owner_.achievementEarnedCallback_(achievementId, achName);
}
} else {
std::string senderName;
auto entity = owner_.getEntityManager().getEntity(guid);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
senderName = unit->getName();
}
if (senderName.empty()) {
auto nit = owner_.getPlayerNameCache().find(guid);
if (nit != owner_.getPlayerNameCache().end())
senderName = nit->second;
}
if (senderName.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(guid));
senderName = tmp;
}
// Use std::string instead of fixed char[256] — achievement names can be
// long and combined with senderName could exceed 256 bytes, silently truncating.
std::string msg = senderName + (!achName.empty()
? " has earned the achievement: " + achName
: " has earned an achievement! (ID " + std::to_string(achievementId) + ")");
owner_.addSystemChatMessage(msg);
}
LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec,
" achievementId=", achievementId, " self=", isSelf,
achName.empty() ? "" : " name=", achName);
if (owner_.addonEventCallback_)
owner_.addonEventCallback_("ACHIEVEMENT_EARNED", {std::to_string(achievementId)});
}
// SMSG_EQUIPMENT_SET_LIST — moved to InventoryHandler
// ============================================================
// Pet spell methods (moved from GameHandler)
// ============================================================
void SpellHandler::handlePetSpells(network::Packet& packet) {
const size_t remaining = packet.getRemainingSize();
if (remaining < 8) {
owner_.petGuid_ = 0;
owner_.petSpellList_.clear();
owner_.petAutocastSpells_.clear();
memset(owner_.petActionSlots_, 0, sizeof(owner_.petActionSlots_));
LOG_INFO("SMSG_PET_SPELLS: pet cleared");
owner_.fireAddonEvent("UNIT_PET", {"player"});
return;
}
owner_.petGuid_ = packet.readUInt64();
if (owner_.petGuid_ == 0) {
owner_.petSpellList_.clear();
owner_.petAutocastSpells_.clear();
memset(owner_.petActionSlots_, 0, sizeof(owner_.petActionSlots_));
LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)");
owner_.fireAddonEvent("UNIT_PET", {"player"});
return;
}
// Parse optional pet fields — bail on truncated packets but always log+fire below.
do {
if (!packet.hasRemaining(4)) break;
/*uint16_t dur =*/ packet.readUInt16();
/*uint16_t timer =*/ packet.readUInt16();
if (!packet.hasRemaining(2)) break;
owner_.petReact_ = packet.readUInt8();
owner_.petCommand_ = packet.readUInt8();
if (!packet.hasRemaining(GameHandler::PET_ACTION_BAR_SLOTS * 4u)) break;
for (int i = 0; i < GameHandler::PET_ACTION_BAR_SLOTS; ++i) {
owner_.petActionSlots_[i] = packet.readUInt32();
}
if (!packet.hasRemaining(1)) break;
uint8_t spellCount = packet.readUInt8();
owner_.petSpellList_.clear();
owner_.petAutocastSpells_.clear();
for (uint8_t i = 0; i < spellCount; ++i) {
if (!packet.hasRemaining(6)) break;
uint32_t spellId = packet.readUInt32();
uint16_t activeFlags = packet.readUInt16();
owner_.petSpellList_.push_back(spellId);
if (activeFlags & 0x0001) {
owner_.petAutocastSpells_.insert(spellId);
}
}
} while (false);
LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, owner_.petGuid_, std::dec,
" react=", static_cast<int>(owner_.petReact_), " command=", static_cast<int>(owner_.petCommand_),
" spells=", owner_.petSpellList_.size());
owner_.fireAddonEvent("UNIT_PET", {"player"});
owner_.fireAddonEvent("PET_BAR_UPDATE", {});
}
void SpellHandler::sendPetAction(uint32_t action, uint64_t targetGuid) {
if (!owner_.hasPet() || owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
auto pkt = PetActionPacket::build(owner_.petGuid_, action, targetGuid);
owner_.socket->send(pkt);
LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, owner_.petGuid_,
" action=0x", action, " target=0x", targetGuid, std::dec);
}
void SpellHandler::dismissPet() {
if (owner_.petGuid_ == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
auto packet = PetActionPacket::build(owner_.petGuid_, 0x07000000);
owner_.socket->send(packet);
}
void SpellHandler::togglePetSpellAutocast(uint32_t spellId) {
if (owner_.petGuid_ == 0 || spellId == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
bool currentlyOn = owner_.petAutocastSpells_.count(spellId) != 0;
uint8_t newState = currentlyOn ? 0 : 1;
network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST));
pkt.writeUInt64(owner_.petGuid_);
pkt.writeUInt32(spellId);
pkt.writeUInt8(newState);
owner_.socket->send(pkt);
if (newState)
owner_.petAutocastSpells_.insert(spellId);
else
owner_.petAutocastSpells_.erase(spellId);
LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", static_cast<int>(newState));
}
void SpellHandler::renamePet(const std::string& newName) {
if (owner_.petGuid_ == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
if (newName.empty() || newName.size() > 12) return;
auto packet = PetRenamePacket::build(owner_.petGuid_, newName, 0);
owner_.socket->send(packet);
LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, owner_.petGuid_, std::dec, " name='", newName, "'");
}
void SpellHandler::handleListStabledPets(network::Packet& packet) {
constexpr size_t kMinHeader = 8 + 1 + 1;
if (!packet.hasRemaining(kMinHeader)) {
LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")");
return;
}
owner_.stableMasterGuid_ = packet.readUInt64();
uint8_t petCount = packet.readUInt8();
owner_.stableNumSlots_ = packet.readUInt8();
owner_.stabledPets_.clear();
owner_.stabledPets_.reserve(petCount);
for (uint8_t i = 0; i < petCount; ++i) {
// petNumber(4) + entry(4) + level(4) = 12 bytes before the name string
if (!packet.hasRemaining(12)) break;
GameHandler::StabledPet pet;
pet.petNumber = packet.readUInt32();
pet.entry = packet.readUInt32();
pet.level = packet.readUInt32();
pet.name = packet.readString();
// displayId(4) + isActive(1) = 5 bytes after the name string
if (!packet.hasRemaining(5)) break;
pet.displayId = packet.readUInt32();
pet.isActive = (packet.readUInt8() != 0);
owner_.stabledPets_.push_back(std::move(pet));
}
owner_.stableWindowOpen_ = true;
LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, owner_.stableMasterGuid_, std::dec,
" petCount=", static_cast<int>(petCount), " numSlots=", static_cast<int>(owner_.stableNumSlots_));
for (const auto& p : owner_.stabledPets_) {
LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry,
" level=", p.level, " name='", p.name, "' displayId=", p.displayId,
" active=", p.isActive);
}
}
// ============================================================
// Cast state methods (moved from GameHandler)
// ============================================================
void SpellHandler::stopCasting() {
if (!owner_.isInWorld()) {
LOG_WARNING("Cannot stop casting: not in world or not connected");
return;
}
if (!casting_) {
return;
}
if (owner_.pendingGameObjectInteractGuid_ == 0 && currentCastSpellId_ != 0) {
auto packet = CancelCastPacket::build(currentCastSpellId_);
owner_.socket->send(packet);
}
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
castTimeTotal_ = 0.0f;
owner_.pendingGameObjectInteractGuid_ = 0;
owner_.lastInteractedGoGuid_ = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
LOG_INFO("Cancelled spell cast");
}
void SpellHandler::resetCastState() {
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
castTimeTotal_ = 0.0f; // Must match castTimeRemaining_ to keep getCastProgress() == 0
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
owner_.pendingGameObjectInteractGuid_ = 0;
// lastInteractedGoGuid_ is intentionally NOT cleared here — it must survive
// until handleSpellGo sends CMSG_LOOT after the server-side cast completes.
// handleSpellGo clears it after use (line 958). Previously this was cleared
// here, which meant the client-side timer fallback destroyed the guid before
// SMSG_SPELL_GO arrived, preventing loot from opening on quest chests.
}
void SpellHandler::resetAllState() {
knownSpells_.clear();
spellCooldowns_.clear();
playerAuras_.clear();
targetAuras_.clear();
unitAurasCache_.clear();
unitCastStates_.clear();
resetCastState();
resetTalentState();
}
void SpellHandler::resetTalentState() {
talentsInitialized_ = false;
learnedTalents_[0].clear();
learnedTalents_[1].clear();
learnedGlyphs_[0].fill(0);
learnedGlyphs_[1].fill(0);
unspentTalentPoints_[0] = 0;
unspentTalentPoints_[1] = 0;
activeTalentSpec_ = 0;
}
void SpellHandler::clearUnitCaches() {
unitCastStates_.clear();
unitAurasCache_.clear();
}
// ============================================================
// Aura duration update (moved from GameHandler)
// ============================================================
void SpellHandler::handleUpdateAuraDuration(uint8_t slot, uint32_t durationMs) {
if (slot >= playerAuras_.size()) return;
if (playerAuras_[slot].isEmpty()) return;
playerAuras_[slot].durationMs = static_cast<int32_t>(durationMs);
playerAuras_[slot].receivedAtMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
}
// ============================================================
// Spell DBC / Cache methods (moved from GameHandler)
// ============================================================
static const std::string SPELL_EMPTY_STRING;
void SpellHandler::loadSpellNameCache() const {
if (owner_.spellNameCacheLoaded_) return;
owner_.spellNameCacheLoaded_ = true;
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("Spell.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Trainer: Could not load Spell.dbc for spell names");
return;
}
if (dbc->getFieldCount() < 148) {
LOG_WARNING("Trainer: Spell.dbc has too few fields (", dbc->getFieldCount(), ")");
return;
}
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
uint32_t schoolMaskField = 0, schoolEnumField = 0;
bool hasSchoolMask = false, hasSchoolEnum = false;
if (spellL) {
uint32_t f = spellL->field("SchoolMask");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolMaskField = f; hasSchoolMask = true; }
f = spellL->field("SchoolEnum");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; }
}
uint32_t dispelField = 0xFFFFFFFF;
bool hasDispelField = false;
if (spellL) {
uint32_t f = spellL->field("DispelType");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; }
}
uint32_t attrExField = 0xFFFFFFFF;
bool hasAttrExField = false;
if (spellL) {
uint32_t f = spellL->field("AttributesEx");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; }
}
uint32_t tooltipField = 0xFFFFFFFF;
if (spellL) {
uint32_t f = spellL->field("Tooltip");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f;
}
// Cache field indices before the loop to avoid repeated layout lookups
const uint32_t idField = spellL ? (*spellL)["ID"] : 0;
const uint32_t nameField = spellL ? (*spellL)["Name"] : 136;
const uint32_t rankField = spellL ? (*spellL)["Rank"] : 153;
const uint32_t ebp0Field = spellL ? spellL->field("EffectBasePoints0") : 0xFFFFFFFF;
const uint32_t ebp1Field = spellL ? spellL->field("EffectBasePoints1") : 0xFFFFFFFF;
const uint32_t ebp2Field = spellL ? spellL->field("EffectBasePoints2") : 0xFFFFFFFF;
const uint32_t durIdxField = spellL ? spellL->field("DurationIndex") : 0xFFFFFFFF;
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t id = dbc->getUInt32(i, idField);
if (id == 0) continue;
std::string name = dbc->getString(i, nameField);
std::string rank = dbc->getString(i, rankField);
if (!name.empty()) {
GameHandler::SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0};
if (tooltipField != 0xFFFFFFFF) {
entry.description = dbc->getString(i, tooltipField);
}
if (hasSchoolMask) {
entry.schoolMask = dbc->getUInt32(i, schoolMaskField);
} else if (hasSchoolEnum) {
static const uint32_t enumToBitmask[] = {0x01,0x02,0x04,0x08,0x10,0x20,0x40};
uint32_t e = dbc->getUInt32(i, schoolEnumField);
entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0;
}
if (hasDispelField) {
entry.dispelType = static_cast<uint8_t>(dbc->getUInt32(i, dispelField));
}
if (hasAttrExField) {
entry.attrEx = dbc->getUInt32(i, attrExField);
}
// Load effect base points for $s1/$s2/$s3 tooltip substitution
if (ebp0Field != 0xFFFFFFFF) entry.effectBasePoints[0] = static_cast<int32_t>(dbc->getUInt32(i, ebp0Field));
if (ebp1Field != 0xFFFFFFFF) entry.effectBasePoints[1] = static_cast<int32_t>(dbc->getUInt32(i, ebp1Field));
if (ebp2Field != 0xFFFFFFFF) entry.effectBasePoints[2] = static_cast<int32_t>(dbc->getUInt32(i, ebp2Field));
// Duration: read DurationIndex and resolve via SpellDuration.dbc later
if (durIdxField != 0xFFFFFFFF)
entry.durationSec = static_cast<float>(dbc->getUInt32(i, durIdxField)); // store index temporarily
owner_.spellNameCache_[id] = std::move(entry);
}
}
auto durDbc = am->loadDBC("SpellDuration.dbc");
if (durDbc && durDbc->isLoaded()) {
std::unordered_map<uint32_t, float> durMap;
for (uint32_t di = 0; di < durDbc->getRecordCount(); ++di) {
uint32_t durId = durDbc->getUInt32(di, 0);
int32_t baseMs = static_cast<int32_t>(durDbc->getUInt32(di, 1));
if (baseMs > 0 && baseMs < 100000000)
durMap[durId] = baseMs / 1000.0f;
}
for (auto& [sid, entry] : owner_.spellNameCache_) {
uint32_t durIdx = static_cast<uint32_t>(entry.durationSec);
if (durIdx > 0) {
auto it = durMap.find(durIdx);
entry.durationSec = (it != durMap.end()) ? it->second : 0.0f;
}
}
}
LOG_INFO("Trainer: Loaded ", owner_.spellNameCache_.size(), " spell names from Spell.dbc");
}
void SpellHandler::loadSkillLineAbilityDbc() {
if (owner_.skillLineAbilityLoaded_) return;
owner_.skillLineAbilityLoaded_ = true;
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto slaDbc = am->loadDBC("SkillLineAbility.dbc");
if (slaDbc && slaDbc->isLoaded()) {
const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr;
const uint32_t slaSkillField = slaL ? (*slaL)["SkillLineID"] : 1;
const uint32_t slaSpellField = slaL ? (*slaL)["SpellID"] : 2;
for (uint32_t i = 0; i < slaDbc->getRecordCount(); i++) {
uint32_t skillLineId = slaDbc->getUInt32(i, slaSkillField);
uint32_t spellId = slaDbc->getUInt32(i, slaSpellField);
if (spellId > 0 && skillLineId > 0) {
owner_.spellToSkillLine_[spellId] = skillLineId;
}
}
LOG_INFO("Trainer: Loaded ", owner_.spellToSkillLine_.size(), " skill line abilities");
}
}
void SpellHandler::categorizeTrainerSpells() {
owner_.trainerTabs_.clear();
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
std::map<uint32_t, std::vector<const TrainerSpell*>> specialtySpells;
std::vector<const TrainerSpell*> generalSpells;
for (const auto& spell : owner_.currentTrainerList_.spells) {
auto slIt = owner_.spellToSkillLine_.find(spell.spellId);
if (slIt != owner_.spellToSkillLine_.end()) {
uint32_t skillLineId = slIt->second;
auto catIt = owner_.skillLineCategories_.find(skillLineId);
if (catIt != owner_.skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
specialtySpells[skillLineId].push_back(&spell);
continue;
}
}
generalSpells.push_back(&spell);
}
auto byName = [this](const TrainerSpell* a, const TrainerSpell* b) {
return getSpellName(a->spellId) < getSpellName(b->spellId);
};
std::vector<std::pair<std::string, std::vector<const TrainerSpell*>>> named;
for (auto& [skillLineId, spells] : specialtySpells) {
auto nameIt = owner_.skillLineNames_.find(skillLineId);
std::string tabName = (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : "Specialty";
std::sort(spells.begin(), spells.end(), byName);
named.push_back({std::move(tabName), std::move(spells)});
}
std::sort(named.begin(), named.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& [name, spells] : named) {
owner_.trainerTabs_.push_back({std::move(name), std::move(spells)});
}
if (!generalSpells.empty()) {
std::sort(generalSpells.begin(), generalSpells.end(), byName);
owner_.trainerTabs_.push_back({"General", std::move(generalSpells)});
}
LOG_INFO("Trainer: Categorized into ", owner_.trainerTabs_.size(), " tabs");
}
const int32_t* SpellHandler::getSpellEffectBasePoints(uint32_t spellId) const {
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.effectBasePoints : nullptr;
}
float SpellHandler::getSpellDuration(uint32_t spellId) const {
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.durationSec : 0.0f;
}
const std::string& SpellHandler::getSpellName(uint32_t spellId) const {
// Lazy-load Spell.dbc so callers don't need to know about initialization order.
// Every other DBC-backed getter (getSpellDescription, getSpellSchoolMask, etc.)
// already does this; these two were missed.
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.name : SPELL_EMPTY_STRING;
}
const std::string& SpellHandler::getSpellRank(uint32_t spellId) const {
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.rank : SPELL_EMPTY_STRING;
}
const std::string& SpellHandler::getSpellDescription(uint32_t spellId) const {
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.description : SPELL_EMPTY_STRING;
}
std::string SpellHandler::getEnchantName(uint32_t enchantId) const {
if (enchantId == 0) return {};
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return {};
auto dbc = am->loadDBC("SpellItemEnchantment.dbc");
if (!dbc || !dbc->isLoaded()) return {};
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
if (dbc->getUInt32(i, 0) == enchantId) {
return dbc->getString(i, 14);
}
}
return {};
}
uint8_t SpellHandler::getSpellDispelType(uint32_t spellId) const {
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.dispelType : 0;
}
bool SpellHandler::isSpellInterruptible(uint32_t spellId) const {
if (spellId == 0) return true;
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
if (it == owner_.spellNameCache_.end()) return true;
return (it->second.attrEx & 0x00000010u) == 0;
}
uint32_t SpellHandler::getSpellSchoolMask(uint32_t spellId) const {
if (spellId == 0) return 0;
loadSpellNameCache();
auto it = owner_.spellNameCache_.find(spellId);
return (it != owner_.spellNameCache_.end()) ? it->second.schoolMask : 0;
}
const std::string& SpellHandler::getSkillLineName(uint32_t spellId) const {
auto slIt = owner_.spellToSkillLine_.find(spellId);
if (slIt == owner_.spellToSkillLine_.end()) return SPELL_EMPTY_STRING;
auto nameIt = owner_.skillLineNames_.find(slIt->second);
return (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : SPELL_EMPTY_STRING;
}
// ============================================================
// Skill DBC methods (moved from GameHandler)
// ============================================================
void SpellHandler::loadSkillLineDbc() {
if (owner_.skillLineDbcLoaded_) return;
owner_.skillLineDbcLoaded_ = true;
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("SkillLine.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("GameHandler: Could not load SkillLine.dbc");
return;
}
const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
const uint32_t slIdField = slL ? (*slL)["ID"] : 0;
const uint32_t slCatField = slL ? (*slL)["Category"] : 1;
const uint32_t slNameField = slL ? (*slL)["Name"] : 3;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
uint32_t id = dbc->getUInt32(i, slIdField);
uint32_t category = dbc->getUInt32(i, slCatField);
std::string name = dbc->getString(i, slNameField);
if (id > 0 && !name.empty()) {
owner_.skillLineNames_[id] = name;
owner_.skillLineCategories_[id] = category;
}
}
LOG_INFO("GameHandler: Loaded ", owner_.skillLineNames_.size(), " skill line names");
}
void SpellHandler::extractSkillFields(const std::map<uint16_t, uint32_t>& fields) {
loadSkillLineDbc();
const uint16_t PLAYER_SKILL_INFO_START = fieldIndex(UF::PLAYER_SKILL_INFO_START);
static constexpr int MAX_SKILL_SLOTS = 128;
std::unordered_map<uint32_t, PlayerSkill> newSkills;
for (int slot = 0; slot < MAX_SKILL_SLOTS; slot++) {
uint16_t baseField = PLAYER_SKILL_INFO_START + slot * 3;
auto idIt = fields.find(baseField);
if (idIt == fields.end()) continue;
uint32_t raw0 = idIt->second;
uint16_t skillId = raw0 & 0xFFFF;
if (skillId == 0) continue;
auto valIt = fields.find(baseField + 1);
if (valIt == fields.end()) continue;
uint32_t raw1 = valIt->second;
uint16_t value = raw1 & 0xFFFF;
uint16_t maxValue = (raw1 >> 16) & 0xFFFF;
uint16_t bonusTemp = 0;
uint16_t bonusPerm = 0;
auto bonusIt = fields.find(static_cast<uint16_t>(baseField + 2));
if (bonusIt != fields.end()) {
bonusTemp = bonusIt->second & 0xFFFF;
bonusPerm = (bonusIt->second >> 16) & 0xFFFF;
}
PlayerSkill skill;
skill.skillId = skillId;
skill.value = value;
skill.maxValue = maxValue;
skill.bonusTemp = bonusTemp;
skill.bonusPerm = bonusPerm;
newSkills[skillId] = skill;
}
for (const auto& [skillId, skill] : newSkills) {
if (skill.value == 0) continue;
auto oldIt = owner_.playerSkills_.find(skillId);
if (oldIt != owner_.playerSkills_.end() && skill.value > oldIt->second.value) {
auto catIt = owner_.skillLineCategories_.find(skillId);
if (catIt != owner_.skillLineCategories_.end()) {
uint32_t category = catIt->second;
if (category == 5 || category == 10 || category == 12) {
continue;
}
}
const std::string& name = owner_.getSkillName(skillId);
std::string skillName = name.empty() ? ("Skill #" + std::to_string(skillId)) : name;
owner_.addSystemChatMessage("Your skill in " + skillName + " has increased to " + std::to_string(skill.value) + ".");
}
}
bool skillsChanged = (newSkills.size() != owner_.playerSkills_.size());
if (!skillsChanged) {
for (const auto& [id, sk] : newSkills) {
auto it = owner_.playerSkills_.find(id);
if (it == owner_.playerSkills_.end() || it->second.value != sk.value) {
skillsChanged = true;
break;
}
}
}
owner_.playerSkills_ = std::move(newSkills);
if (skillsChanged)
owner_.fireAddonEvent("SKILL_LINES_CHANGED", {});
}
void SpellHandler::extractExploredZoneFields(const std::map<uint16_t, uint32_t>& fields) {
const size_t zoneCount = owner_.packetParsers_
? static_cast<size_t>(owner_.packetParsers_->exploredZonesCount())
: GameHandler::PLAYER_EXPLORED_ZONES_COUNT;
if (owner_.playerExploredZones_.size() != GameHandler::PLAYER_EXPLORED_ZONES_COUNT) {
owner_.playerExploredZones_.assign(GameHandler::PLAYER_EXPLORED_ZONES_COUNT, 0u);
}
bool foundAny = false;
for (size_t i = 0; i < zoneCount; i++) {
const uint16_t fieldIdx = static_cast<uint16_t>(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i);
auto it = fields.find(fieldIdx);
if (it == fields.end()) continue;
owner_.playerExploredZones_[i] = it->second;
foundAny = true;
}
for (size_t i = zoneCount; i < GameHandler::PLAYER_EXPLORED_ZONES_COUNT; i++) {
owner_.playerExploredZones_[i] = 0u;
}
if (foundAny) {
owner_.hasPlayerExploredZones_ = true;
}
}
// ============================================================
// Moved opcode handlers (from GameHandler::registerOpcodeHandlers)
// ============================================================
void SpellHandler::handleCastResult(network::Packet& packet) {
uint32_t castResultSpellId = 0;
uint8_t castResult = 0;
if (owner_.packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) {
LOG_DEBUG("SMSG_CAST_RESULT: spellId=", castResultSpellId, " result=", static_cast<int>(castResult));
if (castResult != 0) {
casting_ = false; castIsChannel_ = false; currentCastSpellId_ = 0; castTimeRemaining_ = 0.0f;
owner_.lastInteractedGoGuid_ = 0;
craftQueueSpellId_ = 0; craftQueueRemaining_ = 0;
queuedSpellId_ = 0; queuedSpellTarget_ = 0;
int playerPowerType = -1;
if (auto pe = owner_.getEntityManager().getEntity(owner_.playerGuid)) {
if (auto pu = std::dynamic_pointer_cast<Unit>(pe))
playerPowerType = static_cast<int>(pu->getPowerType());
}
const char* reason = getSpellCastResultString(castResult, playerPowerType);
std::string errMsg = reason ? reason
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
owner_.addUIError(errMsg);
if (owner_.spellCastFailedCallback_) owner_.spellCastFailedCallback_(castResultSpellId);
owner_.fireAddonEvent("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)});
owner_.fireAddonEvent("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)});
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = errMsg;
owner_.addLocalChatMessage(msg);
}
}
}
void SpellHandler::handleSpellFailedOther(network::Packet& packet) {
const bool tbcLike2 = isPreWotlk();
uint64_t failOtherGuid = tbcLike2
? (packet.hasRemaining(8) ? packet.readUInt64() : 0)
: packet.readPackedGuid();
if (failOtherGuid != 0 && failOtherGuid != owner_.playerGuid) {
unitCastStates_.erase(failOtherGuid);
if (owner_.addonEventCallback_) {
std::string unitId;
if (failOtherGuid == owner_.targetGuid) unitId = "target";
else if (failOtherGuid == owner_.focusGuid) unitId = "focus";
if (!unitId.empty()) {
owner_.fireAddonEvent("UNIT_SPELLCAST_FAILED", {unitId});
owner_.fireAddonEvent("UNIT_SPELLCAST_STOP", {unitId});
}
}
}
packet.skipAll();
}
void SpellHandler::handleClearCooldown(network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t spellId = packet.readUInt32();
spellCooldowns_.erase(spellId);
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
slot.cooldownRemaining = 0.0f;
}
}
}
void SpellHandler::handleModifyCooldown(network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint32_t spellId = packet.readUInt32();
int32_t diffMs = static_cast<int32_t>(packet.readUInt32());
float diffSec = diffMs / 1000.0f;
auto it = spellCooldowns_.find(spellId);
if (it != spellCooldowns_.end()) {
it->second = std::max(0.0f, it->second + diffSec);
for (auto& slot : owner_.actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
slot.cooldownRemaining = std::max(0.0f, slot.cooldownRemaining + diffSec);
}
}
}
}
void SpellHandler::handlePlaySpellVisual(network::Packet& packet) {
if (!packet.hasRemaining(12)) return;
uint64_t casterGuid = packet.readUInt64();
uint32_t visualId = packet.readUInt32();
if (visualId == 0) return;
auto* renderer = owner_.services().renderer;
if (!renderer) return;
glm::vec3 spawnPos;
if (casterGuid == owner_.playerGuid) {
spawnPos = renderer->getCharacterPosition();
} else {
auto entity = owner_.getEntityManager().getEntity(casterGuid);
if (!entity) return;
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
spawnPos = core::coords::canonicalToRender(canonical);
}
renderer->playSpellVisual(visualId, spawnPos);
}
void SpellHandler::handleSpellModifier(network::Packet& packet, bool isFlat) {
auto& modMap = isFlat ? owner_.spellFlatMods_ : owner_.spellPctMods_;
while (packet.hasRemaining(6)) {
uint8_t groupIndex = packet.readUInt8();
uint8_t modOpRaw = packet.readUInt8();
int32_t value = static_cast<int32_t>(packet.readUInt32());
if (groupIndex > 5 || modOpRaw >= GameHandler::SPELL_MOD_OP_COUNT) continue;
GameHandler::SpellModKey key{ static_cast<GameHandler::SpellModOp>(modOpRaw), groupIndex };
modMap[key] = value;
}
packet.skipAll();
}
void SpellHandler::handleSpellDelayed(network::Packet& packet) {
const bool spellDelayTbcLike = isPreWotlk();
if (!packet.hasRemaining(spellDelayTbcLike ? 8u : 1u) ) return;
uint64_t caster = spellDelayTbcLike
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(4)) return;
uint32_t delayMs = packet.readUInt32();
if (delayMs == 0) return;
float delaySec = delayMs / 1000.0f;
if (caster == owner_.playerGuid) {
if (casting_) {
castTimeRemaining_ += delaySec;
castTimeTotal_ += delaySec;
}
} else {
auto it = unitCastStates_.find(caster);
if (it != unitCastStates_.end() && it->second.casting) {
it->second.timeRemaining += delaySec;
it->second.timeTotal += delaySec;
}
}
}
// ============================================================
// Extracted opcode handlers (from registerOpcodeHandlers)
// ============================================================
void SpellHandler::handleSpellLogMiss(network::Packet& packet) {
// All expansions: uint32 spellId first.
// WotLK/Classic: spellId(4) + packed_guid caster + uint8 unk + uint32 count
// + count × (packed_guid victim + uint8 missInfo)
// TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count
// + count × (uint64 victim + uint8 missInfo)
// All expansions append uint32 reflectSpellId + uint8 reflectResult when
// missInfo==11 (REFLECT).
const bool spellMissUsesFullGuid = isActiveExpansion("tbc");
auto readSpellMissGuid = [&]() -> uint64_t {
if (spellMissUsesFullGuid)
return (packet.hasRemaining(8)) ? packet.readUInt64() : 0;
return packet.readPackedGuid();
};
// spellId prefix present in all expansions
if (!packet.hasRemaining(4)) return;
uint32_t spellId = packet.readUInt32();
if (!packet.hasRemaining(spellMissUsesFullGuid ? 8u : 1u)
|| (!spellMissUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t casterGuid = readSpellMissGuid();
if (!packet.hasRemaining(5)) return;
/*uint8_t unk =*/ packet.readUInt8();
const uint32_t rawCount = packet.readUInt32();
if (rawCount > 128) {
LOG_WARNING("SMSG_SPELLLOGMISS: miss count capped (requested=", rawCount, ")");
}
const uint32_t storedLimit = std::min<uint32_t>(rawCount, 128u);
struct SpellMissLogEntry {
uint64_t victimGuid = 0;
uint8_t missInfo = 0;
uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT)
};
std::vector<SpellMissLogEntry> parsedMisses;
parsedMisses.reserve(storedLimit);
bool truncated = false;
for (uint32_t i = 0; i < rawCount; ++i) {
if (!packet.hasRemaining(spellMissUsesFullGuid ? 9u : 2u)
|| (!spellMissUsesFullGuid && !packet.hasFullPackedGuid())) {
truncated = true;
return;
}
const uint64_t victimGuid = readSpellMissGuid();
if (!packet.hasRemaining(1)) {
truncated = true;
return;
}
const uint8_t missInfo = packet.readUInt8();
// REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult
uint32_t reflectSpellId = 0;
if (missInfo == 11) {
if (packet.hasRemaining(5)) {
reflectSpellId = packet.readUInt32();
/*uint8_t reflectResult =*/ packet.readUInt8();
} else {
truncated = true;
return;
}
}
if (i < storedLimit) {
parsedMisses.push_back({victimGuid, missInfo, reflectSpellId});
}
}
if (truncated) {
packet.skipAll();
return;
}
for (const auto& miss : parsedMisses) {
const uint64_t victimGuid = miss.victimGuid;
const uint8_t missInfo = miss.missInfo;
CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo);
// For REFLECT, use the reflected spell ID so combat text shows the spell name
uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0)
? miss.reflectSpellId : spellId;
if (casterGuid == owner_.playerGuid) {
// We cast a spell and it missed the target
owner_.addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid);
} else if (victimGuid == owner_.playerGuid) {
// Enemy spell missed us (we dodged/parried/blocked/resisted/etc.)
owner_.addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid);
}
}
}
void SpellHandler::handleSpellFailure(network::Packet& packet) {
// WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 failReason
// TBC: full uint64 + uint8 castCount + uint32 spellId + uint8 failReason
// Classic: full uint64 + uint32 spellId + uint8 failReason (NO castCount)
const bool isClassic = isClassicLikeExpansion();
const bool isTbc = isActiveExpansion("tbc");
uint64_t failGuid = (isClassic || isTbc)
? (packet.hasRemaining(8) ? packet.readUInt64() : 0)
: packet.readPackedGuid();
// Classic omits the castCount byte; TBC and WotLK include it
const size_t remainingFields = isClassic ? 5u : 6u; // spellId(4)+reason(1) [+castCount(1)]
if (packet.hasRemaining(remainingFields)) {
if (!isClassic) /*uint8_t castCount =*/ packet.readUInt8();
uint32_t failSpellId = packet.readUInt32();
uint8_t rawFailReason = packet.readUInt8();
// Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table
uint8_t failReason = isClassic ? static_cast<uint8_t>(rawFailReason + 1) : rawFailReason;
if (failGuid == owner_.playerGuid && failReason != 0) {
// Show interruption/failure reason in chat and error overlay for player
int pt = -1;
if (auto pe = owner_.getEntityManager().getEntity(owner_.playerGuid))
if (auto pu = std::dynamic_pointer_cast<Unit>(pe))
pt = static_cast<int>(pu->getPowerType());
const char* reason = getSpellCastResultString(failReason, pt);
if (reason) {
// Prefix with spell name for context, e.g. "Fireball: Not in range"
const std::string& sName = owner_.getSpellName(failSpellId);
std::string fullMsg = sName.empty() ? reason
: sName + ": " + reason;
owner_.addUIError(fullMsg);
MessageChatData emsg;
emsg.type = ChatType::SYSTEM;
emsg.language = ChatLanguage::UNIVERSAL;
emsg.message = std::move(fullMsg);
owner_.addLocalChatMessage(emsg);
}
}
}
// Fire UNIT_SPELLCAST_INTERRUPTED for Lua addons
if (owner_.addonEventCallback_) {
auto unitId = (failGuid == 0) ? std::string("player") : owner_.guidToUnitId(failGuid);
if (!unitId.empty()) {
owner_.fireAddonEvent("UNIT_SPELLCAST_INTERRUPTED", {unitId});
owner_.fireAddonEvent("UNIT_SPELLCAST_STOP", {unitId});
}
}
if (failGuid == owner_.playerGuid || failGuid == 0) {
// Player's own cast failed — clear gather-node loot target so the
// next timed cast doesn't try to loot a stale interrupted gather node.
casting_ = false; castIsChannel_ = false; currentCastSpellId_ = 0;
owner_.lastInteractedGoGuid_ = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (auto* ac = owner_.services().audioCoordinator) {
if (auto* ssm = ac->getSpellSoundManager()) {
ssm->stopPrecast();
}
}
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(owner_.playerGuid, false, false);
}
} else {
// Another unit's cast failed — clear their tracked cast bar
unitCastStates_.erase(failGuid);
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(failGuid, false, false);
}
}
}
void SpellHandler::handleItemCooldown(network::Packet& packet) {
// uint64 itemGuid + uint32 spellId + uint32 cooldownMs
size_t rem = packet.getRemainingSize();
if (rem >= 16) {
uint64_t itemGuid = packet.readUInt64();
uint32_t spellId = packet.readUInt32();
uint32_t cdMs = packet.readUInt32();
float cdSec = cdMs / 1000.0f;
if (cdSec > 0.0f) {
if (spellId != 0) {
auto it = spellCooldowns_.find(spellId);
if (it == spellCooldowns_.end()) {
spellCooldowns_[spellId] = cdSec;
} else {
it->second = mergeCooldownSeconds(it->second, cdSec);
}
}
// Resolve itemId from the GUID so item-type slots are also updated
uint32_t itemId = 0;
auto iit = owner_.onlineItems_.find(itemGuid);
if (iit != owner_.onlineItems_.end()) itemId = iit->second.entry;
for (auto& slot : owner_.actionBar) {
bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|| (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId);
if (match) {
float prevRemaining = slot.cooldownRemaining;
float merged = mergeCooldownSeconds(slot.cooldownRemaining, cdSec);
slot.cooldownRemaining = merged;
if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) {
slot.cooldownTotal = cdSec;
} else {
slot.cooldownTotal = std::max(slot.cooldownTotal, merged);
}
}
}
LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec,
" spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s");
}
}
}
void SpellHandler::handleDispelFailed(network::Packet& packet) {
// WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim
// [+ count × uint32 failedSpellId]
// Classic: uint32 dispelSpellId + packed_guid caster + packed_guid victim
// [+ count × uint32 failedSpellId]
// TBC: uint64 caster + uint64 victim + uint32 spellId
// [+ count × uint32 failedSpellId]
const bool dispelUsesFullGuid = isActiveExpansion("tbc");
uint32_t dispelSpellId = 0;
uint64_t dispelCasterGuid = 0;
if (dispelUsesFullGuid) {
if (!packet.hasRemaining(20)) return;
dispelCasterGuid = packet.readUInt64();
/*uint64_t victim =*/ packet.readUInt64();
dispelSpellId = packet.readUInt32();
} else {
if (!packet.hasRemaining(4)) return;
dispelSpellId = packet.readUInt32();
if (!packet.hasFullPackedGuid()) {
packet.skipAll(); return;
}
dispelCasterGuid = packet.readPackedGuid();
if (!packet.hasFullPackedGuid()) {
packet.skipAll(); return;
}
/*uint64_t victim =*/ packet.readPackedGuid();
}
// Only show failure to the player who attempted the dispel
if (dispelCasterGuid == owner_.playerGuid) {
const auto& name = owner_.getSpellName(dispelSpellId);
char buf[128];
if (!name.empty())
std::snprintf(buf, sizeof(buf), "%s failed to dispel.", name.c_str());
else
std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", dispelSpellId);
owner_.addSystemChatMessage(buf);
}
}
void SpellHandler::handleTotemCreated(network::Packet& packet) {
// WotLK: uint8 slot + packed_guid + uint32 duration + uint32 spellId
// TBC/Classic: uint8 slot + uint64 guid + uint32 duration + uint32 spellId
const bool totemTbcLike = isPreWotlk();
if (!packet.hasRemaining(totemTbcLike ? 17u : 9u) ) return;
uint8_t slot = packet.readUInt8();
if (totemTbcLike)
/*uint64_t guid =*/ packet.readUInt64();
else
/*uint64_t guid =*/ packet.readPackedGuid();
if (!packet.hasRemaining(8)) return;
uint32_t duration = packet.readUInt32();
uint32_t spellId = packet.readUInt32();
LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", static_cast<int>(slot),
" spellId=", spellId, " duration=", duration, "ms");
if (slot < GameHandler::NUM_TOTEM_SLOTS) {
owner_.activeTotemSlots_[slot].spellId = spellId;
owner_.activeTotemSlots_[slot].durationMs = duration;
owner_.activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now();
}
}
void SpellHandler::handlePeriodicAuraLog(network::Packet& packet) {
// WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects
// TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects
// Classic/Vanilla: packed_guid (same as WotLK)
const bool periodicTbc = isActiveExpansion("tbc");
const size_t guidMinSz = periodicTbc ? 8u : 2u;
if (!packet.hasRemaining(guidMinSz)) return;
uint64_t victimGuid = periodicTbc
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(guidMinSz)) return;
uint64_t casterGuid = periodicTbc
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(8)) return;
uint32_t spellId = packet.readUInt32();
uint32_t count = packet.readUInt32();
bool isPlayerVictim = (victimGuid == owner_.playerGuid);
bool isPlayerCaster = (casterGuid == owner_.playerGuid);
if (!isPlayerVictim && !isPlayerCaster) {
packet.skipAll();
return;
}
for (uint32_t i = 0; i < count && packet.hasRemaining(1); ++i) {
uint8_t auraType = packet.readUInt8();
if (auraType == 3 || auraType == 89) {
// Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes
// WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes
const bool periodicWotlk = isActiveExpansion("wotlk");
const size_t dotSz = periodicWotlk ? 21u : 16u;
if (!packet.hasRemaining(dotSz)) break;
uint32_t dmg = packet.readUInt32();
if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32();
/*uint32_t school=*/ packet.readUInt32();
uint32_t abs = packet.readUInt32();
uint32_t res = packet.readUInt32();
bool dotCrit = false;
if (periodicWotlk) dotCrit = (packet.readUInt8() != 0);
if (dmg > 0)
owner_.addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE,
static_cast<int32_t>(dmg),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (abs > 0)
owner_.addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(abs),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (res > 0)
owner_.addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(res),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
} else if (auraType == 8 || auraType == 124 || auraType == 45) {
// Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes
// WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes
const bool healWotlk = isActiveExpansion("wotlk");
const size_t hotSz = healWotlk ? 17u : 12u;
if (!packet.hasRemaining(hotSz)) break;
uint32_t heal = packet.readUInt32();
/*uint32_t max=*/ packet.readUInt32();
/*uint32_t over=*/ packet.readUInt32();
uint32_t hotAbs = 0;
bool hotCrit = false;
if (healWotlk) {
hotAbs = packet.readUInt32();
hotCrit = (packet.readUInt8() != 0);
}
owner_.addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL,
static_cast<int32_t>(heal),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (hotAbs > 0)
owner_.addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(hotAbs),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
} else if (auraType == 46 || auraType == 91) {
// OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount
// Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc.
if (!packet.hasRemaining(8)) break;
uint8_t periodicPowerType = static_cast<uint8_t>(packet.readUInt32());
uint32_t amount = packet.readUInt32();
if ((isPlayerVictim || isPlayerCaster) && amount > 0)
owner_.addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(amount),
spellId, isPlayerCaster, periodicPowerType, casterGuid, victimGuid);
} else if (auraType == 98) {
// PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier
if (!packet.hasRemaining(12)) break;
uint8_t powerType = static_cast<uint8_t>(packet.readUInt32());
uint32_t amount = packet.readUInt32();
float multiplier = packet.readFloat();
if (isPlayerVictim && amount > 0)
owner_.addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(amount),
spellId, false, powerType, casterGuid, victimGuid);
if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) {
const uint32_t gainedAmount = static_cast<uint32_t>(
std::lround(static_cast<double>(amount) * static_cast<double>(multiplier)));
if (gainedAmount > 0) {
owner_.addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(gainedAmount),
spellId, true, powerType, casterGuid, casterGuid);
}
}
} else {
// Unknown/untracked aura type — stop parsing this event safely
packet.skipAll();
break;
}
}
packet.skipAll();
}
void SpellHandler::handleSpellEnergizeLog(network::Packet& packet) {
// WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint8 powerType + int32 amount
// TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount
// Classic/Vanilla: packed_guid (same as WotLK)
const bool energizeTbc = isActiveExpansion("tbc");
auto readEnergizeGuid = [&]() -> uint64_t {
if (energizeTbc)
return (packet.hasRemaining(8)) ? packet.readUInt64() : 0;
return packet.readPackedGuid();
};
if (!packet.hasRemaining(energizeTbc ? 8u : 1u)
|| (!energizeTbc && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t victimGuid = readEnergizeGuid();
if (!packet.hasRemaining(energizeTbc ? 8u : 1u)
|| (!energizeTbc && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t casterGuid = readEnergizeGuid();
if (!packet.hasRemaining(9)) {
packet.skipAll(); return;
}
uint32_t spellId = packet.readUInt32();
uint8_t energizePowerType = packet.readUInt8();
int32_t amount = static_cast<int32_t>(packet.readUInt32());
bool isPlayerVictim = (victimGuid == owner_.playerGuid);
bool isPlayerCaster = (casterGuid == owner_.playerGuid);
if ((isPlayerVictim || isPlayerCaster) && amount > 0)
owner_.addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType, casterGuid, victimGuid);
packet.skipAll();
}
void SpellHandler::handleExtraAuraInfo(network::Packet& packet, bool isInit) {
// TBC 2.4.3 aura tracking: replaces SMSG_AURA_UPDATE which doesn't exist in TBC.
// Format: uint64 targetGuid + uint8 count + N×{uint8 slot, uint32 spellId,
// uint8 effectIndex, uint8 flags, uint32 durationMs, uint32 maxDurationMs}
auto remaining = [&]() { return packet.getRemainingSize(); };
if (remaining() < 9) { packet.skipAll(); return; }
uint64_t auraTargetGuid = packet.readUInt64();
uint8_t count = packet.readUInt8();
std::vector<AuraSlot>* auraList = nullptr;
if (auraTargetGuid == owner_.playerGuid) auraList = &playerAuras_;
else if (auraTargetGuid == owner_.targetGuid) auraList = &targetAuras_;
else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid];
if (auraList && isInit) auraList->clear();
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (uint8_t i = 0; i < count && remaining() >= 15; i++) {
uint8_t slot = packet.readUInt8(); // 1 byte
uint32_t spellId = packet.readUInt32(); // 4 bytes
(void) packet.readUInt8(); // effectIndex: 1 byte (unused for slot display)
uint8_t flags = packet.readUInt8(); // 1 byte
uint32_t durationMs = packet.readUInt32(); // 4 bytes
uint32_t maxDurMs = packet.readUInt32(); // 4 bytes — total 15 bytes per entry
if (auraList) {
while (auraList->size() <= slot) auraList->push_back(AuraSlot{});
AuraSlot& a = (*auraList)[slot];
a.spellId = spellId;
// TBC uses same flag convention as Classic: 0x02=harmful, 0x04=beneficial.
// Normalize to WotLK SMSG_AURA_UPDATE convention: 0x80=debuff, 0=buff.
a.flags = (flags & 0x02) ? 0x80u : 0u;
a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast<int32_t>(durationMs);
a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast<int32_t>(maxDurMs);
a.receivedAtMs = nowMs;
}
}
packet.skipAll();
}
void SpellHandler::handleSpellDispelLog(network::Packet& packet) {
// WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen
// TBC: full uint64 casterGuid + full uint64 victimGuid + ...
// + uint32 count + count × (uint32 dispelled_spellId + uint32 unk)
const bool dispelUsesFullGuid = isActiveExpansion("tbc");
if (!packet.hasRemaining(dispelUsesFullGuid ? 8u : 1u)
|| (!dispelUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t casterGuid = dispelUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(dispelUsesFullGuid ? 8u : 1u)
|| (!dispelUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t victimGuid = dispelUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(9)) return;
/*uint32_t dispelSpell =*/ packet.readUInt32();
uint8_t isStolen = packet.readUInt8();
uint32_t count = packet.readUInt32();
// Preserve every dispelled aura in the combat log instead of collapsing
// multi-aura packets down to the first entry only.
const size_t dispelEntrySize = dispelUsesFullGuid ? 8u : 5u;
std::vector<uint32_t> dispelledIds;
dispelledIds.reserve(count);
for (uint32_t i = 0; i < count && packet.hasRemaining(dispelEntrySize); ++i) {
uint32_t dispelledId = packet.readUInt32();
if (dispelUsesFullGuid) {
/*uint32_t unk =*/ packet.readUInt32();
} else {
/*uint8_t isPositive =*/ packet.readUInt8();
}
if (dispelledId != 0) {
dispelledIds.push_back(dispelledId);
}
}
// Show system message if player was victim or caster
if (victimGuid == owner_.playerGuid || casterGuid == owner_.playerGuid) {
std::vector<uint32_t> loggedIds;
if (isStolen) {
loggedIds.reserve(dispelledIds.size());
for (uint32_t dispelledId : dispelledIds) {
if (owner_.shouldLogSpellstealAura(casterGuid, victimGuid, dispelledId))
loggedIds.push_back(dispelledId);
}
} else {
loggedIds = dispelledIds;
}
const std::string displaySpellNames = formatSpellNameList(owner_, loggedIds);
if (!displaySpellNames.empty()) {
char buf[256];
const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were";
if (isStolen) {
if (victimGuid == owner_.playerGuid && casterGuid != owner_.playerGuid)
std::snprintf(buf, sizeof(buf), "%s %s stolen.",
displaySpellNames.c_str(), passiveVerb);
else if (casterGuid == owner_.playerGuid)
std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str());
else
std::snprintf(buf, sizeof(buf), "%s %s stolen.",
displaySpellNames.c_str(), passiveVerb);
} else {
if (victimGuid == owner_.playerGuid && casterGuid != owner_.playerGuid)
std::snprintf(buf, sizeof(buf), "%s %s dispelled.",
displaySpellNames.c_str(), passiveVerb);
else if (casterGuid == owner_.playerGuid)
std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str());
else
std::snprintf(buf, sizeof(buf), "%s %s dispelled.",
displaySpellNames.c_str(), passiveVerb);
}
owner_.addSystemChatMessage(buf);
}
// Preserve stolen auras as spellsteal events so the log wording stays accurate.
if (!loggedIds.empty()) {
bool isPlayerCaster = (casterGuid == owner_.playerGuid);
for (uint32_t dispelledId : loggedIds) {
owner_.addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL,
0, dispelledId, isPlayerCaster, 0,
casterGuid, victimGuid);
}
}
}
packet.skipAll();
}
void SpellHandler::handleSpellStealLog(network::Packet& packet) {
// Sent to the CASTER (Mage) when Spellsteal succeeds.
// Wire format mirrors SPELLDISPELLOG:
// WotLK/Classic/Turtle: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count
// + count × (uint32 stolenSpellId + uint8 isPositive)
// TBC: full uint64 victim + full uint64 caster + same tail
const bool stealUsesFullGuid = isActiveExpansion("tbc");
if (!packet.hasRemaining(stealUsesFullGuid ? 8u : 1u)
|| (!stealUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t stealVictim = stealUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(stealUsesFullGuid ? 8u : 1u)
|| (!stealUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t stealCaster = stealUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(9)) {
packet.skipAll(); return;
}
/*uint32_t stealSpellId =*/ packet.readUInt32();
/*uint8_t isStolen =*/ packet.readUInt8();
uint32_t stealCount = packet.readUInt32();
// Preserve every stolen aura in the combat log instead of only the first.
const size_t stealEntrySize = stealUsesFullGuid ? 8u : 5u;
std::vector<uint32_t> stolenIds;
stolenIds.reserve(stealCount);
for (uint32_t i = 0; i < stealCount && packet.hasRemaining(stealEntrySize); ++i) {
uint32_t stolenId = packet.readUInt32();
if (stealUsesFullGuid) {
/*uint32_t unk =*/ packet.readUInt32();
} else {
/*uint8_t isPos =*/ packet.readUInt8();
}
if (stolenId != 0) {
stolenIds.push_back(stolenId);
}
}
if (stealCaster == owner_.playerGuid || stealVictim == owner_.playerGuid) {
std::vector<uint32_t> loggedIds;
loggedIds.reserve(stolenIds.size());
for (uint32_t stolenId : stolenIds) {
if (owner_.shouldLogSpellstealAura(stealCaster, stealVictim, stolenId))
loggedIds.push_back(stolenId);
}
const std::string stealDisplayNames = formatSpellNameList(owner_, loggedIds);
if (!stealDisplayNames.empty()) {
char buf[256];
if (stealCaster == owner_.playerGuid)
std::snprintf(buf, sizeof(buf), "You stole %s.", stealDisplayNames.c_str());
else
std::snprintf(buf, sizeof(buf), "%s %s stolen.", stealDisplayNames.c_str(),
loggedIds.size() == 1 ? "was" : "were");
owner_.addSystemChatMessage(buf);
}
// Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG
// for the same aura. Keep the first event and suppress the duplicate.
if (!loggedIds.empty()) {
bool isPlayerCaster = (stealCaster == owner_.playerGuid);
for (uint32_t stolenId : loggedIds) {
owner_.addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0,
stealCaster, stealVictim);
}
}
}
packet.skipAll();
}
void SpellHandler::handleSpellChanceProcLog(network::Packet& packet) {
// WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ...
// TBC: uint64 target + uint64 caster + uint32 spellId + ...
const bool procChanceUsesFullGuid = isActiveExpansion("tbc");
auto readProcChanceGuid = [&]() -> uint64_t {
if (procChanceUsesFullGuid)
return (packet.hasRemaining(8)) ? packet.readUInt64() : 0;
return packet.readPackedGuid();
};
if (!packet.hasRemaining(procChanceUsesFullGuid ? 8u : 1u)
|| (!procChanceUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t procTargetGuid = readProcChanceGuid();
if (!packet.hasRemaining(procChanceUsesFullGuid ? 8u : 1u)
|| (!procChanceUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t procCasterGuid = readProcChanceGuid();
if (!packet.hasRemaining(4)) {
packet.skipAll(); return;
}
uint32_t procSpellId = packet.readUInt32();
// Show a "PROC!" floating text when the player triggers the proc
if (procCasterGuid == owner_.playerGuid && procSpellId > 0)
owner_.addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0,
procCasterGuid, procTargetGuid);
packet.skipAll();
}
void SpellHandler::handleSpellInstaKillLog(network::Packet& packet) {
// Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.)
// WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId
// TBC: full uint64 caster + full uint64 victim + uint32 spellId
const bool ikUsesFullGuid = isActiveExpansion("tbc");
auto ik_rem = [&]() { return packet.getRemainingSize(); };
if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)
|| (!ikUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t ikCaster = ikUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)
|| (!ikUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); return;
}
uint64_t ikVictim = ikUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (ik_rem() < 4) {
packet.skipAll(); return;
}
uint32_t ikSpell = packet.readUInt32();
// Show kill/death feedback for the local player
if (ikCaster == owner_.playerGuid) {
owner_.addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim);
} else if (ikVictim == owner_.playerGuid) {
owner_.addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim);
owner_.addUIError("You were killed by an instant-kill effect.");
owner_.addSystemChatMessage("You were killed by an instant-kill effect.");
}
LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster,
" victim=0x", ikVictim, std::dec, " spell=", ikSpell);
packet.skipAll();
}
void SpellHandler::handleSpellLogExecute(network::Packet& packet) {
// WotLK/Classic/Turtle: packed_guid caster + uint32 spellId + uint32 effectCount
// TBC: uint64 caster + uint32 spellId + uint32 effectCount
// Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data
// Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier
// Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier
// Effect 24 = CREATE_ITEM: uint32 itemEntry
// Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id
// Effect 49 = FEED_PET: uint32 itemEntry
// Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM)
const bool exeUsesFullGuid = isActiveExpansion("tbc");
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u) ) {
packet.skipAll(); return;
}
if (!exeUsesFullGuid && !packet.hasFullPackedGuid()) {
packet.skipAll(); return;
}
uint64_t exeCaster = exeUsesFullGuid
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(8)) {
packet.skipAll(); return;
}
uint32_t exeSpellId = packet.readUInt32();
uint32_t exeEffectCount = packet.readUInt32();
exeEffectCount = std::min(exeEffectCount, 32u); // sanity
const bool isPlayerCaster = (exeCaster == owner_.playerGuid);
for (uint32_t ei = 0; ei < exeEffectCount; ++ei) {
if (!packet.hasRemaining(5)) break;
uint8_t effectType = packet.readUInt8();
uint32_t effectLogCount = packet.readUInt32();
effectLogCount = std::min(effectLogCount, 64u); // sanity
if (effectType == 10) {
// SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u)
|| (!exeUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); break;
}
uint64_t drainTarget = exeUsesFullGuid
? packet.readUInt64()
: packet.readPackedGuid();
if (!packet.hasRemaining(12)) { packet.skipAll(); break; }
uint32_t drainAmount = packet.readUInt32();
uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic
float drainMult = packet.readFloat();
if (drainAmount > 0) {
if (drainTarget == owner_.playerGuid)
owner_.addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(drainAmount), exeSpellId, false,
static_cast<uint8_t>(drainPower),
exeCaster, drainTarget);
if (isPlayerCaster) {
if (drainTarget != owner_.playerGuid) {
owner_.addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(drainAmount), exeSpellId, true,
static_cast<uint8_t>(drainPower), exeCaster, drainTarget);
}
if (drainMult > 0.0f && std::isfinite(drainMult)) {
const uint32_t gainedAmount = static_cast<uint32_t>(
std::lround(static_cast<double>(drainAmount) * static_cast<double>(drainMult)));
if (gainedAmount > 0) {
owner_.addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(gainedAmount), exeSpellId, true,
static_cast<uint8_t>(drainPower), exeCaster, exeCaster);
}
}
}
}
LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId,
" power=", drainPower, " amount=", drainAmount,
" multiplier=", drainMult);
}
} else if (effectType == 11) {
// SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u)
|| (!exeUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); break;
}
uint64_t leechTarget = exeUsesFullGuid
? packet.readUInt64()
: packet.readPackedGuid();
if (!packet.hasRemaining(8)) { packet.skipAll(); break; }
uint32_t leechAmount = packet.readUInt32();
float leechMult = packet.readFloat();
if (leechAmount > 0) {
if (leechTarget == owner_.playerGuid) {
owner_.addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast<int32_t>(leechAmount), exeSpellId, false, 0,
exeCaster, leechTarget);
} else if (isPlayerCaster) {
owner_.addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast<int32_t>(leechAmount), exeSpellId, true, 0,
exeCaster, leechTarget);
}
if (isPlayerCaster && leechMult > 0.0f && std::isfinite(leechMult)) {
const uint32_t gainedAmount = static_cast<uint32_t>(
std::lround(static_cast<double>(leechAmount) * static_cast<double>(leechMult)));
if (gainedAmount > 0) {
owner_.addCombatText(CombatTextEntry::HEAL, static_cast<int32_t>(gainedAmount), exeSpellId, true, 0,
exeCaster, exeCaster);
}
}
}
LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId,
" amount=", leechAmount, " multiplier=", leechMult);
}
} else if (effectType == 24 || effectType == 114) {
// SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(4)) break;
uint32_t itemEntry = packet.readUInt32();
if (isPlayerCaster && itemEntry != 0) {
owner_.ensureItemInfo(itemEntry);
const ItemQueryResponseData* info = owner_.getItemInfo(itemEntry);
std::string itemName = info && !info->name.empty()
? info->name : ("item #" + std::to_string(itemEntry));
const auto& spellName = owner_.getSpellName(exeSpellId);
std::string msg = spellName.empty()
? ("You create: " + itemName + ".")
: ("You create " + itemName + " using " + spellName + ".");
owner_.addSystemChatMessage(msg);
LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId,
" item=", itemEntry, " name=", itemName);
// Repeat-craft queue: re-cast if more crafts remaining
if (craftQueueRemaining_ > 0 && craftQueueSpellId_ == exeSpellId) {
--craftQueueRemaining_;
if (craftQueueRemaining_ > 0) {
castSpell(craftQueueSpellId_, 0);
} else {
craftQueueSpellId_ = 0;
}
}
}
}
} else if (effectType == 26) {
// SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(exeUsesFullGuid ? 8u : 1u)
|| (!exeUsesFullGuid && !packet.hasFullPackedGuid())) {
packet.skipAll(); break;
}
uint64_t icTarget = exeUsesFullGuid
? packet.readUInt64()
: packet.readPackedGuid();
if (!packet.hasRemaining(4)) { packet.skipAll(); break; }
uint32_t icSpellId = packet.readUInt32();
// Clear the interrupted unit's cast bar immediately
unitCastStates_.erase(icTarget);
// Record interrupt in combat log when player is involved
if (isPlayerCaster || icTarget == owner_.playerGuid)
owner_.addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0,
exeCaster, icTarget);
LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId,
" interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec);
}
} else if (effectType == 49) {
// SPELL_EFFECT_FEED_PET: uint32 itemEntry per log entry
for (uint32_t li = 0; li < effectLogCount; ++li) {
if (!packet.hasRemaining(4)) break;
uint32_t feedItem = packet.readUInt32();
if (isPlayerCaster && feedItem != 0) {
owner_.ensureItemInfo(feedItem);
const ItemQueryResponseData* info = owner_.getItemInfo(feedItem);
std::string itemName = info && !info->name.empty()
? info->name : ("item #" + std::to_string(feedItem));
uint32_t feedQuality = info ? info->quality : 1u;
owner_.addSystemChatMessage("You feed your pet " + buildItemLink(feedItem, feedQuality, itemName) + ".");
LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName);
}
}
} else {
// Unknown effect type — stop parsing to avoid misalignment
packet.skipAll();
break;
}
}
packet.skipAll();
}
void SpellHandler::handleClearExtraAuraInfo(network::Packet& packet) {
// TBC 2.4.3: clear a single aura slot for a unit
// Format: uint64 targetGuid + uint8 slot
if (packet.hasRemaining(9)) {
uint64_t clearGuid = packet.readUInt64();
uint8_t slot = packet.readUInt8();
std::vector<AuraSlot>* auraList = nullptr;
if (clearGuid == owner_.playerGuid) auraList = &playerAuras_;
else if (clearGuid == owner_.targetGuid) auraList = &targetAuras_;
if (auraList && slot < auraList->size()) {
(*auraList)[slot] = AuraSlot{};
}
}
packet.skipAll();
}
void SpellHandler::handleItemEnchantTimeUpdate(network::Packet& packet) {
// Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid
// slot: 0=main-hand, 1=off-hand, 2=ranged
if (!packet.hasRemaining(24)) {
packet.skipAll(); return;
}
/*uint64_t itemGuid =*/ packet.readUInt64();
uint32_t enchSlot = packet.readUInt32();
uint32_t durationSec = packet.readUInt32();
/*uint64_t playerGuid =*/ packet.readUInt64();
// Clamp to known slots (0-2)
if (enchSlot > 2) { return; }
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
if (durationSec == 0) {
// Enchant expired / removed — erase the slot entry
owner_.tempEnchantTimers_.erase(
std::remove_if(owner_.tempEnchantTimers_.begin(), owner_.tempEnchantTimers_.end(),
[enchSlot](const GameHandler::TempEnchantTimer& t) { return t.slot == enchSlot; }),
owner_.tempEnchantTimers_.end());
} else {
uint64_t expireMs = nowMs + static_cast<uint64_t>(durationSec) * 1000u;
bool found = false;
for (auto& t : owner_.tempEnchantTimers_) {
if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; }
}
if (!found) owner_.tempEnchantTimers_.push_back({enchSlot, expireMs});
// Warn at important thresholds
if (durationSec <= 60 && durationSec > 55) {
const char* slotName = (enchSlot < 3) ? owner_.kTempEnchantSlotNames[enchSlot] : "weapon";
char buf[80];
std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName);
owner_.addSystemChatMessage(buf);
} else if (durationSec <= 300 && durationSec > 295) {
const char* slotName = (enchSlot < 3) ? owner_.kTempEnchantSlotNames[enchSlot] : "weapon";
char buf[80];
std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName);
owner_.addSystemChatMessage(buf);
}
}
LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s");
}
void SpellHandler::handleResumeCastBar(network::Packet& packet) {
// WotLK: packed_guid caster + packed_guid target + uint32 spellId + uint32 remainingMs + uint32 totalMs + uint8 schoolMask
// TBC/Classic: uint64 caster + uint64 target + ...
const bool rcbTbc = isPreWotlk();
auto remaining = [&]() { return packet.getRemainingSize(); };
if (remaining() < (rcbTbc ? 8u : 1u)) return;
uint64_t caster = rcbTbc
? packet.readUInt64() : packet.readPackedGuid();
if (remaining() < (rcbTbc ? 8u : 1u)) return;
if (rcbTbc) packet.readUInt64(); // target (discard)
else (void)packet.readPackedGuid(); // target
if (remaining() < 12) return;
uint32_t spellId = packet.readUInt32();
uint32_t remainMs = packet.readUInt32();
uint32_t totalMs = packet.readUInt32();
if (totalMs > 0) {
if (caster == owner_.playerGuid) {
casting_ = true;
castIsChannel_ = false;
currentCastSpellId_ = spellId;
castTimeTotal_ = totalMs / 1000.0f;
castTimeRemaining_ = remainMs / 1000.0f;
} else {
auto& s = unitCastStates_[caster];
s.casting = true;
s.spellId = spellId;
s.timeTotal = totalMs / 1000.0f;
s.timeRemaining = remainMs / 1000.0f;
}
LOG_DEBUG("SMSG_RESUME_CAST_BAR: caster=0x", std::hex, caster, std::dec,
" spell=", spellId, " remaining=", remainMs, "ms total=", totalMs, "ms");
}
}
void SpellHandler::handleChannelStart(network::Packet& packet) {
// casterGuid + uint32 spellId + uint32 totalDurationMs
const bool tbcOrClassic = isPreWotlk();
uint64_t chanCaster = tbcOrClassic
? (packet.hasRemaining(8) ? packet.readUInt64() : 0)
: packet.readPackedGuid();
if (!packet.hasRemaining(8)) return;
uint32_t chanSpellId = packet.readUInt32();
uint32_t chanTotalMs = packet.readUInt32();
if (chanTotalMs > 0 && chanCaster != 0) {
if (chanCaster == owner_.playerGuid) {
casting_ = true;
castIsChannel_ = true;
currentCastSpellId_ = chanSpellId;
castTimeTotal_ = chanTotalMs / 1000.0f;
castTimeRemaining_ = castTimeTotal_;
} else {
auto& s = unitCastStates_[chanCaster];
s.casting = true;
s.isChannel = true;
s.spellId = chanSpellId;
s.timeTotal = chanTotalMs / 1000.0f;
s.timeRemaining = s.timeTotal;
s.interruptible = owner_.isSpellInterruptible(chanSpellId);
}
LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec,
" spell=", chanSpellId, " total=", chanTotalMs, "ms");
// Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons
if (owner_.addonEventCallback_) {
auto unitId = owner_.guidToUnitId(chanCaster);
if (!unitId.empty())
owner_.fireAddonEvent("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)});
}
}
}
void SpellHandler::handleChannelUpdate(network::Packet& packet) {
// casterGuid + uint32 remainingMs
const bool tbcOrClassic2 = isPreWotlk();
uint64_t chanCaster2 = tbcOrClassic2
? (packet.hasRemaining(8) ? packet.readUInt64() : 0)
: packet.readPackedGuid();
if (!packet.hasRemaining(4)) return;
uint32_t chanRemainMs = packet.readUInt32();
if (chanCaster2 == owner_.playerGuid) {
castTimeRemaining_ = chanRemainMs / 1000.0f;
if (chanRemainMs == 0) {
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
}
} else if (chanCaster2 != 0) {
auto it = unitCastStates_.find(chanCaster2);
if (it != unitCastStates_.end()) {
it->second.timeRemaining = chanRemainMs / 1000.0f;
if (chanRemainMs == 0) unitCastStates_.erase(it);
}
}
LOG_DEBUG("MSG_CHANNEL_UPDATE: caster=0x", std::hex, chanCaster2, std::dec,
" remaining=", chanRemainMs, "ms");
// Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends
if (chanRemainMs == 0) {
auto unitId = owner_.guidToUnitId(chanCaster2);
if (!unitId.empty())
owner_.fireAddonEvent("UNIT_SPELLCAST_CHANNEL_STOP", {unitId});
}
}
// ============================================================
// Pet Stable
// ============================================================
void SpellHandler::requestStabledPetList() {
if (owner_.state != WorldState::IN_WORLD || !owner_.socket || owner_.stableMasterGuid_ == 0) return;
auto pkt = ListStabledPetsPacket::build(owner_.stableMasterGuid_);
owner_.socket->send(pkt);
LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, owner_.stableMasterGuid_, std::dec);
}
void SpellHandler::stablePet(uint8_t slot) {
if (owner_.state != WorldState::IN_WORLD || !owner_.socket || owner_.stableMasterGuid_ == 0) return;
if (owner_.petGuid_ == 0) {
owner_.addSystemChatMessage("You do not have an active pet to stable.");
return;
}
auto pkt = StablePetPacket::build(owner_.stableMasterGuid_, slot);
owner_.socket->send(pkt);
LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast<int>(slot));
}
void SpellHandler::unstablePet(uint32_t petNumber) {
if (owner_.state != WorldState::IN_WORLD || !owner_.socket || owner_.stableMasterGuid_ == 0 || petNumber == 0) return;
auto pkt = UnstablePetPacket::build(owner_.stableMasterGuid_, petNumber);
owner_.socket->send(pkt);
LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber);
}
} // namespace game
} // namespace wowee