From f5757aca836d46719b4c8138b96c6df52286295c Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 29 Mar 2026 08:21:27 +0300 Subject: [PATCH] refactor(game): extract EntityController from GameHandler (step 1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves entity lifecycle, name/creature/game-object caches, transport GUID tracking, and the entire update-object pipeline out of GameHandler into a new EntityController class (friend-class pattern, same as CombatHandler et al.). What moved: - applyUpdateObjectBlock() — 1,520-line core of all entity creation, field updates, and movement application - processOutOfRangeObjects() / finalizeUpdateObjectBatch() - handleUpdateObject() / handleCompressedUpdateObject() / handleDestroyObject() - handleNameQueryResponse() / handleCreatureQueryResponse() - handleGameObjectQueryResponse() / handleGameObjectPageText() - handlePageTextQueryResponse() - enqueueUpdateObjectWork() / processPendingUpdateObjectWork() - playerNameCache, playerClassRaceCache_, pendingNameQueries - creatureInfoCache, pendingCreatureQueries - gameObjectInfoCache_, pendingGameObjectQueries_ - transportGuids_, serverUpdatedTransportGuids_ - EntityManager (accessed by other handlers via getEntityManager()) 8 opcodes re-registered by EntityController::registerOpcodes(): SMSG_UPDATE_OBJECT, SMSG_COMPRESSED_UPDATE_OBJECT, SMSG_DESTROY_OBJECT, SMSG_NAME_QUERY_RESPONSE, SMSG_CREATURE_QUERY_RESPONSE, SMSG_GAMEOBJECT_QUERY_RESPONSE, SMSG_GAMEOBJECT_PAGETEXT, SMSG_PAGE_TEXT_QUERY_RESPONSE Other handler files (combat, movement, social, spell, inventory, quest, chat) updated to access EntityManager via getEntityManager() and the name cache via getPlayerNameCache() — no logic changes. Also included: - .clang-tidy: add modernize-use-nodiscard, modernize-use-designated-initializers; set -std=c++20 in ExtraArgs - test.sh: prepend clang's own resource include dir before GCC's to silence xmmintrin.h / ia32intrin.h conflicts during clang-tidy runs Line counts: entity_controller.hpp 147 lines (new) entity_controller.cpp 2172 lines (new) game_handler.cpp 8095 lines (was 10143, −2048) Build: 0 errors, 0 warnings. --- .clang-tidy | 3 + CMakeLists.txt | 1 + include/game/entity_controller.hpp | 147 ++ include/game/game_handler.hpp | 104 +- include/game/spell_handler.hpp | 1 + src/game/chat_handler.cpp | 18 +- src/game/combat_handler.cpp | 34 +- src/game/entity_controller.cpp | 2172 ++++++++++++++++++++++++++++ src/game/game_handler.cpp | 2138 +-------------------------- src/game/inventory_handler.cpp | 16 +- src/game/movement_handler.cpp | 22 +- src/game/quest_handler.cpp | 12 +- src/game/social_handler.cpp | 58 +- src/game/spell_handler.cpp | 20 +- test.sh | 11 + 15 files changed, 2497 insertions(+), 2260 deletions(-) create mode 100644 include/game/entity_controller.hpp create mode 100644 src/game/entity_controller.cpp diff --git a/.clang-tidy b/.clang-tidy index da3e4cff..d35b8333 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -16,6 +16,8 @@ Checks: > modernize-deprecated-headers, modernize-make-unique, modernize-make-shared, + modernize-use-nodiscard, + modernize-use-designated-initializers, readability-braces-around-statements, readability-container-size-empty, readability-delete-null-pointer, @@ -35,6 +37,7 @@ WarningsAsErrors: '' # Suppress the noise from GCC-only LTO flags in compile_commands.json. # clang doesn't support -fno-fat-lto-objects; this silences the harmless warning. ExtraArgs: + - -std=c++20 - -Wno-ignored-optimization-argument HeaderFilterRegex: '^.*/include/.*\.hpp$' diff --git a/CMakeLists.txt b/CMakeLists.txt index baae6535..5489cbe3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -457,6 +457,7 @@ set(WOWEE_SOURCES src/game/inventory_handler.cpp src/game/social_handler.cpp src/game/quest_handler.cpp + src/game/entity_controller.cpp src/game/warden_handler.cpp src/game/warden_crypto.cpp src/game/warden_module.cpp diff --git a/include/game/entity_controller.hpp b/include/game/entity_controller.hpp new file mode 100644 index 00000000..5c8c2031 --- /dev/null +++ b/include/game/entity_controller.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include "game/world_packets.hpp" +#include "game/entity.hpp" +#include "game/opcode_table.hpp" +#include "network/packet.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { + +class GameHandler; + +class EntityController { +public: + using PacketHandler = std::function; + using DispatchTable = std::unordered_map; + + explicit EntityController(GameHandler& owner); + + void registerOpcodes(DispatchTable& table); + + // --- Entity Manager access --- + EntityManager& getEntityManager() { return entityManager; } + const EntityManager& getEntityManager() const { return entityManager; } + + // --- Name / info cache queries --- + void queryPlayerName(uint64_t guid); + void queryCreatureInfo(uint32_t entry, uint64_t guid); + void queryGameObjectInfo(uint32_t entry, uint64_t guid); + std::string getCachedPlayerName(uint64_t guid) const; + std::string getCachedCreatureName(uint32_t entry) const; + void invalidatePlayerName(uint64_t guid) { playerNameCache.erase(guid); } + + // Read-only cache access for other handlers + const std::unordered_map& getPlayerNameCache() const { return playerNameCache; } + const std::unordered_map& getCreatureInfoCache() const { return creatureInfoCache; } + std::string getCachedCreatureSubName(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.subName : ""; + } + int getCreatureRank(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? static_cast(it->second.rank) : -1; + } + uint32_t getCreatureType(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.creatureType : 0; + } + uint32_t getCreatureFamily(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.family : 0; + } + const GameObjectQueryResponseData* getCachedGameObjectInfo(uint32_t entry) const { + auto it = gameObjectInfoCache_.find(entry); + return (it != gameObjectInfoCache_.end()) ? &it->second : nullptr; + } + + // Name lookup (checks cache then entity manager) + const std::string& lookupName(uint64_t guid) const { + static const std::string kEmpty; + auto it = playerNameCache.find(guid); + if (it != playerNameCache.end()) return it->second; + auto entity = entityManager.getEntity(guid); + if (entity) { + if (auto* unit = dynamic_cast(entity.get())) { + if (!unit->getName().empty()) return unit->getName(); + } + } + return kEmpty; + } + uint8_t lookupPlayerClass(uint64_t guid) const { + auto it = playerClassRaceCache_.find(guid); + return it != playerClassRaceCache_.end() ? it->second.classId : 0; + } + uint8_t lookupPlayerRace(uint64_t guid) const { + auto it = playerClassRaceCache_.find(guid); + return it != playerClassRaceCache_.end() ? it->second.raceId : 0; + } + + // --- Transport GUID tracking --- + bool isTransportGuid(uint64_t guid) const { return transportGuids_.count(guid) > 0; } + bool hasServerTransportUpdate(uint64_t guid) const { return serverUpdatedTransportGuids_.count(guid) > 0; } + + // --- Update object work queue --- + void enqueueUpdateObjectWork(UpdateObjectData&& data); + void processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs); + bool hasPendingUpdateObjectWork() const { return !pendingUpdateObjectWork_.empty(); } + + // --- Reset all state (called on disconnect / character switch) --- + void clearAll(); + +private: + GameHandler& owner_; + + // --- Entity tracking --- + EntityManager entityManager; // Manages all entities in view + + // ---- Name caches ---- + std::unordered_map playerNameCache; + // Class/race cache from SMSG_NAME_QUERY_RESPONSE (guid → {classId, raceId}) + struct PlayerClassRace { uint8_t classId = 0; uint8_t raceId = 0; }; + std::unordered_map playerClassRaceCache_; + std::unordered_set pendingNameQueries; + std::unordered_map creatureInfoCache; + std::unordered_set pendingCreatureQueries; + std::unordered_map gameObjectInfoCache_; + std::unordered_set pendingGameObjectQueries_; + + // --- Update Object work queue --- + struct PendingUpdateObjectWork { + UpdateObjectData data; + size_t nextBlockIndex = 0; + bool outOfRangeProcessed = false; + bool newItemCreated = false; + }; + std::deque pendingUpdateObjectWork_; + + // --- Transport GUID tracking --- + std::unordered_set transportGuids_; // GUIDs of known transport GameObjects + std::unordered_set serverUpdatedTransportGuids_; + + // --- Packet handlers --- + void handleUpdateObject(network::Packet& packet); + void handleCompressedUpdateObject(network::Packet& packet); + void handleDestroyObject(network::Packet& packet); + void handleNameQueryResponse(network::Packet& packet); + void handleCreatureQueryResponse(network::Packet& packet); + void handleGameObjectQueryResponse(network::Packet& packet); + void handleGameObjectPageText(network::Packet& packet); + void handlePageTextQueryResponse(network::Packet& packet); + + // --- Entity lifecycle --- + void processOutOfRangeObjects(const std::vector& guids); + void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); + void finalizeUpdateObjectBatch(bool newItemCreated); +}; + +} // namespace game +} // namespace wowee diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 445844fe..ed0f7cb3 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -12,6 +12,7 @@ #include "game/spell_handler.hpp" #include "game/quest_handler.hpp" #include "game/movement_handler.hpp" +#include "game/entity_controller.hpp" #include "network/packet.hpp" #include #include @@ -243,8 +244,8 @@ public: /** * Get entity manager (for accessing entities in view) */ - EntityManager& getEntityManager() { return entityManager; } - const EntityManager& getEntityManager() const { return entityManager; } + EntityManager& getEntityManager() { return entityController_->getEntityManager(); } + const EntityManager& getEntityManager() const { return entityController_->getEntityManager(); } /** * Send a chat message @@ -622,36 +623,38 @@ public: void resetCastState(); // force-clear all cast/craft/queue state without sending packets void clearUnitCaches(); // clear per-unit cast states and aura caches - // ---- Phase 1: Name queries ---- + // ---- Phase 1: Name queries (delegated to EntityController) ---- void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); const GameObjectQueryResponseData* getCachedGameObjectInfo(uint32_t entry) const { - auto it = gameObjectInfoCache_.find(entry); - return (it != gameObjectInfoCache_.end()) ? &it->second : nullptr; + return entityController_->getCachedGameObjectInfo(entry); } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; + // Read-only cache access forwarded from EntityController + const std::unordered_map& getPlayerNameCache() const { + return entityController_->getPlayerNameCache(); + } + const std::unordered_map& getCreatureInfoCache() const { + return entityController_->getCreatureInfoCache(); + } // Returns the creature subname/title (e.g. ""), empty if not cached std::string getCachedCreatureSubName(uint32_t entry) const { - auto it = creatureInfoCache.find(entry); - return (it != creatureInfoCache.end()) ? it->second.subName : ""; + return entityController_->getCachedCreatureSubName(entry); } // Returns the creature rank (0=Normal,1=Elite,2=RareElite,3=Boss,4=Rare) // or -1 if not cached yet int getCreatureRank(uint32_t entry) const { - auto it = creatureInfoCache.find(entry); - return (it != creatureInfoCache.end()) ? static_cast(it->second.rank) : -1; + return entityController_->getCreatureRank(entry); } // Returns creature type (1=Beast,2=Dragonkin,...,7=Humanoid,...) or 0 if not cached uint32_t getCreatureType(uint32_t entry) const { - auto it = creatureInfoCache.find(entry); - return (it != creatureInfoCache.end()) ? it->second.creatureType : 0; + return entityController_->getCreatureType(entry); } // Returns creature family (e.g. pet family for beasts) or 0 uint32_t getCreatureFamily(uint32_t entry) const { - auto it = creatureInfoCache.find(entry); - return (it != creatureInfoCache.end()) ? it->second.family : 0; + return entityController_->getCreatureFamily(entry); } // ---- Phase 2: Combat (delegated to CombatHandler) ---- @@ -1111,8 +1114,8 @@ public: glm::vec3 getPlayerTransportOffset() const { return playerTransportOffset_; } // Check if a GUID is a known transport - bool isTransportGuid(uint64_t guid) const { return transportGuids_.count(guid) > 0; } - bool hasServerTransportUpdate(uint64_t guid) const { return serverUpdatedTransportGuids_.count(guid) > 0; } + bool isTransportGuid(uint64_t guid) const { return entityController_->isTransportGuid(guid); } + bool hasServerTransportUpdate(uint64_t guid) const { return entityController_->hasServerTransportUpdate(guid); } glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset TransportManager* getTransportManager() { return transportManager_.get(); } void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) { @@ -1152,27 +1155,16 @@ public: // Look up class/race for a player GUID from name query cache. Returns 0 if unknown. uint8_t lookupPlayerClass(uint64_t guid) const { - auto it = playerClassRaceCache_.find(guid); - return it != playerClassRaceCache_.end() ? it->second.classId : 0; + return entityController_->lookupPlayerClass(guid); } uint8_t lookupPlayerRace(uint64_t guid) const { - auto it = playerClassRaceCache_.find(guid); - return it != playerClassRaceCache_.end() ? it->second.raceId : 0; + return entityController_->lookupPlayerRace(guid); } // Look up a display name for any guid: checks playerNameCache then entity manager. // Returns empty string if unknown. Used by chat display to resolve names at render time. const std::string& lookupName(uint64_t guid) const { - static const std::string kEmpty; - auto it = playerNameCache.find(guid); - if (it != playerNameCache.end()) return it->second; - auto entity = entityManager.getEntity(guid); - if (entity) { - if (auto* unit = dynamic_cast(entity.get())) { - if (!unit->getName().empty()) return unit->getName(); - } - } - return kEmpty; + return entityController_->lookupName(guid); } uint8_t getPlayerClass() const { @@ -2106,6 +2098,7 @@ private: friend class SocialHandler; friend class QuestHandler; friend class WardenHandler; + friend class EntityController; // Dead: autoTargetAttacker moved to CombatHandler @@ -2121,12 +2114,6 @@ private: void enqueueIncomingPacket(const network::Packet& packet); void enqueueIncomingPacketFront(network::Packet&& packet); void processQueuedIncomingPackets(); - void enqueueUpdateObjectWork(UpdateObjectData&& data); - void processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, - float budgetMs); - void processOutOfRangeObjects(const std::vector& guids); - void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); - void finalizeUpdateObjectBatch(bool newItemCreated); /** * Handle SMSG_AUTH_CHALLENGE from server @@ -2186,27 +2173,7 @@ private: */ void handlePong(network::Packet& packet); - /** - * Handle SMSG_UPDATE_OBJECT from server - */ - void handleUpdateObject(network::Packet& packet); - - /** - * Handle SMSG_COMPRESSED_UPDATE_OBJECT from server - */ - void handleCompressedUpdateObject(network::Packet& packet); - - /** - * Handle SMSG_DESTROY_OBJECT from server - */ - void handleDestroyObject(network::Packet& packet); - - // ---- Phase 1 handlers ---- - void handleNameQueryResponse(network::Packet& packet); - void handleCreatureQueryResponse(network::Packet& packet); - void handleGameObjectQueryResponse(network::Packet& packet); - void handleGameObjectPageText(network::Packet& packet); - void handlePageTextQueryResponse(network::Packet& packet); + // ---- Phase 1 handlers (entity queries delegated to EntityController) ---- void handleItemQueryResponse(network::Packet& packet); void queryItemInfo(uint32_t entry, uint64_t guid); void rebuildOnlineInventory(); @@ -2356,13 +2323,6 @@ private: // Network std::unique_ptr socket; std::deque pendingIncomingPackets_; - struct PendingUpdateObjectWork { - UpdateObjectData data; - size_t nextBlockIndex = 0; - bool outOfRangeProcessed = false; - bool newItemCreated = false; - }; - std::deque pendingUpdateObjectWork_; // State WorldState state = WorldState::DISCONNECTED; @@ -2396,8 +2356,8 @@ private: // Inventory Inventory inventory; - // Entity tracking - EntityManager entityManager; // Manages all entities in view + // Entity tracking (delegated to EntityController) + std::unique_ptr entityController_; // Chat (state lives in ChatHandler; callbacks remain here for cross-domain access) ChatBubbleCallback chatBubbleCallback_; @@ -2444,16 +2404,7 @@ private: uint32_t homeBindZoneId_ = 0; glm::vec3 homeBindPos_{0.0f}; - // ---- Phase 1: Name caches ---- - std::unordered_map playerNameCache; - // Class/race cache from SMSG_NAME_QUERY_RESPONSE (guid → {classId, raceId}) - struct PlayerClassRace { uint8_t classId = 0; uint8_t raceId = 0; }; - std::unordered_map playerClassRaceCache_; - std::unordered_set pendingNameQueries; - std::unordered_map creatureInfoCache; - std::unordered_set pendingCreatureQueries; - std::unordered_map gameObjectInfoCache_; - std::unordered_set pendingGameObjectQueries_; + // ---- Phase 1: Name caches (moved to EntityController) ---- // ---- Friend/contact list cache ---- std::unordered_map friendsCache; // name -> guid @@ -2586,8 +2537,7 @@ private: bool hasLocalOrientation = false; }; std::unordered_map transportAttachments_; - std::unordered_set transportGuids_; // GUIDs of known transport GameObjects - std::unordered_set serverUpdatedTransportGuids_; + // Transport GUID tracking moved to EntityController uint64_t playerTransportGuid_ = 0; // Transport the player is riding (0 = none) glm::vec3 playerTransportOffset_ = glm::vec3(0.0f); // Player offset on transport uint64_t playerTransportStickyGuid_ = 0; // Last transport player was on (temporary retention) diff --git a/include/game/spell_handler.hpp b/include/game/spell_handler.hpp index 4a946c62..9314f76e 100644 --- a/include/game/spell_handler.hpp +++ b/include/game/spell_handler.hpp @@ -257,6 +257,7 @@ private: friend class GameHandler; friend class InventoryHandler; friend class CombatHandler; + friend class EntityController; GameHandler& owner_; diff --git a/src/game/chat_handler.cpp b/src/game/chat_handler.cpp index d725cb15..02c0d459 100644 --- a/src/game/chat_handler.cpp +++ b/src/game/chat_handler.cpp @@ -138,8 +138,8 @@ void ChatHandler::sendChatMessage(ChatType type, const std::string& message, con echo.language = language; echo.message = message; - auto nameIt = owner_.playerNameCache.find(owner_.playerGuid); - if (nameIt != owner_.playerNameCache.end()) { + auto nameIt = owner_.getPlayerNameCache().find(owner_.playerGuid); + if (nameIt != owner_.getPlayerNameCache().end()) { echo.senderName = nameIt->second; } @@ -179,11 +179,11 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { // Resolve sender name from entity/cache if not already set by parser if (data.senderName.empty() && data.senderGuid != 0) { - auto nameIt = owner_.playerNameCache.find(data.senderGuid); - if (nameIt != owner_.playerNameCache.end()) { + auto nameIt = owner_.getPlayerNameCache().find(data.senderGuid); + if (nameIt != owner_.getPlayerNameCache().end()) { data.senderName = nameIt->second; } else { - auto entity = owner_.entityManager.getEntity(data.senderGuid); + auto entity = owner_.getEntityManager().getEntity(data.senderGuid); if (entity) { if (entity->getType() == ObjectType::PLAYER) { auto player = std::dynamic_pointer_cast(entity); @@ -356,11 +356,11 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { } std::string senderName; - auto nameIt = owner_.playerNameCache.find(data.senderGuid); - if (nameIt != owner_.playerNameCache.end()) { + auto nameIt = owner_.getPlayerNameCache().find(data.senderGuid); + if (nameIt != owner_.getPlayerNameCache().end()) { senderName = nameIt->second; } else { - auto entity = owner_.entityManager.getEntity(data.senderGuid); + auto entity = owner_.getEntityManager().getEntity(data.senderGuid); if (entity) { auto unit = std::dynamic_pointer_cast(entity); if (unit) senderName = unit->getName(); @@ -685,7 +685,7 @@ void ChatHandler::handleChannelList(network::Packet& packet) { uint64_t memberGuid = packet.readUInt64(); uint8_t memberFlags = packet.readUInt8(); std::string name; - auto entity = owner_.entityManager.getEntity(memberGuid); + auto entity = owner_.getEntityManager().getEntity(memberGuid); if (entity) { auto player = std::dynamic_pointer_cast(entity); if (player && !player->getName().empty()) name = player->getName(); diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index 2b660f36..2131fcde 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -64,7 +64,7 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { }; table[Opcode::SMSG_ATTACKSWING_BADFACING] = [this](network::Packet& /*packet*/) { if (autoAttackRequested_ && autoAttackTarget_ != 0) { - auto targetEntity = owner_.entityManager.getEntity(autoAttackTarget_); + auto targetEntity = owner_.getEntityManager().getEntity(autoAttackTarget_); if (targetEntity) { float toTargetX = targetEntity->getX() - owner_.movementInfo.x; float toTargetY = targetEntity->getY() - owner_.movementInfo.y; @@ -96,7 +96,7 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { uint64_t guid = packet.readUInt64(); uint32_t reaction = packet.readUInt32(); if (reaction == 2 && owner_.npcAggroCallback_) { - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); if (entity) owner_.npcAggroCallback_(guid, glm::vec3(entity->getX(), entity->getY(), entity->getZ())); } @@ -204,7 +204,7 @@ void CombatHandler::startAutoAttack(uint64_t targetGuid) { // Client-side melee range gate to avoid starting "swing forever" loops when // target is already clearly out of range. - if (auto target = owner_.entityManager.getEntity(targetGuid)) { + if (auto target = owner_.getEntityManager().getEntity(targetGuid)) { float dx = owner_.movementInfo.x - target->getLatestX(); float dy = owner_.movementInfo.y - target->getLatestY(); float dz = owner_.movementInfo.z - target->getLatestZ(); @@ -368,7 +368,7 @@ void CombatHandler::updateCombatText(float deltaTime) { void CombatHandler::autoTargetAttacker(uint64_t attackerGuid) { if (attackerGuid == 0 || attackerGuid == owner_.playerGuid) return; if (owner_.targetGuid != 0) return; - if (!owner_.entityManager.hasEntity(attackerGuid)) return; + if (!owner_.getEntityManager().hasEntity(attackerGuid)) return; owner_.setTarget(attackerGuid); } @@ -389,7 +389,7 @@ void CombatHandler::handleAttackStart(network::Packet& packet) { // Play aggro sound when NPC attacks player if (owner_.npcAggroCallback_) { - auto entity = owner_.entityManager.getEntity(data.attackerGuid); + auto entity = owner_.getEntityManager().getEntity(data.attackerGuid); if (entity && entity->getType() == ObjectType::UNIT) { glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ()); owner_.npcAggroCallback_(data.attackerGuid, pos); @@ -400,8 +400,8 @@ void CombatHandler::handleAttackStart(network::Packet& packet) { // Force both participants to face each other at combat start. // Uses atan2(-dy, dx): canonical orientation convention where the West/Y // component is negated (renderYaw = orientation + 90°, model-forward = render+X). - auto attackerEnt = owner_.entityManager.getEntity(data.attackerGuid); - auto victimEnt = owner_.entityManager.getEntity(data.victimGuid); + auto attackerEnt = owner_.getEntityManager().getEntity(data.attackerGuid); + auto victimEnt = owner_.getEntityManager().getEntity(data.victimGuid); if (attackerEnt && victimEnt) { float dx = victimEnt->getX() - attackerEnt->getX(); float dy = victimEnt->getY() - attackerEnt->getY(); @@ -589,7 +589,7 @@ void CombatHandler::updateAutoAttack(float deltaTime) { // Leave combat if auto-attack target is too far away (leash range) // and keep melee intent tightly synced while stationary. if (autoAttackRequested_ && autoAttackTarget_ != 0) { - auto targetEntity = owner_.entityManager.getEntity(autoAttackTarget_); + auto targetEntity = owner_.getEntityManager().getEntity(autoAttackTarget_); if (targetEntity) { const float targetX = targetEntity->getLatestX(); const float targetY = targetEntity->getLatestY(); @@ -669,7 +669,7 @@ void CombatHandler::updateAutoAttack(float deltaTime) { // Keep active melee attackers visually facing the player as positions change. if (!hostileAttackers_.empty()) { for (uint64_t attackerGuid : hostileAttackers_) { - auto attacker = owner_.entityManager.getEntity(attackerGuid); + auto attacker = owner_.getEntityManager().getEntity(attackerGuid); if (!attacker) continue; float dx = owner_.movementInfo.x - attacker->getX(); float dy = owner_.movementInfo.y - attacker->getY(); @@ -1098,14 +1098,14 @@ void CombatHandler::clearTarget() { std::shared_ptr CombatHandler::getTarget() const { if (owner_.targetGuid == 0) return nullptr; - return owner_.entityManager.getEntity(owner_.targetGuid); + return owner_.getEntityManager().getEntity(owner_.targetGuid); } void CombatHandler::setFocus(uint64_t guid) { owner_.focusGuid = guid; owner_.fireAddonEvent("PLAYER_FOCUS_CHANGED", {}); if (guid != 0) { - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); if (entity) { std::string name; auto unit = std::dynamic_pointer_cast(entity); @@ -1131,7 +1131,7 @@ void CombatHandler::clearFocus() { std::shared_ptr CombatHandler::getFocus() const { if (owner_.focusGuid == 0) return nullptr; - return owner_.entityManager.getEntity(owner_.focusGuid); + return owner_.getEntityManager().getEntity(owner_.focusGuid); } void CombatHandler::setMouseoverGuid(uint64_t guid) { @@ -1156,7 +1156,7 @@ void CombatHandler::targetLastTarget() { void CombatHandler::targetEnemy(bool reverse) { // Get list of hostile entities std::vector hostiles; - auto& entities = owner_.entityManager.getEntities(); + auto& entities = owner_.getEntityManager().getEntities(); for (const auto& [guid, entity] : entities) { if (entity->getType() == ObjectType::UNIT) { @@ -1200,7 +1200,7 @@ void CombatHandler::targetEnemy(bool reverse) { void CombatHandler::targetFriend(bool reverse) { // Get list of friendly entities (players) std::vector friendlies; - auto& entities = owner_.entityManager.getEntities(); + auto& entities = owner_.getEntityManager().getEntities(); for (const auto& [guid, entity] : entities) { if (entity->getType() == ObjectType::PLAYER && guid != owner_.playerGuid) { @@ -1266,7 +1266,7 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { struct EntityDist { uint64_t guid; float distance; }; std::vector sortable; - for (const auto& [guid, entity] : owner_.entityManager.getEntities()) { + for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue; if (guid == owner_.playerGuid) continue; @@ -1297,7 +1297,7 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { while (tries-- > 0) { owner_.tabCycleIndex = (owner_.tabCycleIndex + 1) % static_cast(owner_.tabCycleList.size()); uint64_t guid = owner_.tabCycleList[owner_.tabCycleIndex]; - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); if (isValidTabTarget(entity)) { setTarget(guid); return; @@ -1373,7 +1373,7 @@ void CombatHandler::togglePvp() { auto packet = TogglePvpPacket::build(); owner_.socket->send(packet); - auto entity = owner_.entityManager.getEntity(owner_.playerGuid); + auto entity = owner_.getEntityManager().getEntity(owner_.playerGuid); bool currentlyPvp = false; if (entity) { currentlyPvp = (entity->getField(59) & 0x00001000) != 0; diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp new file mode 100644 index 00000000..36dd0696 --- /dev/null +++ b/src/game/entity_controller.cpp @@ -0,0 +1,2172 @@ +#include "game/entity_controller.hpp" +#include "game/game_handler.hpp" +#include "game/game_utils.hpp" +#include "game/packet_parsers.hpp" +#include "game/entity.hpp" +#include "game/update_field_table.hpp" +#include "game/opcode_table.hpp" +#include "game/chat_handler.hpp" +#include "game/transport_manager.hpp" +#include "core/logger.hpp" +#include "core/coordinates.hpp" +#include "network/world_socket.hpp" +#include +#include +#include + +namespace wowee { +namespace game { + +namespace { + +const char* worldStateName(WorldState state) { + switch (state) { + case WorldState::DISCONNECTED: return "DISCONNECTED"; + case WorldState::CONNECTING: return "CONNECTING"; + case WorldState::CONNECTED: return "CONNECTED"; + case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED"; + case WorldState::AUTH_SENT: return "AUTH_SENT"; + case WorldState::AUTHENTICATED: return "AUTHENTICATED"; + case WorldState::READY: return "READY"; + case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED"; + case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED"; + case WorldState::ENTERING_WORLD: return "ENTERING_WORLD"; + case WorldState::IN_WORLD: return "IN_WORLD"; + case WorldState::FAILED: return "FAILED"; + } + return "UNKNOWN"; +} + +bool envFlagEnabled(const char* key, bool defaultValue = false) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || + raw[0] == 'n' || raw[0] == 'N'); +} + +int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return defaultValue; + return static_cast(std::clamp(parsed, minValue, maxValue)); +} + +int updateObjectBlocksBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float slowUpdateObjectBlockLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +} // anonymous namespace + +EntityController::EntityController(GameHandler& owner) + : owner_(owner) {} + +void EntityController::registerOpcodes(DispatchTable& table) { + // World object updates + table[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) { + LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast(owner_.state), " size=", packet.getSize()); + if (owner_.state == WorldState::IN_WORLD) handleUpdateObject(packet); + }; + table[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) { + LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(owner_.state), " size=", packet.getSize()); + if (owner_.state == WorldState::IN_WORLD) handleCompressedUpdateObject(packet); + }; + table[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) { + if (owner_.state == WorldState::IN_WORLD) handleDestroyObject(packet); + }; + + // Entity queries + table[Opcode::SMSG_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) { + handleNameQueryResponse(packet); + }; + table[Opcode::SMSG_CREATURE_QUERY_RESPONSE] = [this](network::Packet& packet) { + handleCreatureQueryResponse(packet); + }; + table[Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE] = [this](network::Packet& packet) { + handleGameObjectQueryResponse(packet); + }; + table[Opcode::SMSG_GAMEOBJECT_PAGETEXT] = [this](network::Packet& packet) { + handleGameObjectPageText(packet); + }; + table[Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE] = [this](network::Packet& packet) { + handlePageTextQueryResponse(packet); + }; +} + +void EntityController::clearAll() { + pendingUpdateObjectWork_.clear(); + playerNameCache.clear(); + playerClassRaceCache_.clear(); + pendingNameQueries.clear(); + creatureInfoCache.clear(); + pendingCreatureQueries.clear(); + gameObjectInfoCache_.clear(); + pendingGameObjectQueries_.clear(); + transportGuids_.clear(); + serverUpdatedTransportGuids_.clear(); + entityManager.clear(); +} + +// ============================================================ +// Update Object Pipeline +// ============================================================ + +void EntityController::enqueueUpdateObjectWork(UpdateObjectData&& data) { + pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); +} +void EntityController::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs) { + if (pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(owner_.state); + int processedBlocks = 0; + + while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + auto& work = pendingUpdateObjectWork_.front(); + if (!work.outOfRangeProcessed) { + auto outOfRangeStart = std::chrono::steady_clock::now(); + processOutOfRangeObjects(work.data.outOfRangeGuids); + float outOfRangeMs = std::chrono::duration( + std::chrono::steady_clock::now() - outOfRangeStart).count(); + if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, + "ms guidCount=", work.data.outOfRangeGuids.size()); + } + work.outOfRangeProcessed = true; + } + + while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { + elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; + auto blockStart = std::chrono::steady_clock::now(); + applyUpdateObjectBlock(block, work.newItemCreated); + float blockMs = std::chrono::duration( + std::chrono::steady_clock::now() - blockStart).count(); + if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object block apply: ", blockMs, + "ms index=", work.nextBlockIndex, + " type=", static_cast(block.updateType), + " guid=0x", std::hex, block.guid, std::dec, + " objectType=", static_cast(block.objectType), + " fieldCount=", block.fields.size(), + " hasMovement=", block.hasMovement ? 1 : 0); + } + ++work.nextBlockIndex; + ++processedBlocks; + } + + if (work.nextBlockIndex >= work.data.blocks.size()) { + finalizeUpdateObjectBatch(work.newItemCreated); + pendingUpdateObjectWork_.pop_front(); + continue; + } + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + const auto& work = pendingUpdateObjectWork_.front(); + LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", + pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, + "/", work.data.blocks.size(), ", owner_.state=", worldStateName(owner_.state), ")"); + } +} +void EntityController::handleUpdateObject(network::Packet& packet) { + UpdateObjectData data; + if (!owner_.packetParsers_->parseUpdateObject(packet, data)) { + static int updateObjErrors = 0; + if (++updateObjErrors <= 5) + LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); + if (data.blocks.empty()) return; + // Fall through: process any blocks that were successfully parsed before the failure. + } + + enqueueUpdateObjectWork(std::move(data)); +} + +void EntityController::processOutOfRangeObjects(const std::vector& guids) { + // Process out-of-range objects first + for (uint64_t guid : guids) { + auto entity = entityManager.getEntity(guid); + if (!entity) continue; + + const bool isKnownTransport = transportGuids_.count(guid) > 0; + if (isKnownTransport) { + // Keep transports alive across out-of-range flapping. + // Boats/zeppelins are global movers and removing them here can make + // them disappear until a later movement snapshot happens to recreate them. + const bool playerAboardNow = (owner_.playerTransportGuid_ == guid); + const bool stickyAboard = (owner_.playerTransportStickyGuid_ == guid && owner_.playerTransportStickyTimer_ > 0.0f); + const bool movementSaysAboard = (owner_.movementInfo.transportGuid == guid); + LOG_INFO("Preserving transport on out-of-range: 0x", + std::hex, guid, std::dec, + " now=", playerAboardNow, + " sticky=", stickyAboard, + " movement=", movementSaysAboard); + continue; + } + + LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); + // Trigger despawn callbacks before removing entity + if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { + owner_.creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { + owner_.playerDespawnCallback_(guid); + owner_.otherPlayerVisibleItemEntries_.erase(guid); + owner_.otherPlayerVisibleDirty_.erase(guid); + owner_.otherPlayerMoveTimeMs_.erase(guid); + owner_.inspectedPlayerItemEntries_.erase(guid); + owner_.pendingAutoInspect_.erase(guid); + // Clear pending name query so the query is re-sent when this player + // comes back into range (entity is recreated as a new object). + pendingNameQueries.erase(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) { + owner_.gameObjectDespawnCallback_(guid); + } + transportGuids_.erase(guid); + serverUpdatedTransportGuids_.erase(guid); + owner_.clearTransportAttachment(guid); + if (owner_.playerTransportGuid_ == guid) { + owner_.clearPlayerTransport(); + } + entityManager.removeEntity(guid); + } + +} + +void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { + static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); + auto extractPlayerAppearance = [&](const std::map& fields, + uint8_t& outRace, + uint8_t& outGender, + uint32_t& outAppearanceBytes, + uint8_t& outFacial) -> bool { + outRace = 0; + outGender = 0; + outAppearanceBytes = 0; + outFacial = 0; + + auto readField = [&](uint16_t idx, uint32_t& out) -> bool { + if (idx == 0xFFFF) return false; + auto it = fields.find(idx); + if (it == fields.end()) return false; + out = it->second; + return true; + }; + + uint32_t bytes0 = 0; + uint32_t pbytes = 0; + uint32_t pbytes2 = 0; + + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES); + const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2); + + bool haveBytes0 = readField(ufBytes0, bytes0); + bool havePbytes = readField(ufPbytes, pbytes); + bool havePbytes2 = readField(ufPbytes2, pbytes2); + + // Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing, + // try to locate plausible packed fields by scanning. + if (!haveBytes0) { + for (const auto& [idx, v] : fields) { + uint8_t race = static_cast(v & 0xFF); + uint8_t cls = static_cast((v >> 8) & 0xFF); + uint8_t gender = static_cast((v >> 16) & 0xFF); + uint8_t power = static_cast((v >> 24) & 0xFF); + if (race >= 1 && race <= 20 && + cls >= 1 && cls <= 20 && + gender <= 1 && + power <= 10) { + bytes0 = v; + haveBytes0 = true; + break; + } + } + } + if (!havePbytes) { + for (const auto& [idx, v] : fields) { + uint8_t skin = static_cast(v & 0xFF); + uint8_t face = static_cast((v >> 8) & 0xFF); + uint8_t hair = static_cast((v >> 16) & 0xFF); + uint8_t color = static_cast((v >> 24) & 0xFF); + if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) { + pbytes = v; + havePbytes = true; + break; + } + } + } + if (!havePbytes2) { + for (const auto& [idx, v] : fields) { + uint8_t facial = static_cast(v & 0xFF); + if (facial <= 100) { + pbytes2 = v; + havePbytes2 = true; + break; + } + } + } + + if (!haveBytes0 || !havePbytes) return false; + + outRace = static_cast(bytes0 & 0xFF); + outGender = static_cast((bytes0 >> 16) & 0xFF); + outAppearanceBytes = pbytes; + outFacial = havePbytes2 ? static_cast(pbytes2 & 0xFF) : 0; + return true; + }; + + auto maybeDetectCoinageIndex = [&](const std::map& oldFields, + const std::map& newFields) { + if (owner_.pendingMoneyDelta_ == 0 || owner_.pendingMoneyDeltaTimer_ <= 0.0f) return; + if (oldFields.empty() || newFields.empty()) return; + + constexpr uint32_t kMaxPlausibleCoinage = 2147483647u; + std::vector candidates; + candidates.reserve(8); + + for (const auto& [idx, newVal] : newFields) { + auto itOld = oldFields.find(idx); + if (itOld == oldFields.end()) continue; + uint32_t oldVal = itOld->second; + if (newVal < oldVal) continue; + uint32_t delta = newVal - oldVal; + if (delta != owner_.pendingMoneyDelta_) continue; + if (newVal > kMaxPlausibleCoinage) continue; + candidates.push_back(idx); + } + + if (candidates.empty()) return; + + uint16_t current = fieldIndex(UF::PLAYER_FIELD_COINAGE); + uint16_t chosen = candidates[0]; + if (std::find(candidates.begin(), candidates.end(), current) != candidates.end()) { + chosen = current; + } else { + std::sort(candidates.begin(), candidates.end()); + chosen = candidates[0]; + } + + if (chosen != current && current != 0xFFFF) { + owner_.updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen); + LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")"); + } + + owner_.pendingMoneyDelta_ = 0; + owner_.pendingMoneyDeltaTimer_ = 0.0f; + }; + + switch (block.updateType) { + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: { + // Create new entity + std::shared_ptr entity; + + switch (block.objectType) { + case ObjectType::PLAYER: + entity = std::make_shared(block.guid); + break; + + case ObjectType::UNIT: + entity = std::make_shared(block.guid); + break; + + case ObjectType::GAMEOBJECT: + entity = std::make_shared(block.guid); + break; + + default: + entity = std::make_shared(block.guid); + entity->setType(block.objectType); + break; + } + + // Set position from movement block (server → canonical) + if (block.hasMovement) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + if (block.guid == owner_.playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + owner_.serverRunSpeed_ = block.runSpeed; + } + // Track player-on-transport owner_.state + if (block.guid == owner_.playerGuid) { + if (block.onTransport) { + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + owner_.setPlayerOnTransport(block.transportGuid, canonicalOffset); + if (owner_.transportManager_ && owner_.transportManager_->getTransport(owner_.playerTransportGuid_)) { + glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + owner_.movementInfo.x = composed.x; + owner_.movementInfo.y = composed.y; + owner_.movementInfo.z = composed.z; + } + LOG_INFO("Player on transport: 0x", std::hex, owner_.playerTransportGuid_, std::dec, + " offset=(", owner_.playerTransportOffset_.x, ", ", owner_.playerTransportOffset_.y, ", ", owner_.playerTransportOffset_.z, ")"); + } else { + // Don't clear client-side M2 transport boarding (trams) — + // the server doesn't know about client-detected transport attachment. + bool isClientM2Transport = false; + if (owner_.playerTransportGuid_ != 0 && owner_.transportManager_) { + auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (owner_.playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport"); + owner_.clearPlayerTransport(); + } + } + } + + // Track transport-relative children so they follow parent transport motion. + if (block.guid != owner_.playerGuid && + (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + owner_.setTransportAttachment(block.guid, block.objectType, block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (owner_.transportManager_ && owner_.transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + owner_.clearTransportAttachment(block.guid); + } + } + } + + // Set fields + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + // Add to manager + entityManager.addEntity(block.guid, entity); + + // For the local player, capture the full initial field owner_.state (CREATE_OBJECT carries the + // large baseline update-field set, including visible item fields on many cores). + // Later VALUES updates often only include deltas and may never touch visible item fields. + if (block.guid == owner_.playerGuid && block.objectType == ObjectType::PLAYER) { + owner_.lastPlayerFields_ = entity->getFields(); + owner_.maybeDetectVisibleItemLayout(); + } + + // Auto-query names (Phase 1) + if (block.objectType == ObjectType::PLAYER) { + queryPlayerName(block.guid); + if (block.guid != owner_.playerGuid) { + owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + } else if (block.objectType == ObjectType::UNIT) { + auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (it != block.fields.end() && it->second != 0) { + auto unit = std::static_pointer_cast(entity); + unit->setEntry(it->second); + // Set name from cache immediately if available + std::string cached = getCachedCreatureName(it->second); + if (!cached.empty()) { + unit->setName(cached); + } + queryCreatureInfo(it->second, block.guid); + } + } + + // Extract health/mana/power from fields (Phase 2) — single pass + if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + bool unitInitiallyDead = false; + const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); + const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); + const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); + const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); + const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); + const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); + const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); + const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); + const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); + const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + for (const auto& [key, val] : block.fields) { + // Check all specific fields BEFORE power/maxpower range checks. + // In Classic, power indices (23-27) are adjacent to maxHealth (28), + // and maxPower indices (29-33) are adjacent to level (34) and faction (35). + // A range check like "key >= powerBase && key < powerBase+7" would + // incorrectly capture maxHealth/level/faction in Classic's tight layout. + if (key == ufHealth) { + unit->setHealth(val); + if (block.objectType == ObjectType::UNIT && val == 0) { + unitInitiallyDead = true; + } + if (block.guid == owner_.playerGuid && val == 0) { + owner_.playerDead_ = true; + LOG_INFO("Player logged in dead"); + } + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } + else if (key == ufLevel) { + unit->setLevel(val); + } else if (key == ufFaction) { + unit->setFactionTemplate(val); + if (owner_.addonEventCallback_) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + owner_.fireAddonEvent("UNIT_FACTION", {uid}); + } + } + else if (key == ufFlags) { + unit->setUnitFlags(val); + if (owner_.addonEventCallback_) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + owner_.fireAddonEvent("UNIT_FLAGS", {uid}); + } + } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufDisplayId) { + unit->setDisplayId(val); + if (owner_.addonEventCallback_) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + owner_.fireAddonEvent("UNIT_MODEL_CHANGED", {uid}); + } + } + else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + else if (key == ufDynFlags) { + unit->setDynamicFlags(val); + if (block.objectType == ObjectType::UNIT && + ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { + unitInitiallyDead = true; + } + } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + } + else if (key == ufMountDisplayId) { + if (block.guid == owner_.playerGuid) { + uint32_t old = owner_.currentMountDisplayId_; + owner_.currentMountDisplayId_ = val; + if (val != old && owner_.mountCallback_) owner_.mountCallback_(val); + if (val != old) + owner_.fireAddonEvent("UNIT_MODEL_CHANGED", {"player"}); + if (old == 0 && val != 0) { + // Just mounted — find the mount aura (indefinite duration, self-cast) + owner_.mountAuraSpellId_ = 0; + if (owner_.spellHandler_) for (const auto& a : owner_.spellHandler_->playerAuras_) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.playerGuid) { + owner_.mountAuraSpellId_ = a.spellId; + } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (owner_.mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + owner_.mountAuraSpellId_ = fv; + break; + } + } + } + } + LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", owner_.mountAuraSpellId_); + } + if (old != 0 && val == 0) { + owner_.mountAuraSpellId_ = 0; + if (owner_.spellHandler_) for (auto& a : owner_.spellHandler_->playerAuras_) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; + } + } + unit->setMountDisplayId(val); + } + } + if (block.guid == owner_.playerGuid) { + constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; + if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !owner_.onTaxiFlight_ && owner_.taxiLandingCooldown_ <= 0.0f) { + owner_.onTaxiFlight_ = true; + owner_.taxiStartGrace_ = std::max(owner_.taxiStartGrace_, 2.0f); + owner_.sanitizeMovementForTaxi(); + if (owner_.movementHandler_) owner_.movementHandler_->applyTaxiMountForCurrentNode(); + } + } + if (block.guid == owner_.playerGuid && + (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { + owner_.playerDead_ = true; + LOG_INFO("Player logged in dead (dynamic flags)"); + } + // Detect ghost owner_.state on login via PLAYER_FLAGS + if (block.guid == owner_.playerGuid) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); + if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { + owner_.releasedSpirit_ = true; + owner_.playerDead_ = true; + LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); + if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(true); + // Query corpse position so minimap marker is accurate on reconnect + if (owner_.socket) { + network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); + owner_.socket->send(cq); + } + } + } + // Classic: rebuild owner_.spellHandler_->playerAuras_ from UNIT_FIELD_AURAS on initial object create + if (block.guid == owner_.playerGuid && isClassicLikeExpansion() && owner_.spellHandler_) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraField = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } + } + if (hasAuraField) { + owner_.spellHandler_->playerAuras_.clear(); + owner_.spellHandler_->playerAuras_.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = owner_.spellHandler_->playerAuras_[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful + // Normalize to WotLK convention: 0x80 = negative (debuff) + uint8_t classicFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) + a.flags = (classicFlag & 0x02) ? 0x80u : 0u; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = owner_.playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); + owner_.fireAddonEvent("UNIT_AURA", {"player"}); + } + } + } + // Determine hostility from faction template for online creatures. + // Always call owner_.isHostileFaction — factionTemplate=0 defaults to hostile + // in the lookup rather than silently staying at the struct default (false). + unit->setHostile(owner_.isHostileFaction(unit->getFactionTemplate())); + // Trigger creature spawn callback for units/players with displayId + if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { + LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " has displayId=0 — no spawn (entry=", unit->getEntry(), + " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + } + if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { + if (block.objectType == ObjectType::PLAYER && block.guid == owner_.playerGuid) { + // Skip local player — spawned separately via spawnPlayerCharacter() + } else if (block.objectType == ObjectType::PLAYER) { + if (owner_.playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field owner_.state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + owner_.playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + } + } + if (unitInitiallyDead && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + } + } else if (owner_.creatureSpawnCallback_) { + LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " at (", + unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + float unitScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale, &raw, sizeof(float)); + if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; + } + } + } + owner_.creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); + if (unitInitiallyDead && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + } + } + // Initialise swim/walk owner_.state from spawn-time movement flags (cold-join fix). + // Without this, an entity already swimming/walking when the client joins + // won't get its animation owner_.state set until the next MSG_MOVE_* heartbeat. + if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ && + block.guid != owner_.playerGuid) { + owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags); + } + // Query quest giver status for NPCs with questgiver flag (0x02) + if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && owner_.socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + owner_.socket->send(qsPkt); + } + } + } + // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) + if (block.objectType == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); + if (itDisp != block.fields.end()) { + go->setDisplayId(itDisp->second); + } + auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (itEntry != block.fields.end() && itEntry->second != 0) { + go->setEntry(itEntry->second); + auto cacheIt = gameObjectInfoCache_.find(itEntry->second); + if (cacheIt != gameObjectInfoCache_.end()) { + go->setName(cacheIt->second.name); + } + queryGameObjectInfo(itEntry->second, block.guid); + } + // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) + LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), " displayId=", go->getDisplayId(), + " updateFlags=0x", std::hex, block.updateFlags, std::dec, + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + if (block.updateFlags & 0x0002) { + transportGuids_.insert(block.guid); + LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), + " displayId=", go->getDisplayId(), + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created + } + if (go->getDisplayId() != 0 && owner_.gameObjectSpawnCallback_) { + float goScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&goScale, &raw, sizeof(float)); + if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; + } + } + } + owner_.gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), + go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); + } + // Fire transport move callback for transports (position update on re-creation) + if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + owner_.transportMoveCallback_(block.guid, + go->getX(), go->getY(), go->getZ(), go->getOrientation()); + } + } + // Detect player's own corpse object so we have the position even when + // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). + if (block.objectType == ObjectType::CORPSE && block.hasMovement) { + // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) + uint16_t ownerLowIdx = 6; + auto ownerLowIt = block.fields.find(ownerLowIdx); + uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; + auto ownerHighIt = block.fields.find(ownerLowIdx + 1); + uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; + uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; + if (ownerGuid == owner_.playerGuid || ownerLow == static_cast(owner_.playerGuid)) { + // Server coords from movement block + owner_.corpseGuid_ = block.guid; + owner_.corpseX_ = block.x; + owner_.corpseY_ = block.y; + owner_.corpseZ_ = block.z; + owner_.corpseMapId_ = owner_.currentMapId_; + LOG_INFO("Corpse object detected: guid=0x", std::hex, owner_.corpseGuid_, std::dec, + " server=(", block.x, ", ", block.y, ", ", block.z, + ") map=", owner_.corpseMapId_); + } + } + + // Track online item objects (CONTAINER = bags, also tracked as items) + if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { + auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); + auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); + auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); + const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) + ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; + auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); + auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); + auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end(); + auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end(); + auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); + if (entryIt != block.fields.end() && entryIt->second != 0) { + // Preserve existing info when doing partial updates + GameHandler::OnlineItemInfo info = owner_.onlineItems_.count(block.guid) + ? owner_.onlineItems_[block.guid] : GameHandler::OnlineItemInfo{}; + info.entry = entryIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; + if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; + if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; + if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; + if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; + if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second; + auto [itemIt, isNew] = owner_.onlineItems_.insert_or_assign(block.guid, info); + if (isNew) newItemCreated = true; + owner_.queryItemInfo(info.entry, block.guid); + } + // Extract container slot GUIDs for bags + if (block.objectType == ObjectType::CONTAINER) { + owner_.extractContainerFields(block.guid, block.fields); + } + } + + // Extract XP / owner_.inventory slot / skill fields for player entity + if (block.guid == owner_.playerGuid && block.objectType == ObjectType::PLAYER) { + // Auto-detect coinage index using the previous snapshot vs this full snapshot. + maybeDetectCoinageIndex(owner_.lastPlayerFields_, block.fields); + + owner_.lastPlayerFields_ = block.fields; + owner_.detectInventorySlotBases(block.fields); + + if (kVerboseUpdateObject) { + uint16_t maxField = 0; + for (const auto& [key, _val] : block.fields) { + if (key > maxField) maxField = key; + } + LOG_INFO("Player update with ", block.fields.size(), + " fields (max index=", maxField, ")"); + } + + bool slotsChanged = false; + const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); + const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); + const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); + const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { owner_.playerXp_ = val; } + else if (key == ufPlayerNextXp) { owner_.playerNextLevelXp_ = val; } + else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { owner_.playerRestedXp_ = val; } + else if (key == ufPlayerLevel) { + owner_.serverPlayerLevel_ = val; + for (auto& ch : owner_.characters) { + if (ch.guid == owner_.playerGuid) { ch.level = val; break; } + } + } + else if (key == ufCoinage) { + uint64_t oldMoney = owner_.playerMoneyCopper_; + owner_.playerMoneyCopper_ = val; + LOG_DEBUG("Money set from update fields: ", val, " copper"); + if (val != oldMoney) + owner_.fireAddonEvent("PLAYER_MONEY", {}); + } + else if (ufHonor != 0xFFFF && key == ufHonor) { + owner_.playerHonorPoints_ = val; + LOG_DEBUG("Honor points from update fields: ", val); + } + else if (ufArena != 0xFFFF && key == ufArena) { + owner_.playerArenaPoints_ = val; + LOG_DEBUG("Arena points from update fields: ", val); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + owner_.playerArmorRating_ = static_cast(val); + LOG_DEBUG("Armor rating from update fields: ", owner_.playerArmorRating_); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + owner_.playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + owner_.inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + bool wasResting = owner_.isResting_; + owner_.isResting_ = (restStateByte != 0); + if (owner_.isResting_ != wasResting) { + owner_.fireAddonEvent("UPDATE_EXHAUSTION", {}); + owner_.fireAddonEvent("PLAYER_UPDATE_RESTING", {}); + } + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + owner_.chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", owner_.chosenTitleBit_); + } + else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { owner_.playerMeleeAP_ = static_cast(val); } + else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { owner_.playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { + owner_.playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); + } + else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { owner_.playerHealBonus_ = static_cast(val); } + else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&owner_.playerBlockPct_, &val, 4); } + else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&owner_.playerDodgePct_, &val, 4); } + else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&owner_.playerParryPct_, &val, 4); } + else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&owner_.playerCritPct_, &val, 4); } + else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); } + else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { + std::memcpy(&owner_.playerSpellCritPct_[key - ufSCrit1], &val, 4); + } + else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { + owner_.playerCombatRatings_[key - ufRating1] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + owner_.playerStats_[si] = static_cast(val); + break; + } + } + } + // Do not synthesize quest-log entries from raw update-field slots. + // Slot layouts differ on some classic-family realms and can produce + // phantom "already accepted" quests that block quest acceptance. + } + if (owner_.applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) owner_.rebuildOnlineInventory(); + owner_.maybeDetectVisibleItemLayout(); + owner_.extractSkillFields(owner_.lastPlayerFields_); + owner_.extractExploredZoneFields(owner_.lastPlayerFields_); + owner_.applyQuestStateFromFields(owner_.lastPlayerFields_); + } + break; + } + + case UpdateType::VALUES: { + // Update existing entity fields + auto entity = entityManager.getEntity(block.guid); + if (entity) { + if (block.hasMovement) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + + if (block.guid != owner_.playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + owner_.setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (owner_.transportManager_ && owner_.transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + owner_.clearTransportAttachment(block.guid); + } + } + } + + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + if (entity->getType() == ObjectType::PLAYER && block.guid != owner_.playerGuid) { + owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + + // Update cached health/mana/power values (Phase 2) — single pass + if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + uint32_t oldDisplayId = unit->getDisplayId(); + bool displayIdChanged = false; + bool npcDeathNotified = false; + bool npcRespawnNotified = false; + bool healthChanged = false; + bool powerChanged = false; + const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); + const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); + const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); + const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); + const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); + const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); + const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); + const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); + const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); + const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + const uint16_t ufBytes1 = fieldIndex(UF::UNIT_FIELD_BYTES_1); + for (const auto& [key, val] : block.fields) { + if (key == ufHealth) { + uint32_t oldHealth = unit->getHealth(); + unit->setHealth(val); + healthChanged = true; + if (val == 0) { + if (owner_.combatHandler_ && block.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) { + owner_.stopAutoAttack(); + } + if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(block.guid); + if (block.guid == owner_.playerGuid) { + owner_.playerDead_ = true; + owner_.releasedSpirit_ = false; + owner_.stopAutoAttack(); + // Cache death position as corpse location. + // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so + // this is the primary source for canReclaimCorpse(). + // owner_.movementInfo is canonical (x=north, y=west); owner_.corpseX_/Y_ + // are raw server coords (x=west, y=north) — swap axes. + owner_.corpseX_ = owner_.movementInfo.y; // canonical west = server X + owner_.corpseY_ = owner_.movementInfo.x; // canonical north = server Y + owner_.corpseZ_ = owner_.movementInfo.z; + owner_.corpseMapId_ = owner_.currentMapId_; + LOG_INFO("Player died! Corpse position cached at server=(", + owner_.corpseX_, ",", owner_.corpseY_, ",", owner_.corpseZ_, + ") map=", owner_.corpseMapId_); + owner_.fireAddonEvent("PLAYER_DEAD", {}); + } + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (oldHealth == 0 && val > 0) { + if (block.guid == owner_.playerGuid) { + bool wasGhost = owner_.releasedSpirit_; + owner_.playerDead_ = false; + if (!wasGhost) { + LOG_INFO("Player resurrected!"); + owner_.fireAddonEvent("PLAYER_ALIVE", {}); + } else { + LOG_INFO("Player entered ghost form"); + owner_.releasedSpirit_ = false; + owner_.fireAddonEvent("PLAYER_UNGHOST", {}); + } + } + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcRespawnCallback_) { + owner_.npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } + } + // Specific fields checked BEFORE power/maxpower range checks + // (Classic packs maxHealth/level/faction adjacent to power indices) + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); healthChanged = true; } + else if (key == ufBytes0) { + uint8_t oldPT = unit->getPowerType(); + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + if (unit->getPowerType() != oldPT) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + owner_.fireAddonEvent("UNIT_DISPLAYPOWER", {uid}); + } + } else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (ufBytes1 != 0xFFFF && key == ufBytes1 && block.guid == owner_.playerGuid) { + uint8_t newForm = static_cast((val >> 24) & 0xFF); + if (newForm != owner_.shapeshiftFormId_) { + owner_.shapeshiftFormId_ = newForm; + LOG_INFO("Shapeshift form changed: ", static_cast(newForm)); + owner_.fireAddonEvent("UPDATE_SHAPESHIFT_FORM", {}); + owner_.fireAddonEvent("UPDATE_SHAPESHIFT_FORMS", {}); + } + } + else if (key == ufDynFlags) { + uint32_t oldDyn = unit->getDynamicFlags(); + unit->setDynamicFlags(val); + if (block.guid == owner_.playerGuid) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + owner_.playerDead_ = true; + owner_.releasedSpirit_ = false; + owner_.corpseX_ = owner_.movementInfo.y; + owner_.corpseY_ = owner_.movementInfo.x; + owner_.corpseZ_ = owner_.movementInfo.z; + owner_.corpseMapId_ = owner_.currentMapId_; + LOG_INFO("Player died (dynamic flags). Corpse cached map=", owner_.corpseMapId_); + } else if (wasDead && !nowDead) { + owner_.playerDead_ = false; + owner_.releasedSpirit_ = false; + owner_.selfResAvailable_ = false; + LOG_INFO("Player resurrected (dynamic flags)"); + } + } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + if (!npcDeathNotified && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (wasDead && !nowDead) { + if (!npcRespawnNotified && owner_.npcRespawnCallback_) { + owner_.npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } + } + } + } else if (key == ufLevel) { + uint32_t oldLvl = unit->getLevel(); + unit->setLevel(val); + if (val != oldLvl) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + owner_.fireAddonEvent("UNIT_LEVEL", {uid}); + } + if (block.guid != owner_.playerGuid && + entity->getType() == ObjectType::PLAYER && + val > oldLvl && oldLvl > 0 && + owner_.otherPlayerLevelUpCallback_) { + owner_.otherPlayerLevelUpCallback_(block.guid, val); + } + } + else if (key == ufFaction) { + unit->setFactionTemplate(val); + unit->setHostile(owner_.isHostileFaction(val)); + } else if (key == ufDisplayId) { + if (val != unit->getDisplayId()) { + unit->setDisplayId(val); + displayIdChanged = true; + } + } else if (key == ufMountDisplayId) { + if (block.guid == owner_.playerGuid) { + uint32_t old = owner_.currentMountDisplayId_; + owner_.currentMountDisplayId_ = val; + if (val != old && owner_.mountCallback_) owner_.mountCallback_(val); + if (val != old) + owner_.fireAddonEvent("UNIT_MODEL_CHANGED", {"player"}); + if (old == 0 && val != 0) { + owner_.mountAuraSpellId_ = 0; + if (owner_.spellHandler_) for (const auto& a : owner_.spellHandler_->playerAuras_) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.playerGuid) { + owner_.mountAuraSpellId_ = a.spellId; + } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (owner_.mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + owner_.mountAuraSpellId_ = fv; + break; + } + } + } + } + LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", owner_.mountAuraSpellId_); + } + if (old != 0 && val == 0) { + owner_.mountAuraSpellId_ = 0; + if (owner_.spellHandler_) for (auto& a : owner_.spellHandler_->playerAuras_) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; + } + } + unit->setMountDisplayId(val); + } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + powerChanged = true; + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + powerChanged = true; + } + } + + // Fire UNIT_HEALTH / UNIT_POWER events for Lua addons + if ((healthChanged || powerChanged)) { + auto unitId = owner_.guidToUnitId(block.guid); + if (!unitId.empty()) { + if (healthChanged) owner_.fireAddonEvent("UNIT_HEALTH", {unitId}); + if (powerChanged) { + owner_.fireAddonEvent("UNIT_POWER", {unitId}); + // When player power changes, action bar usability may change + if (block.guid == owner_.playerGuid) { + owner_.fireAddonEvent("ACTIONBAR_UPDATE_USABLE", {}); + owner_.fireAddonEvent("SPELL_UPDATE_USABLE", {}); + } + } + } + } + + // Classic: sync owner_.spellHandler_->playerAuras_ from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == owner_.playerGuid && isClassicLikeExpansion() && owner_.spellHandler_) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraUpdate = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } + } + if (hasAuraUpdate) { + owner_.spellHandler_->playerAuras_.clear(); + owner_.spellHandler_->playerAuras_.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = owner_.spellHandler_->playerAuras_[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = owner_.playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); + owner_.fireAddonEvent("UNIT_AURA", {"player"}); + } + } + } + + // Some units/players are created without displayId and get it later via VALUES. + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && + displayIdChanged && + unit->getDisplayId() != 0 && + unit->getDisplayId() != oldDisplayId) { + if (entity->getType() == ObjectType::PLAYER && block.guid == owner_.playerGuid) { + // Skip local player — spawned separately + } else if (entity->getType() == ObjectType::PLAYER) { + if (owner_.playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field owner_.state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + owner_.playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); + } + } + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (owner_.creatureSpawnCallback_) { + float unitScale2 = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale2, &raw, sizeof(float)); + if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; + } + } + } + owner_.creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } + if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && owner_.socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + owner_.socket->send(qsPkt); + } + // Fire UNIT_MODEL_CHANGED for addons that track model swaps + if (owner_.addonEventCallback_) { + std::string uid; + if (block.guid == owner_.targetGuid) uid = "target"; + else if (block.guid == owner_.focusGuid) uid = "focus"; + else if (block.guid == owner_.petGuid_) uid = "pet"; + if (!uid.empty()) + owner_.fireAddonEvent("UNIT_MODEL_CHANGED", {uid}); + } + } + } + // Update XP / owner_.inventory slot / skill fields for player entity + if (block.guid == owner_.playerGuid) { + const bool needCoinageDetectSnapshot = + (owner_.pendingMoneyDelta_ != 0 && owner_.pendingMoneyDeltaTimer_ > 0.0f); + std::map oldFieldsSnapshot; + if (needCoinageDetectSnapshot) { + oldFieldsSnapshot = owner_.lastPlayerFields_; + } + if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + owner_.serverRunSpeed_ = block.runSpeed; + // Some server dismount paths update run speed without updating mount display field. + if (!owner_.onTaxiFlight_ && !owner_.taxiMountActive_ && + owner_.currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { + LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, + " displayId=", owner_.currentMountDisplayId_); + owner_.currentMountDisplayId_ = 0; + if (owner_.mountCallback_) { + owner_.mountCallback_(0); + } + } + } + auto mergeHint = owner_.lastPlayerFields_.end(); + for (const auto& [key, val] : block.fields) { + mergeHint = owner_.lastPlayerFields_.insert_or_assign(mergeHint, key, val); + } + if (needCoinageDetectSnapshot) { + maybeDetectCoinageIndex(oldFieldsSnapshot, owner_.lastPlayerFields_); + } + owner_.maybeDetectVisibleItemLayout(); + owner_.detectInventorySlotBases(block.fields); + bool slotsChanged = false; + const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); + const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); + const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); + const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); + const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES); + const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { + owner_.playerXp_ = val; + LOG_DEBUG("XP updated: ", val); + owner_.fireAddonEvent("PLAYER_XP_UPDATE", {std::to_string(val)}); + } + else if (key == ufPlayerNextXp) { + owner_.playerNextLevelXp_ = val; + LOG_DEBUG("Next level XP updated: ", val); + } + else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { + owner_.playerRestedXp_ = val; + owner_.fireAddonEvent("UPDATE_EXHAUSTION", {}); + } + else if (key == ufPlayerLevel) { + owner_.serverPlayerLevel_ = val; + LOG_DEBUG("Level updated: ", val); + for (auto& ch : owner_.characters) { + if (ch.guid == owner_.playerGuid) { + ch.level = val; + break; + } + } + } + else if (key == ufCoinage) { + uint64_t oldM = owner_.playerMoneyCopper_; + owner_.playerMoneyCopper_ = val; + LOG_DEBUG("Money updated via VALUES: ", val, " copper"); + if (val != oldM) + owner_.fireAddonEvent("PLAYER_MONEY", {}); + } + else if (ufHonorV != 0xFFFF && key == ufHonorV) { + owner_.playerHonorPoints_ = val; + LOG_DEBUG("Honor points updated: ", val); + } + else if (ufArenaV != 0xFFFF && key == ufArenaV) { + owner_.playerArenaPoints_ = val; + LOG_DEBUG("Arena points updated: ", val); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + owner_.playerArmorRating_ = static_cast(val); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + owner_.playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytesV != 0xFFFF && key == ufPBytesV) { + // PLAYER_BYTES changed (barber shop, polymorph, etc.) + // Update the Character struct so owner_.inventory preview refreshes + for (auto& ch : owner_.characters) { + if (ch.guid == owner_.playerGuid) { + ch.appearanceBytes = val; + break; + } + } + if (owner_.appearanceChangedCallback_) + owner_.appearanceChangedCallback_(); + } + else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { + // Byte 0 (bits 0-7): facial hair / piercings + uint8_t facialHair = static_cast(val & 0xFF); + for (auto& ch : owner_.characters) { + if (ch.guid == owner_.playerGuid) { + ch.facialFeatures = facialHair; + break; + } + } + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots), + " facial=", static_cast(facialHair)); + owner_.inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + owner_.isResting_ = (restStateByte != 0); + if (owner_.appearanceChangedCallback_) + owner_.appearanceChangedCallback_(); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + owner_.chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", owner_.chosenTitleBit_); + } + else if (key == ufPlayerFlags) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + bool wasGhost = owner_.releasedSpirit_; + bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; + if (!wasGhost && nowGhost) { + owner_.releasedSpirit_ = true; + LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); + if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(true); + } else if (wasGhost && !nowGhost) { + owner_.releasedSpirit_ = false; + owner_.playerDead_ = false; + owner_.repopPending_ = false; + owner_.resurrectPending_ = false; + owner_.selfResAvailable_ = false; + owner_.corpseMapId_ = 0; // corpse reclaimed + owner_.corpseGuid_ = 0; + owner_.corpseReclaimAvailableMs_ = 0; + LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); + owner_.fireAddonEvent("PLAYER_ALIVE", {}); + if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(false); + } + owner_.fireAddonEvent("PLAYER_FLAGS_CHANGED", {}); + } + else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { owner_.playerMeleeAP_ = static_cast(val); } + else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { owner_.playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { + owner_.playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); + } + else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { owner_.playerHealBonus_ = static_cast(val); } + else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&owner_.playerBlockPct_, &val, 4); } + else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&owner_.playerDodgePct_, &val, 4); } + else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&owner_.playerParryPct_, &val, 4); } + else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&owner_.playerCritPct_, &val, 4); } + else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); } + else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { + std::memcpy(&owner_.playerSpellCritPct_[key - ufSCrit1V], &val, 4); + } + else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { + owner_.playerCombatRatings_[key - ufRating1V] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + owner_.playerStats_[si] = static_cast(val); + break; + } + } + } + } + // Do not auto-create quests from VALUES quest-log slot fields for the + // same reason as CREATE_OBJECT2 above (can be misaligned per realm). + if (owner_.applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) { + owner_.rebuildOnlineInventory(); + owner_.fireAddonEvent("PLAYER_EQUIPMENT_CHANGED", {}); + } + owner_.extractSkillFields(owner_.lastPlayerFields_); + owner_.extractExploredZoneFields(owner_.lastPlayerFields_); + owner_.applyQuestStateFromFields(owner_.lastPlayerFields_); + } + + // Update item stack count / durability for online items + if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { + bool inventoryChanged = false; + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); + const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); + const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); + const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); + // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset + // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). + // Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12). + const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; + const uint16_t itemPermEnchField = itemEnchBase; + const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; + const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF; + const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; + const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; + + auto it = owner_.onlineItems_.find(block.guid); + bool isItemInInventory = (it != owner_.onlineItems_.end()); + + for (const auto& [key, val] : block.fields) { + if (key == itemStackField && isItemInInventory) { + if (it->second.stackCount != val) { + it->second.stackCount = val; + inventoryChanged = true; + } + } else if (key == itemDurField && isItemInInventory) { + if (it->second.curDurability != val) { + const uint32_t prevDur = it->second.curDurability; + it->second.curDurability = val; + inventoryChanged = true; + // Warn once when durability drops below 20% for an equipped item. + const uint32_t maxDur = it->second.maxDurability; + if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { + // Check if this item is in an equip slot (not bag owner_.inventory). + bool isEquipped = false; + for (uint64_t slotGuid : owner_.equipSlotGuids_) { + if (slotGuid == block.guid) { isEquipped = true; break; } + } + if (isEquipped) { + std::string itemName; + const auto* info = owner_.getItemInfo(it->second.entry); + if (info) itemName = info->name; + char buf[128]; + if (!itemName.empty()) + std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str()); + else + std::snprintf(buf, sizeof(buf), "An equipped item is about to break!"); + owner_.addUIError(buf); + owner_.addSystemChatMessage(buf); + } + } + } + } else if (key == itemMaxDurField && isItemInInventory) { + if (it->second.maxDurability != val) { + it->second.maxDurability = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) { + if (it->second.permanentEnchantId != val) { + it->second.permanentEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) { + if (it->second.temporaryEnchantId != val) { + it->second.temporaryEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) { + if (it->second.socketEnchantIds[0] != val) { + it->second.socketEnchantIds[0] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) { + if (it->second.socketEnchantIds[1] != val) { + it->second.socketEnchantIds[1] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) { + if (it->second.socketEnchantIds[2] != val) { + it->second.socketEnchantIds[2] = val; + inventoryChanged = true; + } + } + } + // Update container slot GUIDs on bag content changes + if (entity->getType() == ObjectType::CONTAINER) { + for (const auto& [key, _] : block.fields) { + if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || + (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { + inventoryChanged = true; + break; + } + } + owner_.extractContainerFields(block.guid, block.fields); + } + if (inventoryChanged) { + owner_.rebuildOnlineInventory(); + owner_.fireAddonEvent("BAG_UPDATE", {}); + owner_.fireAddonEvent("UNIT_INVENTORY_CHANGED", {"player"}); + } + } + if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { + if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + owner_.transportMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } else if (owner_.gameObjectMoveCallback_) { + owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } + } + + LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); + } else { + } + break; + } + + case UpdateType::MOVEMENT: { + // Diagnostic: Log if we receive MOVEMENT blocks for transports + if (transportGuids_.count(block.guid)) { + LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); + } + + // Update entity position (server → canonical) + auto entity = entityManager.getEntity(block.guid); + if (entity) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); + + if (block.guid != owner_.playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + owner_.setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (owner_.transportManager_ && owner_.transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + owner_.clearTransportAttachment(block.guid); + } + } + + if (block.guid == owner_.playerGuid) { + owner_.movementInfo.orientation = oCanonical; + + // Track player-on-transport owner_.state from MOVEMENT updates + if (block.onTransport) { + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + owner_.setPlayerOnTransport(block.transportGuid, canonicalOffset); + if (owner_.transportManager_ && owner_.transportManager_->getTransport(owner_.playerTransportGuid_)) { + glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + owner_.movementInfo.x = composed.x; + owner_.movementInfo.y = composed.y; + owner_.movementInfo.z = composed.z; + } else { + owner_.movementInfo.x = pos.x; + owner_.movementInfo.y = pos.y; + owner_.movementInfo.z = pos.z; + } + LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, owner_.playerTransportGuid_, std::dec); + } else { + owner_.movementInfo.x = pos.x; + owner_.movementInfo.y = pos.y; + owner_.movementInfo.z = pos.z; + // Don't clear client-side M2 transport boarding + bool isClientM2Transport = false; + if (owner_.playerTransportGuid_ != 0 && owner_.transportManager_) { + auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (owner_.playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport (MOVEMENT)"); + owner_.clearPlayerTransport(); + } + } + } + + // Fire transport move callback if this is a known transport + if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + owner_.transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); + } + // Fire move callback for non-transport gameobjects. + if (entity->getType() == ObjectType::GAMEOBJECT && + transportGuids_.count(block.guid) == 0 && + owner_.gameObjectMoveCallback_) { + owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } + // Fire move callback for non-player units (creatures). + // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many + // servers (especially vanilla/Turtle WoW) communicate NPC positions + // via MOVEMENT blocks instead. Use duration=0 for an instant snap. + if (block.guid != owner_.playerGuid && + entity->getType() == ObjectType::UNIT && + transportGuids_.count(block.guid) == 0 && + owner_.creatureMoveCallback_) { + owner_.creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); + } + } else { + LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); + } + break; + } + + default: + break; + } +} + +void EntityController::finalizeUpdateObjectBatch(bool newItemCreated) { + owner_.tabCycleStale = true; + // Entity count logging disabled + + // Deferred rebuild: if new item objects were created in this packet, rebuild + // owner_.inventory so that slot GUIDs updated earlier in the same packet can resolve. + if (newItemCreated) { + owner_.rebuildOnlineInventory(); + } + + // Late owner_.inventory base detection once items are known + if (owner_.playerGuid != 0 && owner_.invSlotBase_ < 0 && !owner_.lastPlayerFields_.empty() && !owner_.onlineItems_.empty()) { + owner_.detectInventorySlotBases(owner_.lastPlayerFields_); + if (owner_.invSlotBase_ >= 0) { + if (owner_.applyInventoryFields(owner_.lastPlayerFields_)) { + owner_.rebuildOnlineInventory(); + } + } + } +} + +void EntityController::handleCompressedUpdateObject(network::Packet& packet) { + LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize()); + + // First 4 bytes = decompressed size + if (packet.getSize() < 4) { + LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small"); + return; + } + + uint32_t decompressedSize = packet.readUInt32(); + LOG_DEBUG(" Decompressed size: ", decompressedSize); + + // Capital cities and large raids can produce very large update packets. + // The real WoW client handles up to ~10MB; 5MB covers all practical cases. + if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) { + LOG_WARNING("Invalid decompressed size: ", decompressedSize); + return; + } + + // Remaining data is zlib compressed + size_t compressedSize = packet.getRemainingSize(); + const uint8_t* compressedData = packet.getData().data() + packet.getReadPos(); + + // Decompress + std::vector decompressed(decompressedSize); + uLongf destLen = decompressedSize; + int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize); + + if (ret != Z_OK) { + LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret); + return; + } + + // Create packet from decompressed data and parse it + network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed); + handleUpdateObject(decompressedPacket); +} +void EntityController::handleDestroyObject(network::Packet& packet) { + LOG_DEBUG("Handling SMSG_DESTROY_OBJECT"); + + DestroyObjectData data; + if (!DestroyObjectParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT"); + return; + } + + // Remove entity + if (entityManager.hasEntity(data.guid)) { + if (transportGuids_.count(data.guid) > 0) { + const bool playerAboardNow = (owner_.playerTransportGuid_ == data.guid); + const bool stickyAboard = (owner_.playerTransportStickyGuid_ == data.guid && owner_.playerTransportStickyTimer_ > 0.0f); + const bool movementSaysAboard = (owner_.movementInfo.transportGuid == data.guid); + if (playerAboardNow || stickyAboard || movementSaysAboard) { + serverUpdatedTransportGuids_.erase(data.guid); + LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec, + " now=", playerAboardNow, + " sticky=", stickyAboard, + " movement=", movementSaysAboard); + return; + } + } + // Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal. + auto entity = entityManager.getEntity(data.guid); + if (entity) { + if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { + owner_.creatureDespawnCallback_(data.guid); + } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { + // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. + owner_.playerDespawnCallback_(data.guid); + owner_.otherPlayerVisibleItemEntries_.erase(data.guid); + owner_.otherPlayerVisibleDirty_.erase(data.guid); + owner_.otherPlayerMoveTimeMs_.erase(data.guid); + owner_.inspectedPlayerItemEntries_.erase(data.guid); + owner_.pendingAutoInspect_.erase(data.guid); + pendingNameQueries.erase(data.guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) { + owner_.gameObjectDespawnCallback_(data.guid); + } + } + if (transportGuids_.count(data.guid) > 0) { + transportGuids_.erase(data.guid); + serverUpdatedTransportGuids_.erase(data.guid); + if (owner_.playerTransportGuid_ == data.guid) { + owner_.clearPlayerTransport(); + } + } + owner_.clearTransportAttachment(data.guid); + entityManager.removeEntity(data.guid); + LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec, + " (", (data.isDeath ? "death" : "despawn"), ")"); + } else { + LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec); + } + + // Clean up auto-attack and target if destroyed entity was our target + if (owner_.combatHandler_ && data.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) { + owner_.stopAutoAttack(); + } + if (data.guid == owner_.targetGuid) { + owner_.targetGuid = 0; + } + if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(data.guid); + + // Remove online item/container tracking + owner_.containerContents_.erase(data.guid); + if (owner_.onlineItems_.erase(data.guid)) { + owner_.rebuildOnlineInventory(); + } + + // Clean up quest giver status + owner_.npcQuestStatus_.erase(data.guid); + + // Remove combat text entries referencing the destroyed entity so floating + // damage numbers don't linger after the source/target despawns. + if (owner_.combatHandler_) owner_.combatHandler_->removeCombatTextForGuid(data.guid); + + // Clean up unit cast owner_.state (cast bar) for the destroyed unit + if (owner_.spellHandler_) owner_.spellHandler_->unitCastStates_.erase(data.guid); + // Clean up cached auras + if (owner_.spellHandler_) owner_.spellHandler_->unitAurasCache_.erase(data.guid); + + owner_.tabCycleStale = true; +} + +// Name Queries +// ============================================================ + +void EntityController::queryPlayerName(uint64_t guid) { + // If already cached, apply the name to the entity (handles entity recreation after + // moving out/in range — the entity object is new but the cached name is valid). + auto cacheIt = playerNameCache.find(guid); + if (cacheIt != playerNameCache.end()) { + auto entity = entityManager.getEntity(guid); + if (entity && entity->getType() == ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) { + player->setName(cacheIt->second); + } + } + return; + } + if (pendingNameQueries.count(guid)) return; + if (!owner_.isInWorld()) { + LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec, + " owner_.state=", worldStateName(owner_.state), " owner_.socket=", (owner_.socket ? "yes" : "no")); + return; + } + + LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec); + pendingNameQueries.insert(guid); + auto packet = NameQueryPacket::build(guid); + owner_.socket->send(packet); +} + +void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { + if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return; + if (!owner_.isInWorld()) return; + + pendingCreatureQueries.insert(entry); + auto packet = CreatureQueryPacket::build(entry, guid); + owner_.socket->send(packet); +} + +void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) { + if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return; + if (!owner_.isInWorld()) return; + + pendingGameObjectQueries_.insert(entry); + auto packet = GameObjectQueryPacket::build(entry, guid); + owner_.socket->send(packet); +} + +std::string EntityController::getCachedPlayerName(uint64_t guid) const { + return std::string(lookupName(guid)); +} + +std::string EntityController::getCachedCreatureName(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.name : ""; +} +void EntityController::handleNameQueryResponse(network::Packet& packet) { + NameQueryResponseData data; + if (!owner_.packetParsers_ || !owner_.packetParsers_->parseNameQueryResponse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")"); + return; + } + + pendingNameQueries.erase(data.guid); + + LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec, + " found=", static_cast(data.found), " name='", data.name, "'", + " race=", static_cast(data.race), " class=", static_cast(data.classId)); + + if (data.isValid()) { + playerNameCache[data.guid] = data.name; + // Cache class/race from name query for UnitClass/UnitRace fallback + if (data.classId != 0 || data.race != 0) { + playerClassRaceCache_[data.guid] = {data.classId, data.race}; + } + // Update entity name + auto entity = entityManager.getEntity(data.guid); + if (entity && entity->getType() == ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + player->setName(data.name); + } + + // Backfill chat history entries that arrived before we knew the name. + if (owner_.chatHandler_) { + for (auto& msg : owner_.chatHandler_->getChatHistory()) { + if (msg.senderGuid == data.guid && msg.senderName.empty()) { + msg.senderName = data.name; + } + } + } + + // Backfill mail inbox sender names + for (auto& mail : owner_.mailInbox_) { + if (mail.messageType == 0 && mail.senderGuid == data.guid) { + mail.senderName = data.name; + } + } + + // Backfill friend list: if this GUID came from a friend list packet, + // register the name in owner_.friendsCache now that we know it. + if (owner_.friendGuids_.count(data.guid)) { + owner_.friendsCache[data.name] = data.guid; + } + + // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available + if (owner_.addonEventCallback_) { + std::string unitId; + if (data.guid == owner_.targetGuid) unitId = "target"; + else if (data.guid == owner_.focusGuid) unitId = "focus"; + else if (data.guid == owner_.playerGuid) unitId = "player"; + if (!unitId.empty()) + owner_.fireAddonEvent("UNIT_NAME_UPDATE", {unitId}); + } + } +} + +void EntityController::handleCreatureQueryResponse(network::Packet& packet) { + CreatureQueryResponseData data; + if (!owner_.packetParsers_->parseCreatureQueryResponse(packet, data)) return; + + pendingCreatureQueries.erase(data.entry); + + if (data.isValid()) { + creatureInfoCache[data.entry] = data; + // Update all unit entities with this entry + for (auto& [guid, entity] : entityManager.getEntities()) { + if (entity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (unit->getEntry() == data.entry) { + unit->setName(data.name); + } + } + } + } +} + +// ============================================================ +// GameObject Query +// ============================================================ + +void EntityController::handleGameObjectQueryResponse(network::Packet& packet) { + GameObjectQueryResponseData data; + bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseGameObjectQueryResponse(packet, data) + : GameObjectQueryResponseParser::parse(packet, data); + if (!ok) return; + + pendingGameObjectQueries_.erase(data.entry); + + if (data.isValid()) { + gameObjectInfoCache_[data.entry] = data; + // Update all gameobject entities with this entry + for (auto& [guid, entity] : entityManager.getEntities()) { + if (entity->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (go->getEntry() == data.entry) { + go->setName(data.name); + } + } + } + + // MO_TRANSPORT (type 15): assign TaxiPathNode path if available + if (data.type == 15 && data.hasData && data.data[0] != 0 && owner_.transportManager_) { + uint32_t taxiPathId = data.data[0]; + if (owner_.transportManager_->hasTaxiPath(taxiPathId)) { + if (owner_.transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) { + LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId); + } + } else { + LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId, + " not found in TaxiPathNode.dbc"); + } + } + } +} + +void EntityController::handleGameObjectPageText(network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint64_t guid = packet.readUInt64(); + auto entity = entityManager.getEntity(guid); + if (!entity || entity->getType() != ObjectType::GAMEOBJECT) return; + + auto go = std::static_pointer_cast(entity); + uint32_t entry = go->getEntry(); + if (entry == 0) return; + + auto cacheIt = gameObjectInfoCache_.find(entry); + if (cacheIt == gameObjectInfoCache_.end()) { + queryGameObjectInfo(entry, guid); + return; + } + + const GameObjectQueryResponseData& info = cacheIt->second; + uint32_t pageId = 0; + // AzerothCore layout: + // type 9 (TEXT): data[0]=pageID + // type 10 (GOOBER): data[7]=pageId + if (info.type == 9) pageId = info.data[0]; + else if (info.type == 10) pageId = info.data[7]; + + if (pageId != 0 && owner_.socket && owner_.state == WorldState::IN_WORLD) { + owner_.bookPages_.clear(); // start a fresh book for this interaction + auto req = PageTextQueryPacket::build(pageId, guid); + owner_.socket->send(req); + return; + } + + if (!info.name.empty()) { + owner_.addSystemChatMessage(info.name); + } +} + +void EntityController::handlePageTextQueryResponse(network::Packet& packet) { + PageTextQueryResponseData data; + if (!PageTextQueryResponseParser::parse(packet, data)) return; + + if (!data.isValid()) return; + + // Append page if not already collected + bool alreadyHave = false; + for (const auto& bp : owner_.bookPages_) { + if (bp.pageId == data.pageId) { alreadyHave = true; break; } + } + if (!alreadyHave) { + owner_.bookPages_.push_back({data.pageId, data.text}); + } + + // Follow the chain: if there's a next page we haven't fetched yet, request it + if (data.nextPageId != 0) { + bool nextHave = false; + for (const auto& bp : owner_.bookPages_) { + if (bp.pageId == data.nextPageId) { nextHave = true; break; } + } + if (!nextHave && owner_.socket && owner_.state == WorldState::IN_WORLD) { + auto req = PageTextQueryPacket::build(data.nextPageId, owner_.playerGuid); + owner_.socket->send(req); + } + } + LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, + " nextPage=", data.nextPageId, + " totalPages=", owner_.bookPages_.size()); +} + + +} // namespace game +} // namespace wowee diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6b2f6f8c..981be6f8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -677,6 +677,7 @@ GameHandler::GameHandler() { wardenModuleManager_ = std::make_unique(); // Initialize domain handlers + entityController_ = std::make_unique(*this); chatHandler_ = std::make_unique(*this); movementHandler_ = std::make_unique(*this); combatHandler_ = std::make_unique(*this); @@ -791,18 +792,11 @@ void GameHandler::disconnect() { socket.reset(); } activeCharacterGuid_ = 0; - playerNameCache.clear(); - pendingNameQueries.clear(); guildNameCache_.clear(); pendingGuildNameQueries_.clear(); friendGuids_.clear(); contacts_.clear(); transportAttachments_.clear(); - serverUpdatedTransportGuids_.clear(); - // Clear in-flight query sets so reconnect can re-issue queries for any - // entries whose responses were lost during the disconnect. - pendingCreatureQueries.clear(); - pendingGameObjectQueries_.clear(); requiresWarden_ = false; wardenGateSeen_ = false; wardenGateElapsed_ = 0.0f; @@ -817,9 +811,8 @@ void GameHandler::disconnect() { wardenModuleData_.clear(); wardenLoadedModule_.reset(); pendingIncomingPackets_.clear(); - pendingUpdateObjectWork_.clear(); // Fire despawn callbacks so the renderer releases M2/character model resources. - for (const auto& [guid, entity] : entityManager.getEntities()) { + for (const auto& [guid, entity] : entityController_->getEntityManager().getEntities()) { if (guid == playerGuid) continue; if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) creatureDespawnCallback_(guid); @@ -834,7 +827,7 @@ void GameHandler::disconnect() { if (spellHandler_) spellHandler_->unitCastStates_.clear(); if (spellHandler_) spellHandler_->unitAurasCache_.clear(); if (combatHandler_) combatHandler_->clearCombatText(); - entityManager.clear(); + entityController_->clearAll(); setState(WorldState::DISCONNECTED); LOG_INFO("Disconnected from world server"); } @@ -935,14 +928,13 @@ void GameHandler::updateNetworking(float deltaTime) { // Detect server-side disconnect (socket closed during update) if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) { - if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + if (pendingIncomingPackets_.empty() && !entityController_->hasPendingUpdateObjectWork()) { LOG_WARNING("Server closed connection in state: ", worldStateName(state)); disconnect(); return; } LOG_DEBUG("World socket closed with ", pendingIncomingPackets_.size(), - " queued packet(s) and ", pendingUpdateObjectWork_.size(), - " update-object batch(es) pending dispatch"); + " queued packet(s) and update-object batch(es) pending dispatch"); } // Post-gate visibility: determine whether server goes silent or closes after Warden requirement. @@ -976,7 +968,7 @@ if (playerTransportStickyTimer_ > 0.0f) { // Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared if (onTaxiFlight_) { updateClientTaxi(deltaTime); - auto playerEntity = entityManager.getEntity(playerGuid); + auto playerEntity = entityController_->getEntityManager().getEntity(playerGuid); auto unit = std::dynamic_pointer_cast(playerEntity); if (unit && (unit->getUnitFlags() & 0x00000100) == 0 && @@ -1008,7 +1000,7 @@ if (onTaxiFlight_) { // Guard against transient taxi-state flicker. if (!onTaxiFlight_ && taxiMountActive_) { bool serverStillTaxi = false; - auto playerEntity = entityManager.getEntity(playerGuid); + auto playerEntity = entityController_->getEntityManager().getEntity(playerGuid); auto playerUnit = std::dynamic_pointer_cast(playerEntity); if (playerUnit) { serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0; @@ -1035,7 +1027,7 @@ if (!onTaxiFlight_ && taxiMountActive_) { // Some server paths don't emit explicit mount field updates in lockstep // with local visual state changes, so reconcile continuously. if (!onTaxiFlight_ && !taxiMountActive_) { - auto playerEntity = entityManager.getEntity(playerGuid); + auto playerEntity = entityController_->getEntityManager().getEntity(playerGuid); auto playerUnit = std::dynamic_pointer_cast(playerEntity); if (playerUnit) { uint32_t serverMountDisplayId = playerUnit->getMountDisplayId(); @@ -1051,7 +1043,7 @@ if (!onTaxiFlight_ && !taxiMountActive_) { } if (taxiRecoverPending_ && state == WorldState::IN_WORLD) { - auto playerEntity = entityManager.getEntity(playerGuid); + auto playerEntity = entityController_->getEntityManager().getEntity(playerGuid); if (playerEntity) { playerEntity->setPosition(taxiRecoverPos_.x, taxiRecoverPos_.y, taxiRecoverPos_.z, movementInfo.orientation); @@ -1101,10 +1093,10 @@ void GameHandler::updateEntityInterpolation(float deltaTime) { // Update entity movement interpolation (keeps targeting in sync with visuals) // Only update entities within reasonable distance for performance const float updateRadiusSq = 150.0f * 150.0f; // 150 unit radius -auto playerEntity = entityManager.getEntity(playerGuid); +auto playerEntity = entityController_->getEntityManager().getEntity(playerGuid); glm::vec3 playerPos = playerEntity ? glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()) : glm::vec3(0.0f); -for (auto& [guid, entity] : entityManager.getEntities()) { +for (auto& [guid, entity] : entityController_->getEntityManager().getEntities()) { // Always update player if (guid == playerGuid) { entity->updateMovement(deltaTime); @@ -1216,18 +1208,13 @@ void GameHandler::updateTimers(float deltaTime) { nameResyncTimer += deltaTime; if (nameResyncTimer >= 5.0f) { nameResyncTimer = 0.0f; - for (const auto& [guid, entity] : entityManager.getEntities()) { + for (const auto& [guid, entity] : entityController_->getEntityManager().getEntities()) { if (!entity || entity->getType() != ObjectType::PLAYER) continue; if (guid == playerGuid) continue; auto player = std::static_pointer_cast(entity); if (!player->getName().empty()) continue; - if (playerNameCache.count(guid)) continue; - if (pendingNameQueries.count(guid)) continue; // Player entity exists with empty name and no pending query — resend. - LOG_DEBUG("Name resync: re-querying guid=0x", std::hex, guid, std::dec); - pendingNameQueries.insert(guid); - auto pkt = NameQueryPacket::build(guid); - socket->send(pkt); + entityController_->queryPlayerName(guid); } } } @@ -1281,7 +1268,7 @@ void GameHandler::updateTimers(float deltaTime) { if (isInWorld() && inspectRateLimit_ <= 0.0f && !pendingAutoInspect_.empty()) { uint64_t guid = *pendingAutoInspect_.begin(); pendingAutoInspect_.erase(pendingAutoInspect_.begin()); - if (guid != 0 && guid != playerGuid && entityManager.hasEntity(guid)) { + if (guid != 0 && guid != playerGuid && entityController_->getEntityManager().hasEntity(guid)) { auto pkt = InspectPacket::build(guid); socket->send(pkt); inspectRateLimit_ = 2.0f; // throttle to avoid compositing stutter @@ -1307,13 +1294,13 @@ void GameHandler::update(float deltaTime) { if (!socket) return; // disconnect() may have been called // Validate target still exists - if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) { + if (targetGuid != 0 && !entityController_->getEntityManager().hasEntity(targetGuid)) { clearTarget(); } // Update auto-follow: refresh render position or cancel if entity disappeared if (followTargetGuid_ != 0) { - auto followEnt = entityManager.getEntity(followTargetGuid_); + auto followEnt = entityController_->getEntityManager().getEntity(followTargetGuid_); if (followEnt) { followRenderPos_ = core::coords::canonicalToRender( glm::vec3(followEnt->getX(), followEnt->getY(), followEnt->getZ())); @@ -1458,7 +1445,7 @@ void GameHandler::update(float deltaTime) { updateAutoAttack(deltaTime); auto closeIfTooFar = [&](bool windowOpen, uint64_t npcGuid, auto closeFn, const char* label) { if (!windowOpen || npcGuid == 0) return; - auto npc = entityManager.getEntity(npcGuid); + auto npc = entityController_->getEntityManager().getEntity(npcGuid); if (!npc) return; float dx = movementInfo.x - npc->getX(); float dy = movementInfo.y - npc->getY(); @@ -1526,25 +1513,13 @@ void GameHandler::registerOpcodeHandlers() { registerHandler(Opcode::SMSG_PONG, &GameHandler::handlePong); // ----------------------------------------------------------------------- - // World object updates + // World object updates + entity queries (delegated to EntityController) // ----------------------------------------------------------------------- - dispatchTable_[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) { - LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast(state), " size=", packet.getSize()); - if (state == WorldState::IN_WORLD) handleUpdateObject(packet); - }; - dispatchTable_[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) { - LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(state), " size=", packet.getSize()); - if (state == WorldState::IN_WORLD) handleCompressedUpdateObject(packet); - }; - dispatchTable_[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) { - if (state == WorldState::IN_WORLD) handleDestroyObject(packet); - }; + entityController_->registerOpcodes(dispatchTable_); // ----------------------------------------------------------------------- - // Item push / logout / entity queries + // Item push / logout // ----------------------------------------------------------------------- - registerHandler(Opcode::SMSG_NAME_QUERY_RESPONSE, &GameHandler::handleNameQueryResponse); - registerHandler(Opcode::SMSG_CREATURE_QUERY_RESPONSE, &GameHandler::handleCreatureQueryResponse); registerSkipHandler(Opcode::SMSG_ADDON_INFO); registerSkipHandler(Opcode::SMSG_EXPECTED_SPAM_RECORDS); @@ -2181,10 +2156,7 @@ void GameHandler::registerOpcodeHandlers() { // (SMSG_CHANNEL_LIST → moved to ChatHandler) // (SMSG_GROUP_SET_LEADER → moved to SocialHandler) - // Gameobject / page text - registerHandler(Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE, &GameHandler::handleGameObjectQueryResponse); - registerHandler(Opcode::SMSG_GAMEOBJECT_PAGETEXT, &GameHandler::handleGameObjectPageText); - registerHandler(Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE, &GameHandler::handlePageTextQueryResponse); + // Gameobject / page text (entity queries moved to EntityController::registerOpcodes) dispatchTable_[Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM] = [this](network::Packet& packet) { if (packet.getSize() < 12) return; uint64_t guid = packet.readUInt64(); @@ -2192,7 +2164,7 @@ void GameHandler::registerOpcodeHandlers() { if (gameObjectCustomAnimCallback_) gameObjectCustomAnimCallback_(guid, animId); if (animId == 0) { - auto goEnt = entityManager.getEntity(guid); + auto goEnt = entityController_->getEntityManager().getEntity(guid); if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(goEnt); // Only show fishing message if the bobber belongs to us @@ -2721,7 +2693,7 @@ void GameHandler::registerOpcodeHandlers() { dispatchTable_[Opcode::SMSG_INVALIDATE_PLAYER] = [this](network::Packet& packet) { if (packet.hasRemaining(8)) { uint64_t guid = packet.readUInt64(); - playerNameCache.erase(guid); + entityController_->invalidatePlayerName(guid); } }; // uint32 movieId — we don't play movies; acknowledge immediately. @@ -3174,7 +3146,7 @@ void GameHandler::registerOpcodeHandlers() { if (impTargetGuid == playerGuid) { spawnPos = renderer->getCharacterPosition(); } else { - auto entity = entityManager.getEntity(impTargetGuid); + auto entity = entityController_->getEntityManager().getEntity(impTargetGuid); if (!entity) return; glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); spawnPos = core::coords::canonicalToRender(canonical); @@ -3237,7 +3209,7 @@ void GameHandler::registerOpcodeHandlers() { items[s] = packet.readUInt32(); // Resolve player name - auto ent = entityManager.getEntity(guid); + auto ent = entityController_->getEntityManager().getEntity(guid); std::string playerName = "Target"; if (ent) { auto pl = std::dynamic_pointer_cast(ent); @@ -3323,7 +3295,7 @@ void GameHandler::registerOpcodeHandlers() { if (packet.hasRemaining(8)) { uint64_t mentorGuid = packet.readUInt64(); std::string mentorName; - auto ent = entityManager.getEntity(mentorGuid); + auto ent = entityController_->getEntityManager().getEntity(mentorGuid); if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); if (mentorName.empty()) mentorName = lookupName(mentorGuid); addSystemChatMessage(mentorName.empty() @@ -3416,7 +3388,7 @@ void GameHandler::registerOpcodeHandlers() { /*uint8_t classId =*/ packet.readUInt8(); // Apply display ID to the mirror image unit so it renders correctly if (mirrorGuid != 0 && displayId != 0) { - auto entity = entityManager.getEntity(mirrorGuid); + auto entity = entityController_->getEntityManager().getEntity(mirrorGuid); if (entity) { auto unit = std::dynamic_pointer_cast(entity); if (unit && unit->getDisplayId() == 0) @@ -4204,82 +4176,10 @@ void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { pendingIncomingPackets_.emplace_front(std::move(packet)); } -void GameHandler::enqueueUpdateObjectWork(UpdateObjectData&& data) { - pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); -} - -void GameHandler::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, - float budgetMs) { - if (pendingUpdateObjectWork_.empty()) { - return; - } - - const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(state); - int processedBlocks = 0; - - while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - start).count(); - if (elapsedMs >= budgetMs) { - break; - } - - auto& work = pendingUpdateObjectWork_.front(); - if (!work.outOfRangeProcessed) { - auto outOfRangeStart = std::chrono::steady_clock::now(); - processOutOfRangeObjects(work.data.outOfRangeGuids); - float outOfRangeMs = std::chrono::duration( - std::chrono::steady_clock::now() - outOfRangeStart).count(); - if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { - LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, - "ms guidCount=", work.data.outOfRangeGuids.size()); - } - work.outOfRangeProcessed = true; - } - - while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { - elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - start).count(); - if (elapsedMs >= budgetMs) { - break; - } - - const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; - auto blockStart = std::chrono::steady_clock::now(); - applyUpdateObjectBlock(block, work.newItemCreated); - float blockMs = std::chrono::duration( - std::chrono::steady_clock::now() - blockStart).count(); - if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { - LOG_WARNING("SLOW update-object block apply: ", blockMs, - "ms index=", work.nextBlockIndex, - " type=", static_cast(block.updateType), - " guid=0x", std::hex, block.guid, std::dec, - " objectType=", static_cast(block.objectType), - " fieldCount=", block.fields.size(), - " hasMovement=", block.hasMovement ? 1 : 0); - } - ++work.nextBlockIndex; - ++processedBlocks; - } - - if (work.nextBlockIndex >= work.data.blocks.size()) { - finalizeUpdateObjectBatch(work.newItemCreated); - pendingUpdateObjectWork_.pop_front(); - continue; - } - break; - } - - if (!pendingUpdateObjectWork_.empty()) { - const auto& work = pendingUpdateObjectWork_.front(); - LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", - pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, - "/", work.data.blocks.size(), ", state=", worldStateName(state), ")"); - } -} +// enqueueUpdateObjectWork and processPendingUpdateObjectWork moved to EntityController void GameHandler::processQueuedIncomingPackets() { - if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + if (pendingIncomingPackets_.empty() && !entityController_->hasPendingUpdateObjectWork()) { return; } @@ -4295,9 +4195,9 @@ void GameHandler::processQueuedIncomingPackets() { break; } - if (!pendingUpdateObjectWork_.empty()) { - processPendingUpdateObjectWork(start, budgetMs); - if (!pendingUpdateObjectWork_.empty()) { + if (entityController_->hasPendingUpdateObjectWork()) { + entityController_->processPendingUpdateObjectWork(start, budgetMs); + if (entityController_->hasPendingUpdateObjectWork()) { break; } continue; @@ -4328,7 +4228,7 @@ void GameHandler::processQueuedIncomingPackets() { ++processed; } - if (!pendingUpdateObjectWork_.empty()) { + if (entityController_->hasPendingUpdateObjectWork()) { return; } @@ -4753,7 +4653,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { focusGuid = 0; lastTargetGuid = 0; tabCycleStale = true; - entityManager = EntityManager(); + entityController_->clearAll(); // Build CMSG_PLAYER_LOGIN packet auto packet = PlayerLoginPacket::build(characterGuid); @@ -5396,1737 +5296,9 @@ void GameHandler::setOrientation(float orientation) { if (movementHandler_) movementHandler_->setOrientation(orientation); } -void GameHandler::handleUpdateObject(network::Packet& packet) { - UpdateObjectData data; - if (!packetParsers_->parseUpdateObject(packet, data)) { - static int updateObjErrors = 0; - if (++updateObjErrors <= 5) - LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); - if (data.blocks.empty()) return; - // Fall through: process any blocks that were successfully parsed before the failure. - } - - enqueueUpdateObjectWork(std::move(data)); -} - -void GameHandler::processOutOfRangeObjects(const std::vector& guids) { - // Process out-of-range objects first - for (uint64_t guid : guids) { - auto entity = entityManager.getEntity(guid); - if (!entity) continue; - - const bool isKnownTransport = transportGuids_.count(guid) > 0; - if (isKnownTransport) { - // Keep transports alive across out-of-range flapping. - // Boats/zeppelins are global movers and removing them here can make - // them disappear until a later movement snapshot happens to recreate them. - const bool playerAboardNow = (playerTransportGuid_ == guid); - const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (movementInfo.transportGuid == guid); - LOG_INFO("Preserving transport on out-of-range: 0x", - std::hex, guid, std::dec, - " now=", playerAboardNow, - " sticky=", stickyAboard, - " movement=", movementSaysAboard); - continue; - } - - LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); - // Trigger despawn callbacks before removing entity - if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { - creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { - playerDespawnCallback_(guid); - otherPlayerVisibleItemEntries_.erase(guid); - otherPlayerVisibleDirty_.erase(guid); - otherPlayerMoveTimeMs_.erase(guid); - inspectedPlayerItemEntries_.erase(guid); - pendingAutoInspect_.erase(guid); - // Clear pending name query so the query is re-sent when this player - // comes back into range (entity is recreated as a new object). - pendingNameQueries.erase(guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { - gameObjectDespawnCallback_(guid); - } - transportGuids_.erase(guid); - serverUpdatedTransportGuids_.erase(guid); - clearTransportAttachment(guid); - if (playerTransportGuid_ == guid) { - clearPlayerTransport(); - } - entityManager.removeEntity(guid); - } - -} - -void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { - static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); - auto extractPlayerAppearance = [&](const std::map& fields, - uint8_t& outRace, - uint8_t& outGender, - uint32_t& outAppearanceBytes, - uint8_t& outFacial) -> bool { - outRace = 0; - outGender = 0; - outAppearanceBytes = 0; - outFacial = 0; - - auto readField = [&](uint16_t idx, uint32_t& out) -> bool { - if (idx == 0xFFFF) return false; - auto it = fields.find(idx); - if (it == fields.end()) return false; - out = it->second; - return true; - }; - - uint32_t bytes0 = 0; - uint32_t pbytes = 0; - uint32_t pbytes2 = 0; - - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES); - const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2); - - bool haveBytes0 = readField(ufBytes0, bytes0); - bool havePbytes = readField(ufPbytes, pbytes); - bool havePbytes2 = readField(ufPbytes2, pbytes2); - - // Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing, - // try to locate plausible packed fields by scanning. - if (!haveBytes0) { - for (const auto& [idx, v] : fields) { - uint8_t race = static_cast(v & 0xFF); - uint8_t cls = static_cast((v >> 8) & 0xFF); - uint8_t gender = static_cast((v >> 16) & 0xFF); - uint8_t power = static_cast((v >> 24) & 0xFF); - if (race >= 1 && race <= 20 && - cls >= 1 && cls <= 20 && - gender <= 1 && - power <= 10) { - bytes0 = v; - haveBytes0 = true; - break; - } - } - } - if (!havePbytes) { - for (const auto& [idx, v] : fields) { - uint8_t skin = static_cast(v & 0xFF); - uint8_t face = static_cast((v >> 8) & 0xFF); - uint8_t hair = static_cast((v >> 16) & 0xFF); - uint8_t color = static_cast((v >> 24) & 0xFF); - if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) { - pbytes = v; - havePbytes = true; - break; - } - } - } - if (!havePbytes2) { - for (const auto& [idx, v] : fields) { - uint8_t facial = static_cast(v & 0xFF); - if (facial <= 100) { - pbytes2 = v; - havePbytes2 = true; - break; - } - } - } - - if (!haveBytes0 || !havePbytes) return false; - - outRace = static_cast(bytes0 & 0xFF); - outGender = static_cast((bytes0 >> 16) & 0xFF); - outAppearanceBytes = pbytes; - outFacial = havePbytes2 ? static_cast(pbytes2 & 0xFF) : 0; - return true; - }; - - auto maybeDetectCoinageIndex = [&](const std::map& oldFields, - const std::map& newFields) { - if (pendingMoneyDelta_ == 0 || pendingMoneyDeltaTimer_ <= 0.0f) return; - if (oldFields.empty() || newFields.empty()) return; - - constexpr uint32_t kMaxPlausibleCoinage = 2147483647u; - std::vector candidates; - candidates.reserve(8); - - for (const auto& [idx, newVal] : newFields) { - auto itOld = oldFields.find(idx); - if (itOld == oldFields.end()) continue; - uint32_t oldVal = itOld->second; - if (newVal < oldVal) continue; - uint32_t delta = newVal - oldVal; - if (delta != pendingMoneyDelta_) continue; - if (newVal > kMaxPlausibleCoinage) continue; - candidates.push_back(idx); - } - - if (candidates.empty()) return; - - uint16_t current = fieldIndex(UF::PLAYER_FIELD_COINAGE); - uint16_t chosen = candidates[0]; - if (std::find(candidates.begin(), candidates.end(), current) != candidates.end()) { - chosen = current; - } else { - std::sort(candidates.begin(), candidates.end()); - chosen = candidates[0]; - } - - if (chosen != current && current != 0xFFFF) { - updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen); - LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")"); - } - - pendingMoneyDelta_ = 0; - pendingMoneyDeltaTimer_ = 0.0f; - }; - - switch (block.updateType) { - case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - // Create new entity - std::shared_ptr entity; - - switch (block.objectType) { - case ObjectType::PLAYER: - entity = std::make_shared(block.guid); - break; - - case ObjectType::UNIT: - entity = std::make_shared(block.guid); - break; - - case ObjectType::GAMEOBJECT: - entity = std::make_shared(block.guid); - break; - - default: - entity = std::make_shared(block.guid); - entity->setType(block.objectType); - break; - } - - // Set position from movement block (server → canonical) - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - } - // Track player-on-transport state - if (block.guid == playerGuid) { - if (block.onTransport) { - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); - setPlayerOnTransport(block.transportGuid, canonicalOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } - LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, - " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); - } else { - // Don't clear client-side M2 transport boarding (trams) — - // the server doesn't know about client-detected transport attachment. - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); - } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport"); - clearPlayerTransport(); - } - } - } - - // Track transport-relative children so they follow parent transport motion. - if (block.guid != playerGuid && - (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, block.objectType, block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - } - - // Set fields - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - // Add to manager - entityManager.addEntity(block.guid, entity); - - // For the local player, capture the full initial field state (CREATE_OBJECT carries the - // large baseline update-field set, including visible item fields on many cores). - // Later VALUES updates often only include deltas and may never touch visible item fields. - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - lastPlayerFields_ = entity->getFields(); - maybeDetectVisibleItemLayout(); - } - - // Auto-query names (Phase 1) - if (block.objectType == ObjectType::PLAYER) { - queryPlayerName(block.guid); - if (block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - } else if (block.objectType == ObjectType::UNIT) { - auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (it != block.fields.end() && it->second != 0) { - auto unit = std::static_pointer_cast(entity); - unit->setEntry(it->second); - // Set name from cache immediately if available - std::string cached = getCachedCreatureName(it->second); - if (!cached.empty()) { - unit->setName(cached); - } - queryCreatureInfo(it->second, block.guid); - } - } - - // Extract health/mana/power from fields (Phase 2) — single pass - if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - bool unitInitiallyDead = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - for (const auto& [key, val] : block.fields) { - // Check all specific fields BEFORE power/maxpower range checks. - // In Classic, power indices (23-27) are adjacent to maxHealth (28), - // and maxPower indices (29-33) are adjacent to level (34) and faction (35). - // A range check like "key >= powerBase && key < powerBase+7" would - // incorrectly capture maxHealth/level/faction in Classic's tight layout. - if (key == ufHealth) { - unit->setHealth(val); - if (block.objectType == ObjectType::UNIT && val == 0) { - unitInitiallyDead = true; - } - if (block.guid == playerGuid && val == 0) { - playerDead_ = true; - LOG_INFO("Player logged in dead"); - } - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufLevel) { - unit->setLevel(val); - } else if (key == ufFaction) { - unit->setFactionTemplate(val); - if (addonEventCallback_) { - auto uid = guidToUnitId(block.guid); - if (!uid.empty()) - fireAddonEvent("UNIT_FACTION", {uid}); - } - } - else if (key == ufFlags) { - unit->setUnitFlags(val); - if (addonEventCallback_) { - auto uid = guidToUnitId(block.guid); - if (!uid.empty()) - fireAddonEvent("UNIT_FLAGS", {uid}); - } - } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufDisplayId) { - unit->setDisplayId(val); - if (addonEventCallback_) { - auto uid = guidToUnitId(block.guid); - if (!uid.empty()) - fireAddonEvent("UNIT_MODEL_CHANGED", {uid}); - } - } - else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - else if (key == ufDynFlags) { - unit->setDynamicFlags(val); - if (block.objectType == ObjectType::UNIT && - ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { - unitInitiallyDead = true; - } - } - // Power/maxpower range checks AFTER all specific fields - else if (key >= ufPowerBase && key < ufPowerBase + 7) { - unit->setPowerByType(static_cast(key - ufPowerBase), val); - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); - } - else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (val != old) - fireAddonEvent("UNIT_MODEL_CHANGED", {"player"}); - if (old == 0 && val != 0) { - // Just mounted — find the mount aura (indefinite duration, self-cast) - mountAuraSpellId_ = 0; - if (spellHandler_) for (const auto& a : spellHandler_->playerAuras_) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } - } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } - } - } - } - LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - if (spellHandler_) for (auto& a : spellHandler_->playerAuras_) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; - } - } - unit->setMountDisplayId(val); - } - } - if (block.guid == playerGuid) { - constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; - if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { - onTaxiFlight_ = true; - taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); - sanitizeMovementForTaxi(); - if (movementHandler_) movementHandler_->applyTaxiMountForCurrentNode(); - } - } - if (block.guid == playerGuid && - (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { - playerDead_ = true; - LOG_INFO("Player logged in dead (dynamic flags)"); - } - // Detect ghost state on login via PLAYER_FLAGS - if (block.guid == playerGuid) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); - if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { - releasedSpirit_ = true; - playerDead_ = true; - LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); - if (ghostStateCallback_) ghostStateCallback_(true); - // Query corpse position so minimap marker is accurate on reconnect - if (socket) { - network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); - socket->send(cq); - } - } - } - // Classic: rebuild spellHandler_->playerAuras_ from UNIT_FIELD_AURAS on initial object create - if (block.guid == playerGuid && isClassicLikeExpansion() && spellHandler_) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); - if (ufAuras != 0xFFFF) { - bool hasAuraField = false; - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } - } - if (hasAuraField) { - spellHandler_->playerAuras_.clear(); - spellHandler_->playerAuras_.resize(48); - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - const auto& allFields = entity->getFields(); - for (int slot = 0; slot < 48; ++slot) { - auto it = allFields.find(static_cast(ufAuras + slot)); - if (it != allFields.end() && it->second != 0) { - AuraSlot& a = spellHandler_->playerAuras_[slot]; - a.spellId = it->second; - // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful - // Normalize to WotLK convention: 0x80 = negative (debuff) - uint8_t classicFlag = 0; - if (ufAuraFlags != 0xFFFF) { - auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); - if (fit != allFields.end()) - classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); - } - // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) - a.flags = (classicFlag & 0x02) ? 0x80u : 0u; - a.durationMs = -1; - a.maxDurationMs = -1; - a.casterGuid = playerGuid; - a.receivedAtMs = nowMs; - } - } - LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); - fireAddonEvent("UNIT_AURA", {"player"}); - } - } - } - // Determine hostility from faction template for online creatures. - // Always call isHostileFaction — factionTemplate=0 defaults to hostile - // in the lookup rather than silently staying at the struct default (false). - unit->setHostile(isHostileFaction(unit->getFactionTemplate())); - // Trigger creature spawn callback for units/players with displayId - if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { - LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " has displayId=0 — no spawn (entry=", unit->getEntry(), - " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - } - if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { - if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately via spawnPlayerCharacter() - } else if (block.objectType == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } else { - LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); - } - } - if (unitInitiallyDead && npcDeathCallback_) { - npcDeathCallback_(block.guid); - } - } else if (creatureSpawnCallback_) { - LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " at (", - unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - float unitScale = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&unitScale, &raw, sizeof(float)); - if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; - } - } - } - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); - if (unitInitiallyDead && npcDeathCallback_) { - npcDeathCallback_(block.guid); - } - } - // Initialise swim/walk state from spawn-time movement flags (cold-join fix). - // Without this, an entity already swimming/walking when the client joins - // won't get its animation state set until the next MSG_MOVE_* heartbeat. - if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && - block.guid != playerGuid) { - unitMoveFlagsCallback_(block.guid, block.moveFlags); - } - // Query quest giver status for NPCs with questgiver flag (0x02) - if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); - } - } - } - // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) - if (block.objectType == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); - if (itDisp != block.fields.end()) { - go->setDisplayId(itDisp->second); - } - auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (itEntry != block.fields.end() && itEntry->second != 0) { - go->setEntry(itEntry->second); - auto cacheIt = gameObjectInfoCache_.find(itEntry->second); - if (cacheIt != gameObjectInfoCache_.end()) { - go->setName(cacheIt->second.name); - } - queryGameObjectInfo(itEntry->second, block.guid); - } - // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) - LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, - " entry=", go->getEntry(), " displayId=", go->getDisplayId(), - " updateFlags=0x", std::hex, block.updateFlags, std::dec, - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - if (block.updateFlags & 0x0002) { - transportGuids_.insert(block.guid); - LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, - " entry=", go->getEntry(), - " displayId=", go->getDisplayId(), - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created - } - if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { - float goScale = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&goScale, &raw, sizeof(float)); - if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; - } - } - } - gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), - go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); - } - // Fire transport move callback for transports (position update on re-creation) - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, - go->getX(), go->getY(), go->getZ(), go->getOrientation()); - } - } - // Detect player's own corpse object so we have the position even when - // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). - if (block.objectType == ObjectType::CORPSE && block.hasMovement) { - // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) - uint16_t ownerLowIdx = 6; - auto ownerLowIt = block.fields.find(ownerLowIdx); - uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; - auto ownerHighIt = block.fields.find(ownerLowIdx + 1); - uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; - uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; - if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { - // Server coords from movement block - corpseGuid_ = block.guid; - corpseX_ = block.x; - corpseY_ = block.y; - corpseZ_ = block.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, - " server=(", block.x, ", ", block.y, ", ", block.z, - ") map=", corpseMapId_); - } - } - - // Track online item objects (CONTAINER = bags, also tracked as items) - if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { - auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); - auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); - auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); - const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) - ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; - auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); - auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); - auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end(); - auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end(); - auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); - if (entryIt != block.fields.end() && entryIt->second != 0) { - // Preserve existing info when doing partial updates - OnlineItemInfo info = onlineItems_.count(block.guid) - ? onlineItems_[block.guid] : OnlineItemInfo{}; - info.entry = entryIt->second; - if (stackIt != block.fields.end()) info.stackCount = stackIt->second; - if (durIt != block.fields.end()) info.curDurability = durIt->second; - if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; - if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; - if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; - if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; - if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; - if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second; - auto [itemIt, isNew] = onlineItems_.insert_or_assign(block.guid, info); - if (isNew) newItemCreated = true; - queryItemInfo(info.entry, block.guid); - } - // Extract container slot GUIDs for bags - if (block.objectType == ObjectType::CONTAINER) { - extractContainerFields(block.guid, block.fields); - } - } - - // Extract XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - // Auto-detect coinage index using the previous snapshot vs this full snapshot. - maybeDetectCoinageIndex(lastPlayerFields_, block.fields); - - lastPlayerFields_ = block.fields; - detectInventorySlotBases(block.fields); - - if (kVerboseUpdateObject) { - uint16_t maxField = 0; - for (const auto& [key, _val] : block.fields) { - if (key > maxField) maxField = key; - } - LOG_INFO("Player update with ", block.fields.size(), - " fields (max index=", maxField, ")"); - } - - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); - const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); - const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); - const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); - const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); - const uint16_t ufStats[5] = { - fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), - fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), - fieldIndex(UF::UNIT_FIELD_STAT4) - }; - const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); - const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); - const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); - const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); - const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); - const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); - const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); - const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); - const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); - const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); - const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { playerXp_ = val; } - else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } - else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - for (auto& ch : characters) { - if (ch.guid == playerGuid) { ch.level = val; break; } - } - } - else if (key == ufCoinage) { - uint64_t oldMoney = playerMoneyCopper_; - playerMoneyCopper_ = val; - LOG_DEBUG("Money set from update fields: ", val, " copper"); - if (val != oldMoney) - fireAddonEvent("PLAYER_MONEY", {}); - } - else if (ufHonor != 0xFFFF && key == ufHonor) { - playerHonorPoints_ = val; - LOG_DEBUG("Honor points from update fields: ", val); - } - else if (ufArena != 0xFFFF && key == ufArena) { - playerArenaPoints_ = val; - LOG_DEBUG("Arena points from update fields: ", val); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); - } - else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { - playerResistances_[key - ufArmor - 1] = static_cast(val); - } - else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE - // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY - uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - bool wasResting = isResting_; - isResting_ = (restStateByte != 0); - if (isResting_ != wasResting) { - fireAddonEvent("UPDATE_EXHAUSTION", {}); - fireAddonEvent("PLAYER_UPDATE_RESTING", {}); - } - } - else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { - chosenTitleBit_ = static_cast(val); - LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); - } - else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } - else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } - else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { - playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); - } - else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } - else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } - else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } - else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } - else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } - else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } - else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { - std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); - } - else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { - playerCombatRatings_[key - ufRating1] = static_cast(val); - } - else { - for (int si = 0; si < 5; ++si) { - if (ufStats[si] != 0xFFFF && key == ufStats[si]) { - playerStats_[si] = static_cast(val); - break; - } - } - } - // Do not synthesize quest-log entries from raw update-field slots. - // Slot layouts differ on some classic-family realms and can produce - // phantom "already accepted" quests that block quest acceptance. - } - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - maybeDetectVisibleItemLayout(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - applyQuestStateFromFields(lastPlayerFields_); - } - break; - } - - case UpdateType::VALUES: { - // Update existing entity fields - auto entity = entityManager.getEntity(block.guid); - if (entity) { - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - - if (block.guid != playerGuid && - (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, entity->getType(), block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - } - - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - - // Update cached health/mana/power values (Phase 2) — single pass - if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - uint32_t oldDisplayId = unit->getDisplayId(); - bool displayIdChanged = false; - bool npcDeathNotified = false; - bool npcRespawnNotified = false; - bool healthChanged = false; - bool powerChanged = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - const uint16_t ufBytes1 = fieldIndex(UF::UNIT_FIELD_BYTES_1); - for (const auto& [key, val] : block.fields) { - if (key == ufHealth) { - uint32_t oldHealth = unit->getHealth(); - unit->setHealth(val); - healthChanged = true; - if (val == 0) { - if (combatHandler_ && block.guid == combatHandler_->getAutoAttackTargetGuid()) { - stopAutoAttack(); - } - if (combatHandler_) combatHandler_->removeHostileAttacker(block.guid); - if (block.guid == playerGuid) { - playerDead_ = true; - releasedSpirit_ = false; - stopAutoAttack(); - // Cache death position as corpse location. - // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so - // this is the primary source for canReclaimCorpse(). - // movementInfo is canonical (x=north, y=west); corpseX_/Y_ - // are raw server coords (x=west, y=north) — swap axes. - corpseX_ = movementInfo.y; // canonical west = server X - corpseY_ = movementInfo.x; // canonical north = server Y - corpseZ_ = movementInfo.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Player died! Corpse position cached at server=(", - corpseX_, ",", corpseY_, ",", corpseZ_, - ") map=", corpseMapId_); - fireAddonEvent("PLAYER_DEAD", {}); - } - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (oldHealth == 0 && val > 0) { - if (block.guid == playerGuid) { - bool wasGhost = releasedSpirit_; - playerDead_ = false; - if (!wasGhost) { - LOG_INFO("Player resurrected!"); - fireAddonEvent("PLAYER_ALIVE", {}); - } else { - LOG_INFO("Player entered ghost form"); - releasedSpirit_ = false; - fireAddonEvent("PLAYER_UNGHOST", {}); - } - } - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - // Specific fields checked BEFORE power/maxpower range checks - // (Classic packs maxHealth/level/faction adjacent to power indices) - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); healthChanged = true; } - else if (key == ufBytes0) { - uint8_t oldPT = unit->getPowerType(); - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - if (unit->getPowerType() != oldPT) { - auto uid = guidToUnitId(block.guid); - if (!uid.empty()) - fireAddonEvent("UNIT_DISPLAYPOWER", {uid}); - } - } else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (ufBytes1 != 0xFFFF && key == ufBytes1 && block.guid == playerGuid) { - uint8_t newForm = static_cast((val >> 24) & 0xFF); - if (newForm != shapeshiftFormId_) { - shapeshiftFormId_ = newForm; - LOG_INFO("Shapeshift form changed: ", static_cast(newForm)); - fireAddonEvent("UPDATE_SHAPESHIFT_FORM", {}); - fireAddonEvent("UPDATE_SHAPESHIFT_FORMS", {}); - } - } - else if (key == ufDynFlags) { - uint32_t oldDyn = unit->getDynamicFlags(); - unit->setDynamicFlags(val); - if (block.guid == playerGuid) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - playerDead_ = true; - releasedSpirit_ = false; - corpseX_ = movementInfo.y; - corpseY_ = movementInfo.x; - corpseZ_ = movementInfo.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); - } else if (wasDead && !nowDead) { - playerDead_ = false; - releasedSpirit_ = false; - selfResAvailable_ = false; - LOG_INFO("Player resurrected (dynamic flags)"); - } - } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - if (!npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (wasDead && !nowDead) { - if (!npcRespawnNotified && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - } - } else if (key == ufLevel) { - uint32_t oldLvl = unit->getLevel(); - unit->setLevel(val); - if (val != oldLvl) { - auto uid = guidToUnitId(block.guid); - if (!uid.empty()) - fireAddonEvent("UNIT_LEVEL", {uid}); - } - if (block.guid != playerGuid && - entity->getType() == ObjectType::PLAYER && - val > oldLvl && oldLvl > 0 && - otherPlayerLevelUpCallback_) { - otherPlayerLevelUpCallback_(block.guid, val); - } - } - else if (key == ufFaction) { - unit->setFactionTemplate(val); - unit->setHostile(isHostileFaction(val)); - } else if (key == ufDisplayId) { - if (val != unit->getDisplayId()) { - unit->setDisplayId(val); - displayIdChanged = true; - } - } else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (val != old) - fireAddonEvent("UNIT_MODEL_CHANGED", {"player"}); - if (old == 0 && val != 0) { - mountAuraSpellId_ = 0; - if (spellHandler_) for (const auto& a : spellHandler_->playerAuras_) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } - } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } - } - } - } - LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - if (spellHandler_) for (auto& a : spellHandler_->playerAuras_) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; - } - } - unit->setMountDisplayId(val); - } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - // Power/maxpower range checks AFTER all specific fields - else if (key >= ufPowerBase && key < ufPowerBase + 7) { - unit->setPowerByType(static_cast(key - ufPowerBase), val); - powerChanged = true; - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); - powerChanged = true; - } - } - - // Fire UNIT_HEALTH / UNIT_POWER events for Lua addons - if ((healthChanged || powerChanged)) { - auto unitId = guidToUnitId(block.guid); - if (!unitId.empty()) { - if (healthChanged) fireAddonEvent("UNIT_HEALTH", {unitId}); - if (powerChanged) { - fireAddonEvent("UNIT_POWER", {unitId}); - // When player power changes, action bar usability may change - if (block.guid == playerGuid) { - fireAddonEvent("ACTIONBAR_UPDATE_USABLE", {}); - fireAddonEvent("SPELL_UPDATE_USABLE", {}); - } - } - } - } - - // Classic: sync spellHandler_->playerAuras_ from UNIT_FIELD_AURAS when those fields are updated - if (block.guid == playerGuid && isClassicLikeExpansion() && spellHandler_) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); - if (ufAuras != 0xFFFF) { - bool hasAuraUpdate = false; - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } - } - if (hasAuraUpdate) { - spellHandler_->playerAuras_.clear(); - spellHandler_->playerAuras_.resize(48); - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - const auto& allFields = entity->getFields(); - for (int slot = 0; slot < 48; ++slot) { - auto it = allFields.find(static_cast(ufAuras + slot)); - if (it != allFields.end() && it->second != 0) { - AuraSlot& a = spellHandler_->playerAuras_[slot]; - a.spellId = it->second; - // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - uint8_t aFlag = 0; - if (ufAuraFlags != 0xFFFF) { - auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); - if (fit != allFields.end()) - aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); - } - a.flags = aFlag; - a.durationMs = -1; - a.maxDurationMs = -1; - a.casterGuid = playerGuid; - a.receivedAtMs = nowMs; - } - } - LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); - fireAddonEvent("UNIT_AURA", {"player"}); - } - } - } - - // Some units/players are created without displayId and get it later via VALUES. - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && - displayIdChanged && - unit->getDisplayId() != 0 && - unit->getDisplayId() != oldDisplayId) { - if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately - } else if (entity->getType() == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } else { - LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); - } - } - bool isDeadNow = (unit->getHealth() == 0) || - ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); - if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (creatureSpawnCallback_) { - float unitScale2 = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&unitScale2, &raw, sizeof(float)); - if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; - } - } - } - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); - bool isDeadNow = (unit->getHealth() == 0) || - ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); - if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } - if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); - } - // Fire UNIT_MODEL_CHANGED for addons that track model swaps - if (addonEventCallback_) { - std::string uid; - if (block.guid == targetGuid) uid = "target"; - else if (block.guid == focusGuid) uid = "focus"; - else if (block.guid == petGuid_) uid = "pet"; - if (!uid.empty()) - fireAddonEvent("UNIT_MODEL_CHANGED", {uid}); - } - } - } - // Update XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid) { - const bool needCoinageDetectSnapshot = - (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); - std::map oldFieldsSnapshot; - if (needCoinageDetectSnapshot) { - oldFieldsSnapshot = lastPlayerFields_; - } - if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - // Some server dismount paths update run speed without updating mount display field. - if (!onTaxiFlight_ && !taxiMountActive_ && - currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { - LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, - " displayId=", currentMountDisplayId_); - currentMountDisplayId_ = 0; - if (mountCallback_) { - mountCallback_(0); - } - } - } - auto mergeHint = lastPlayerFields_.end(); - for (const auto& [key, val] : block.fields) { - mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); - } - if (needCoinageDetectSnapshot) { - maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); - } - maybeDetectVisibleItemLayout(); - detectInventorySlotBases(block.fields); - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); - const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); - const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); - const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); - const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES); - const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); - const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); - const uint16_t ufStatsV[5] = { - fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), - fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), - fieldIndex(UF::UNIT_FIELD_STAT4) - }; - const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); - const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); - const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); - const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); - const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); - const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); - const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); - const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); - const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); - const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); - const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { - playerXp_ = val; - LOG_DEBUG("XP updated: ", val); - fireAddonEvent("PLAYER_XP_UPDATE", {std::to_string(val)}); - } - else if (key == ufPlayerNextXp) { - playerNextLevelXp_ = val; - LOG_DEBUG("Next level XP updated: ", val); - } - else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { - playerRestedXp_ = val; - fireAddonEvent("UPDATE_EXHAUSTION", {}); - } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - LOG_DEBUG("Level updated: ", val); - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.level = val; - break; - } - } - } - else if (key == ufCoinage) { - uint64_t oldM = playerMoneyCopper_; - playerMoneyCopper_ = val; - LOG_DEBUG("Money updated via VALUES: ", val, " copper"); - if (val != oldM) - fireAddonEvent("PLAYER_MONEY", {}); - } - else if (ufHonorV != 0xFFFF && key == ufHonorV) { - playerHonorPoints_ = val; - LOG_DEBUG("Honor points updated: ", val); - } - else if (ufArenaV != 0xFFFF && key == ufArenaV) { - playerArenaPoints_ = val; - LOG_DEBUG("Arena points updated: ", val); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - } - else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { - playerResistances_[key - ufArmor - 1] = static_cast(val); - } - else if (ufPBytesV != 0xFFFF && key == ufPBytesV) { - // PLAYER_BYTES changed (barber shop, polymorph, etc.) - // Update the Character struct so inventory preview refreshes - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.appearanceBytes = val; - break; - } - } - if (appearanceChangedCallback_) - appearanceChangedCallback_(); - } - else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { - // Byte 0 (bits 0-7): facial hair / piercings - uint8_t facialHair = static_cast(val & 0xFF); - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.facialFeatures = facialHair; - break; - } - } - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots), - " facial=", static_cast(facialHair)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE - // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY - uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte != 0); - if (appearanceChangedCallback_) - appearanceChangedCallback_(); - } - else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { - chosenTitleBit_ = static_cast(val); - LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); - } - else if (key == ufPlayerFlags) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - bool wasGhost = releasedSpirit_; - bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; - if (!wasGhost && nowGhost) { - releasedSpirit_ = true; - LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); - if (ghostStateCallback_) ghostStateCallback_(true); - } else if (wasGhost && !nowGhost) { - releasedSpirit_ = false; - playerDead_ = false; - repopPending_ = false; - resurrectPending_ = false; - selfResAvailable_ = false; - corpseMapId_ = 0; // corpse reclaimed - corpseGuid_ = 0; - corpseReclaimAvailableMs_ = 0; - LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); - fireAddonEvent("PLAYER_ALIVE", {}); - if (ghostStateCallback_) ghostStateCallback_(false); - } - fireAddonEvent("PLAYER_FLAGS_CHANGED", {}); - } - else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } - else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } - else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { - playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); - } - else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } - else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } - else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } - else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } - else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } - else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } - else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { - std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); - } - else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { - playerCombatRatings_[key - ufRating1V] = static_cast(val); - } - else { - for (int si = 0; si < 5; ++si) { - if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { - playerStats_[si] = static_cast(val); - break; - } - } - } - } - // Do not auto-create quests from VALUES quest-log slot fields for the - // same reason as CREATE_OBJECT2 above (can be misaligned per realm). - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) { - rebuildOnlineInventory(); - fireAddonEvent("PLAYER_EQUIPMENT_CHANGED", {}); - } - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - applyQuestStateFromFields(lastPlayerFields_); - } - - // Update item stack count / durability for online items - if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { - bool inventoryChanged = false; - const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); - const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); - const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); - const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); - const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); - // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset - // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). - // Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12). - const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; - const uint16_t itemPermEnchField = itemEnchBase; - const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; - const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF; - const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; - const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; - - auto it = onlineItems_.find(block.guid); - bool isItemInInventory = (it != onlineItems_.end()); - - for (const auto& [key, val] : block.fields) { - if (key == itemStackField && isItemInInventory) { - if (it->second.stackCount != val) { - it->second.stackCount = val; - inventoryChanged = true; - } - } else if (key == itemDurField && isItemInInventory) { - if (it->second.curDurability != val) { - const uint32_t prevDur = it->second.curDurability; - it->second.curDurability = val; - inventoryChanged = true; - // Warn once when durability drops below 20% for an equipped item. - const uint32_t maxDur = it->second.maxDurability; - if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { - // Check if this item is in an equip slot (not bag inventory). - bool isEquipped = false; - for (uint64_t slotGuid : equipSlotGuids_) { - if (slotGuid == block.guid) { isEquipped = true; break; } - } - if (isEquipped) { - std::string itemName; - const auto* info = getItemInfo(it->second.entry); - if (info) itemName = info->name; - char buf[128]; - if (!itemName.empty()) - std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str()); - else - std::snprintf(buf, sizeof(buf), "An equipped item is about to break!"); - addUIError(buf); - addSystemChatMessage(buf); - } - } - } - } else if (key == itemMaxDurField && isItemInInventory) { - if (it->second.maxDurability != val) { - it->second.maxDurability = val; - inventoryChanged = true; - } - } else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) { - if (it->second.permanentEnchantId != val) { - it->second.permanentEnchantId = val; - inventoryChanged = true; - } - } else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) { - if (it->second.temporaryEnchantId != val) { - it->second.temporaryEnchantId = val; - inventoryChanged = true; - } - } else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) { - if (it->second.socketEnchantIds[0] != val) { - it->second.socketEnchantIds[0] = val; - inventoryChanged = true; - } - } else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) { - if (it->second.socketEnchantIds[1] != val) { - it->second.socketEnchantIds[1] = val; - inventoryChanged = true; - } - } else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) { - if (it->second.socketEnchantIds[2] != val) { - it->second.socketEnchantIds[2] = val; - inventoryChanged = true; - } - } - } - // Update container slot GUIDs on bag content changes - if (entity->getType() == ObjectType::CONTAINER) { - for (const auto& [key, _] : block.fields) { - if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || - (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { - inventoryChanged = true; - break; - } - } - extractContainerFields(block.guid, block.fields); - } - if (inventoryChanged) { - rebuildOnlineInventory(); - fireAddonEvent("BAG_UPDATE", {}); - fireAddonEvent("UNIT_INVENTORY_CHANGED", {"player"}); - } - } - if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } else if (gameObjectMoveCallback_) { - gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } - } - - LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); - } else { - } - break; - } - - case UpdateType::MOVEMENT: { - // Diagnostic: Log if we receive MOVEMENT blocks for transports - if (transportGuids_.count(block.guid)) { - LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); - } - - // Update entity position (server → canonical) - auto entity = entityManager.getEntity(block.guid); - if (entity) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); - - if (block.guid != playerGuid && - (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, entity->getType(), block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - - if (block.guid == playerGuid) { - movementInfo.orientation = oCanonical; - - // Track player-on-transport state from MOVEMENT updates - if (block.onTransport) { - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); - setPlayerOnTransport(block.transportGuid, canonicalOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; - } - LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; - // Don't clear client-side M2 transport boarding - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); - } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport (MOVEMENT)"); - clearPlayerTransport(); - } - } - } - - // Fire transport move callback if this is a known transport - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); - } - // Fire move callback for non-transport gameobjects. - if (entity->getType() == ObjectType::GAMEOBJECT && - transportGuids_.count(block.guid) == 0 && - gameObjectMoveCallback_) { - gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } - // Fire move callback for non-player units (creatures). - // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many - // servers (especially vanilla/Turtle WoW) communicate NPC positions - // via MOVEMENT blocks instead. Use duration=0 for an instant snap. - if (block.guid != playerGuid && - entity->getType() == ObjectType::UNIT && - transportGuids_.count(block.guid) == 0 && - creatureMoveCallback_) { - creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); - } - } else { - LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); - } - break; - } - - default: - break; - } -} - -void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) { - tabCycleStale = true; - // Entity count logging disabled - - // Deferred rebuild: if new item objects were created in this packet, rebuild - // inventory so that slot GUIDs updated earlier in the same packet can resolve. - if (newItemCreated) { - rebuildOnlineInventory(); - } - - // Late inventory base detection once items are known - if (playerGuid != 0 && invSlotBase_ < 0 && !lastPlayerFields_.empty() && !onlineItems_.empty()) { - detectInventorySlotBases(lastPlayerFields_); - if (invSlotBase_ >= 0) { - if (applyInventoryFields(lastPlayerFields_)) { - rebuildOnlineInventory(); - } - } - } -} - -void GameHandler::handleCompressedUpdateObject(network::Packet& packet) { - LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize()); - - // First 4 bytes = decompressed size - if (packet.getSize() < 4) { - LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small"); - return; - } - - uint32_t decompressedSize = packet.readUInt32(); - LOG_DEBUG(" Decompressed size: ", decompressedSize); - - // Capital cities and large raids can produce very large update packets. - // The real WoW client handles up to ~10MB; 5MB covers all practical cases. - if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) { - LOG_WARNING("Invalid decompressed size: ", decompressedSize); - return; - } - - // Remaining data is zlib compressed - size_t compressedSize = packet.getRemainingSize(); - const uint8_t* compressedData = packet.getData().data() + packet.getReadPos(); - - // Decompress - std::vector decompressed(decompressedSize); - uLongf destLen = decompressedSize; - int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize); - - if (ret != Z_OK) { - LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret); - return; - } - - // Create packet from decompressed data and parse it - network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed); - handleUpdateObject(decompressedPacket); -} - -void GameHandler::handleDestroyObject(network::Packet& packet) { - LOG_DEBUG("Handling SMSG_DESTROY_OBJECT"); - - DestroyObjectData data; - if (!DestroyObjectParser::parse(packet, data)) { - LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT"); - return; - } - - // Remove entity - if (entityManager.hasEntity(data.guid)) { - if (transportGuids_.count(data.guid) > 0) { - const bool playerAboardNow = (playerTransportGuid_ == data.guid); - const bool stickyAboard = (playerTransportStickyGuid_ == data.guid && playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (movementInfo.transportGuid == data.guid); - if (playerAboardNow || stickyAboard || movementSaysAboard) { - serverUpdatedTransportGuids_.erase(data.guid); - LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec, - " now=", playerAboardNow, - " sticky=", stickyAboard, - " movement=", movementSaysAboard); - return; - } - } - // Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal. - auto entity = entityManager.getEntity(data.guid); - if (entity) { - if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { - creatureDespawnCallback_(data.guid); - } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { - // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. - playerDespawnCallback_(data.guid); - otherPlayerVisibleItemEntries_.erase(data.guid); - otherPlayerVisibleDirty_.erase(data.guid); - otherPlayerMoveTimeMs_.erase(data.guid); - inspectedPlayerItemEntries_.erase(data.guid); - pendingAutoInspect_.erase(data.guid); - pendingNameQueries.erase(data.guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { - gameObjectDespawnCallback_(data.guid); - } - } - if (transportGuids_.count(data.guid) > 0) { - transportGuids_.erase(data.guid); - serverUpdatedTransportGuids_.erase(data.guid); - if (playerTransportGuid_ == data.guid) { - clearPlayerTransport(); - } - } - clearTransportAttachment(data.guid); - entityManager.removeEntity(data.guid); - LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec, - " (", (data.isDeath ? "death" : "despawn"), ")"); - } else { - LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec); - } - - // Clean up auto-attack and target if destroyed entity was our target - if (combatHandler_ && data.guid == combatHandler_->getAutoAttackTargetGuid()) { - stopAutoAttack(); - } - if (data.guid == targetGuid) { - targetGuid = 0; - } - if (combatHandler_) combatHandler_->removeHostileAttacker(data.guid); - - // Remove online item/container tracking - containerContents_.erase(data.guid); - if (onlineItems_.erase(data.guid)) { - rebuildOnlineInventory(); - } - - // Clean up quest giver status - npcQuestStatus_.erase(data.guid); - - // Remove combat text entries referencing the destroyed entity so floating - // damage numbers don't linger after the source/target despawns. - if (combatHandler_) combatHandler_->removeCombatTextForGuid(data.guid); - - // Clean up unit cast state (cast bar) for the destroyed unit - if (spellHandler_) spellHandler_->unitCastStates_.erase(data.guid); - // Clean up cached auras - if (spellHandler_) spellHandler_->unitAurasCache_.erase(data.guid); - - tabCycleStale = true; -} +// Entity lifecycle methods (handleUpdateObject, processOutOfRangeObjects, +// applyUpdateObjectBlock, finalizeUpdateObjectBatch, handleCompressedUpdateObject, +// handleDestroyObject) moved to EntityController — see entity_controller.cpp void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) { if (chatHandler_) chatHandler_->sendChatMessage(type, message, target); @@ -7452,247 +5624,27 @@ const std::vector& GameHandler::getJoinedChannels() const { } // ============================================================ -// Phase 1: Name Queries +// Phase 1: Name Queries (delegated to EntityController) // ============================================================ void GameHandler::queryPlayerName(uint64_t guid) { - // If already cached, apply the name to the entity (handles entity recreation after - // moving out/in range — the entity object is new but the cached name is valid). - auto cacheIt = playerNameCache.find(guid); - if (cacheIt != playerNameCache.end()) { - auto entity = entityManager.getEntity(guid); - if (entity && entity->getType() == ObjectType::PLAYER) { - auto player = std::static_pointer_cast(entity); - if (player->getName().empty()) { - player->setName(cacheIt->second); - } - } - return; - } - if (pendingNameQueries.count(guid)) return; - if (!isInWorld()) { - LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec, - " state=", worldStateName(state), " socket=", (socket ? "yes" : "no")); - return; - } - - LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec); - pendingNameQueries.insert(guid); - auto packet = NameQueryPacket::build(guid); - socket->send(packet); + if (entityController_) entityController_->queryPlayerName(guid); } void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) { - if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return; - if (!isInWorld()) return; - - pendingCreatureQueries.insert(entry); - auto packet = CreatureQueryPacket::build(entry, guid); - socket->send(packet); + if (entityController_) entityController_->queryCreatureInfo(entry, guid); } void GameHandler::queryGameObjectInfo(uint32_t entry, uint64_t guid) { - if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return; - if (!isInWorld()) return; - - pendingGameObjectQueries_.insert(entry); - auto packet = GameObjectQueryPacket::build(entry, guid); - socket->send(packet); + if (entityController_) entityController_->queryGameObjectInfo(entry, guid); } std::string GameHandler::getCachedPlayerName(uint64_t guid) const { - return std::string(lookupName(guid)); + return entityController_ ? entityController_->getCachedPlayerName(guid) : ""; } std::string GameHandler::getCachedCreatureName(uint32_t entry) const { - auto it = creatureInfoCache.find(entry); - return (it != creatureInfoCache.end()) ? it->second.name : ""; -} - -void GameHandler::handleNameQueryResponse(network::Packet& packet) { - NameQueryResponseData data; - if (!packetParsers_ || !packetParsers_->parseNameQueryResponse(packet, data)) { - LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")"); - return; - } - - pendingNameQueries.erase(data.guid); - - LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec, - " found=", static_cast(data.found), " name='", data.name, "'", - " race=", static_cast(data.race), " class=", static_cast(data.classId)); - - if (data.isValid()) { - playerNameCache[data.guid] = data.name; - // Cache class/race from name query for UnitClass/UnitRace fallback - if (data.classId != 0 || data.race != 0) { - playerClassRaceCache_[data.guid] = {data.classId, data.race}; - } - // Update entity name - auto entity = entityManager.getEntity(data.guid); - if (entity && entity->getType() == ObjectType::PLAYER) { - auto player = std::static_pointer_cast(entity); - player->setName(data.name); - } - - // Backfill chat history entries that arrived before we knew the name. - if (chatHandler_) { - for (auto& msg : chatHandler_->getChatHistory()) { - if (msg.senderGuid == data.guid && msg.senderName.empty()) { - msg.senderName = data.name; - } - } - } - - // Backfill mail inbox sender names - for (auto& mail : mailInbox_) { - if (mail.messageType == 0 && mail.senderGuid == data.guid) { - mail.senderName = data.name; - } - } - - // Backfill friend list: if this GUID came from a friend list packet, - // register the name in friendsCache now that we know it. - if (friendGuids_.count(data.guid)) { - friendsCache[data.name] = data.guid; - } - - // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available - if (addonEventCallback_) { - std::string unitId; - if (data.guid == targetGuid) unitId = "target"; - else if (data.guid == focusGuid) unitId = "focus"; - else if (data.guid == playerGuid) unitId = "player"; - if (!unitId.empty()) - fireAddonEvent("UNIT_NAME_UPDATE", {unitId}); - } - } -} - -void GameHandler::handleCreatureQueryResponse(network::Packet& packet) { - CreatureQueryResponseData data; - if (!packetParsers_->parseCreatureQueryResponse(packet, data)) return; - - pendingCreatureQueries.erase(data.entry); - - if (data.isValid()) { - creatureInfoCache[data.entry] = data; - // Update all unit entities with this entry - for (auto& [guid, entity] : entityManager.getEntities()) { - if (entity->getType() == ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - if (unit->getEntry() == data.entry) { - unit->setName(data.name); - } - } - } - } -} - -// ============================================================ -// GameObject Query -// ============================================================ - -void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) { - GameObjectQueryResponseData data; - bool ok = packetParsers_ ? packetParsers_->parseGameObjectQueryResponse(packet, data) - : GameObjectQueryResponseParser::parse(packet, data); - if (!ok) return; - - pendingGameObjectQueries_.erase(data.entry); - - if (data.isValid()) { - gameObjectInfoCache_[data.entry] = data; - // Update all gameobject entities with this entry - for (auto& [guid, entity] : entityManager.getEntities()) { - if (entity->getType() == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - if (go->getEntry() == data.entry) { - go->setName(data.name); - } - } - } - - // MO_TRANSPORT (type 15): assign TaxiPathNode path if available - if (data.type == 15 && data.hasData && data.data[0] != 0 && transportManager_) { - uint32_t taxiPathId = data.data[0]; - if (transportManager_->hasTaxiPath(taxiPathId)) { - if (transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) { - LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId); - } - } else { - LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId, - " not found in TaxiPathNode.dbc"); - } - } - } -} - -void GameHandler::handleGameObjectPageText(network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint64_t guid = packet.readUInt64(); - auto entity = entityManager.getEntity(guid); - if (!entity || entity->getType() != ObjectType::GAMEOBJECT) return; - - auto go = std::static_pointer_cast(entity); - uint32_t entry = go->getEntry(); - if (entry == 0) return; - - auto cacheIt = gameObjectInfoCache_.find(entry); - if (cacheIt == gameObjectInfoCache_.end()) { - queryGameObjectInfo(entry, guid); - return; - } - - const GameObjectQueryResponseData& info = cacheIt->second; - uint32_t pageId = 0; - // AzerothCore layout: - // type 9 (TEXT): data[0]=pageID - // type 10 (GOOBER): data[7]=pageId - if (info.type == 9) pageId = info.data[0]; - else if (info.type == 10) pageId = info.data[7]; - - if (pageId != 0 && socket && state == WorldState::IN_WORLD) { - bookPages_.clear(); // start a fresh book for this interaction - auto req = PageTextQueryPacket::build(pageId, guid); - socket->send(req); - return; - } - - if (!info.name.empty()) { - addSystemChatMessage(info.name); - } -} - -void GameHandler::handlePageTextQueryResponse(network::Packet& packet) { - PageTextQueryResponseData data; - if (!PageTextQueryResponseParser::parse(packet, data)) return; - - if (!data.isValid()) return; - - // Append page if not already collected - bool alreadyHave = false; - for (const auto& bp : bookPages_) { - if (bp.pageId == data.pageId) { alreadyHave = true; break; } - } - if (!alreadyHave) { - bookPages_.push_back({data.pageId, data.text}); - } - - // Follow the chain: if there's a next page we haven't fetched yet, request it - if (data.nextPageId != 0) { - bool nextHave = false; - for (const auto& bp : bookPages_) { - if (bp.pageId == data.nextPageId) { nextHave = true; break; } - } - if (!nextHave && socket && state == WorldState::IN_WORLD) { - auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid); - socket->send(req); - } - } - LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, - " nextPage=", data.nextPageId, - " totalPages=", bookPages_.size()); + return entityController_ ? entityController_->getCachedCreatureName(entry) : ""; } // ============================================================ @@ -8232,7 +6184,7 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { // Ensure GO interaction isn't blocked by stale or active melee state. stopAutoAttack(); - auto entity = entityManager.getEntity(guid); + auto entity = entityController_->getEntityManager().getEntity(guid); uint32_t goEntry = 0; uint32_t goType = 0; std::string goName; @@ -8362,7 +6314,7 @@ bool GameHandler::hasQuestInLog(uint32_t questId) const { } Unit* GameHandler::getUnitByGuid(uint64_t guid) { - auto entity = entityManager.getEntity(guid); + auto entity = entityController_->getEntityManager().getEntity(guid); return entity ? dynamic_cast(entity.get()) : nullptr; } diff --git a/src/game/inventory_handler.cpp b/src/game/inventory_handler.cpp index 11dc5367..87265fd3 100644 --- a/src/game/inventory_handler.cpp +++ b/src/game/inventory_handler.cpp @@ -810,8 +810,8 @@ void InventoryHandler::handleLootRoll(network::Packet& packet) { // Resolve player name std::string playerName; - auto nit = owner_.playerNameCache.find(playerGuid); - if (nit != owner_.playerNameCache.end()) playerName = nit->second; + auto nit = owner_.getPlayerNameCache().find(playerGuid); + if (nit != owner_.getPlayerNameCache().end()) playerName = nit->second; if (playerName.empty()) playerName = "Player"; if (pendingLootRollActive_ && @@ -848,8 +848,8 @@ void InventoryHandler::handleLootRollWon(network::Packet& packet) { uint8_t rollType = packet.readUInt8(); std::string winnerName; - auto nit = owner_.playerNameCache.find(winnerGuid); - if (nit != owner_.playerNameCache.end()) winnerName = nit->second; + auto nit = owner_.getPlayerNameCache().find(winnerGuid); + if (nit != owner_.getPlayerNameCache().end()) winnerName = nit->second; if (winnerName.empty()) winnerName = "Player"; owner_.ensureItemInfo(itemId); @@ -1374,7 +1374,7 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { // Play vendor sound if (owner_.npcVendorCallback_ && currentVendorItems_.vendorGuid != 0) { - auto entity = owner_.entityManager.getEntity(currentVendorItems_.vendorGuid); + auto entity = owner_.getEntityManager().getEntity(currentVendorItems_.vendorGuid); if (entity && entity->getType() == ObjectType::UNIT) { glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ()); owner_.npcVendorCallback_(currentVendorItems_.vendorGuid, pos); @@ -2076,8 +2076,8 @@ void InventoryHandler::handleTradeStatus(network::Packet& packet) { tradePeerGuid_ = packet.readUInt64(); tradeStatus_ = TradeStatus::PendingIncoming; // Resolve name - auto nit = owner_.playerNameCache.find(tradePeerGuid_); - if (nit != owner_.playerNameCache.end()) tradePeerName_ = nit->second; + auto nit = owner_.getPlayerNameCache().find(tradePeerGuid_); + if (nit != owner_.getPlayerNameCache().end()) tradePeerName_ = nit->second; else tradePeerName_ = "Unknown"; owner_.addSystemChatMessage(tradePeerName_ + " wants to trade with you."); if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_REQUEST", {tradePeerName_}); @@ -3098,7 +3098,7 @@ void InventoryHandler::maybeDetectVisibleItemLayout() { " mismatches=", bestMismatches, " score=", bestScore, ")"); // Backfill existing player entities already in view. - for (const auto& [guid, ent] : owner_.entityManager.getEntities()) { + for (const auto& [guid, ent] : owner_.getEntityManager().getEntities()) { if (!ent || ent->getType() != ObjectType::PLAYER) continue; if (guid == owner_.playerGuid) continue; updateOtherPlayerVisibleItems(guid, ent->getFields()); diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index d5071b86..d53fda76 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -1180,7 +1180,7 @@ void MovementHandler::handleOtherPlayerMovement(network::Packet& packet) { } } - auto entity = owner_.entityManager.getEntity(moverGuid); + auto entity = owner_.getEntityManager().getEntity(moverGuid); if (!entity) { return; } @@ -1539,7 +1539,7 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { } } - auto entity = owner_.entityManager.getEntity(data.guid); + auto entity = owner_.getEntityManager().getEntity(data.guid); if (!entity) { return; } @@ -1552,7 +1552,7 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { if (data.moveType == 4) { orientation = core::coords::serverToCanonicalYaw(data.facingAngle); } else if (data.moveType == 3) { - auto target = owner_.entityManager.getEntity(data.facingTarget); + auto target = owner_.getEntityManager().getEntity(data.facingTarget); if (target) { float dx = target->getX() - entity->getX(); float dy = target->getY() - entity->getY(); @@ -1613,7 +1613,7 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { posCanonical.x, posCanonical.y, posCanonical.z, 0); } } else if (data.moveType == 3 && data.facingTarget != 0) { - auto target = owner_.entityManager.getEntity(data.facingTarget); + auto target = owner_.getEntityManager().getEntity(data.facingTarget); if (target) { float dx = target->getX() - entity->getX(); float dy = target->getY() - entity->getY(); @@ -1635,7 +1635,7 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { float localY = packet.readFloat(); float localZ = packet.readFloat(); - auto entity = owner_.entityManager.getEntity(moverGuid); + auto entity = owner_.getEntityManager().getEntity(moverGuid); if (!entity) return; if (packet.getReadPos() + 5 > packet.getSize()) { @@ -1674,7 +1674,7 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { } else if (moveType == 3) { if (packet.getReadPos() + 8 > packet.getSize()) return; uint64_t tgtGuid = packet.readUInt64(); - if (auto tgt = owner_.entityManager.getEntity(tgtGuid)) { + if (auto tgt = owner_.getEntityManager().getEntity(tgtGuid)) { float dx = tgt->getX() - entity->getX(); float dy = tgt->getY() - entity->getY(); if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) @@ -1922,7 +1922,7 @@ void MovementHandler::handleNewWorld(network::Packet& packet) { owner_.mountCallback_(0); } - for (const auto& [guid, entity] : owner_.entityManager.getEntities()) { + for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { if (guid == owner_.playerGuid) continue; if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { owner_.creatureDespawnCallback_(guid); @@ -1938,7 +1938,7 @@ void MovementHandler::handleNewWorld(network::Packet& packet) { owner_.unitCastStates_.clear(); owner_.unitAurasCache_.clear(); owner_.clearCombatText(); - owner_.entityManager.clear(); + owner_.getEntityManager().clear(); owner_.clearHostileAttackers(); owner_.worldStates_.clear(); owner_.gossipPois_.clear(); @@ -2278,7 +2278,7 @@ void MovementHandler::startClientTaxiPath(const std::vector& pathNodes movementInfo.orientation = initialOrientation; sanitizeMovementForTaxi(); - auto playerEntity = owner_.entityManager.getEntity(owner_.playerGuid); + auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid); if (playerEntity) { playerEntity->setPosition(start.x, start.y, start.z, initialOrientation); } @@ -2293,7 +2293,7 @@ void MovementHandler::startClientTaxiPath(const std::vector& pathNodes void MovementHandler::updateClientTaxi(float deltaTime) { if (!taxiClientActive_ || taxiClientPath_.size() < 2) return; - auto playerEntity = owner_.entityManager.getEntity(owner_.playerGuid); + auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid); auto finishTaxiFlight = [&]() { if (!taxiClientPath_.empty()) { @@ -2820,7 +2820,7 @@ void MovementHandler::updateAttachedTransportChildren(float /*deltaTime*/) { stale.reserve(8); for (const auto& [childGuid, attachment] : owner_.transportAttachments_) { - auto entity = owner_.entityManager.getEntity(childGuid); + auto entity = owner_.getEntityManager().getEntity(childGuid); if (!entity) { stale.push_back(childGuid); continue; diff --git a/src/game/quest_handler.cpp b/src/game/quest_handler.cpp index 4b0d3226..5da9d892 100644 --- a/src/game/quest_handler.cpp +++ b/src/game/quest_handler.cpp @@ -504,7 +504,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { } // Re-query all nearby quest giver NPCs so markers refresh if (owner_.socket) { - for (const auto& [guid, entity] : owner_.entityManager.getEntities()) { + for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { if (entity->getType() != ObjectType::UNIT) continue; auto unit = std::static_pointer_cast(entity); if (unit->getNpcFlags() & 0x02) { @@ -1557,7 +1557,7 @@ void QuestHandler::handleGossipMessage(network::Packet& packet) { // Play NPC greeting voice if (owner_.npcGreetingCallback_ && currentGossip_.npcGuid != 0) { - auto entity = owner_.entityManager.getEntity(currentGossip_.npcGuid); + auto entity = owner_.getEntityManager().getEntity(currentGossip_.npcGuid); if (entity) { glm::vec3 npcPos(entity->getX(), entity->getY(), entity->getZ()); owner_.npcGreetingCallback_(currentGossip_.npcGuid, npcPos); @@ -1654,7 +1654,7 @@ void QuestHandler::handleGossipComplete(network::Packet& packet) { // Play farewell sound before closing if (owner_.npcFarewellCallback_ && currentGossip_.npcGuid != 0) { - auto entity = owner_.entityManager.getEntity(currentGossip_.npcGuid); + auto entity = owner_.getEntityManager().getEntity(currentGossip_.npcGuid); if (entity && entity->getType() == ObjectType::UNIT) { glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ()); owner_.npcFarewellCallback_(currentGossip_.npcGuid, pos); @@ -1865,13 +1865,13 @@ void QuestHandler::handleQuestConfirmAccept(network::Packet& packet) { } sharedQuestSharerName_.clear(); - auto entity = owner_.entityManager.getEntity(sharedQuestSharerGuid_); + auto entity = owner_.getEntityManager().getEntity(sharedQuestSharerGuid_); if (auto* unit = dynamic_cast(entity.get())) { sharedQuestSharerName_ = unit->getName(); } if (sharedQuestSharerName_.empty()) { - auto nit = owner_.playerNameCache.find(sharedQuestSharerGuid_); - if (nit != owner_.playerNameCache.end()) + auto nit = owner_.getPlayerNameCache().find(sharedQuestSharerGuid_); + if (nit != owner_.getPlayerNameCache().end()) sharedQuestSharerName_ = nit->second; } if (sharedQuestSharerName_.empty()) { diff --git a/src/game/social_handler.cpp b/src/game/social_handler.cpp index debaa39d..b8337b6b 100644 --- a/src/game/social_handler.cpp +++ b/src/game/social_handler.cpp @@ -154,7 +154,7 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { readyCheckResults_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); - auto entity = owner_.entityManager.getEntity(initiatorGuid); + auto entity = owner_.getEntityManager().getEntity(initiatorGuid); if (auto* unit = dynamic_cast(entity.get())) readyCheckInitiator_ = unit->getName(); } @@ -174,11 +174,11 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { uint64_t respGuid = packet.readUInt64(); uint8_t isReady = packet.readUInt8(); if (isReady) ++readyCheckReadyCount_; else ++readyCheckNotReadyCount_; - auto nit = owner_.playerNameCache.find(respGuid); + auto nit = owner_.getPlayerNameCache().find(respGuid); std::string rname; - if (nit != owner_.playerNameCache.end()) rname = nit->second; + if (nit != owner_.getPlayerNameCache().end()) rname = nit->second; else { - auto ent = owner_.entityManager.getEntity(respGuid); + auto ent = owner_.getEntityManager().getEntity(respGuid); if (ent) rname = std::static_pointer_cast(ent)->getName(); } if (!rname.empty()) { @@ -231,9 +231,9 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { uint64_t killerGuid = packet.readUInt64(); uint64_t victimGuid = packet.readUInt64(); auto nameFor = [this](uint64_t g) -> std::string { - auto nit = owner_.playerNameCache.find(g); - if (nit != owner_.playerNameCache.end()) return nit->second; - auto ent = owner_.entityManager.getEntity(g); + auto nit = owner_.getPlayerNameCache().find(g); + if (nit != owner_.getPlayerNameCache().end()) return nit->second; + auto ent = owner_.getEntityManager().getEntity(g); if (ent && (ent->getType() == game::ObjectType::UNIT || ent->getType() == game::ObjectType::PLAYER)) return std::static_pointer_cast(ent)->getName(); @@ -303,16 +303,16 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { table[Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED] = [this](network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t guid = packet.readUInt64(); - auto it = owner_.playerNameCache.find(guid); - if (it != owner_.playerNameCache.end() && !it->second.empty()) + auto it = owner_.getPlayerNameCache().find(guid); + if (it != owner_.getPlayerNameCache().end() && !it->second.empty()) owner_.addSystemChatMessage(it->second + " has entered the battleground."); } }; table[Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT] = [this](network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t guid = packet.readUInt64(); - auto it = owner_.playerNameCache.find(guid); - if (it != owner_.playerNameCache.end() && !it->second.empty()) + auto it = owner_.getPlayerNameCache().find(guid); + if (it != owner_.getPlayerNameCache().end() && !it->second.empty()) owner_.addSystemChatMessage(it->second + " has left the battleground."); } }; @@ -455,7 +455,7 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { if (roles & 0x08) roleName += "DPS "; if (roleName.empty()) roleName = "None"; std::string pName = "A player"; - if (auto e = owner_.entityManager.getEntity(roleGuid)) + if (auto e = owner_.getEntityManager().getEntity(roleGuid)) if (auto u = std::dynamic_pointer_cast(e)) pName = u->getName(); if (ready) owner_.addSystemChatMessage(pName + " has chosen: " + roleName); @@ -507,7 +507,7 @@ bool SocialHandler::isInGuild() const { } uint32_t SocialHandler::getEntityGuildId(uint64_t guid) const { - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); if (!entity || entity->getType() != ObjectType::PLAYER) return 0; const uint16_t ufUnitEnd = fieldIndex(UF::UNIT_END); if (ufUnitEnd == 0xFFFF) return 0; @@ -613,7 +613,7 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { size_t bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft < 6) { LOG_WARNING("SMSG_TALENTS_INFO: too short after guid, ", bytesLeft, " bytes"); - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); std::string name = "Target"; if (entity) { auto player = std::dynamic_pointer_cast(entity); @@ -627,7 +627,7 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { uint8_t talentGroupCount = packet.readUInt8(); uint8_t activeTalentGroup = packet.readUInt8(); - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); std::string playerName = "Target"; if (entity) { auto player = std::dynamic_pointer_cast(entity); @@ -1028,12 +1028,12 @@ void SocialHandler::handleDuelRequested(network::Packet& packet) { duelChallengerGuid_ = packet.readUInt64(); duelFlagGuid_ = packet.readUInt64(); duelChallengerName_.clear(); - auto entity = owner_.entityManager.getEntity(duelChallengerGuid_); + auto entity = owner_.getEntityManager().getEntity(duelChallengerGuid_); if (auto* unit = dynamic_cast(entity.get())) duelChallengerName_ = unit->getName(); if (duelChallengerName_.empty()) { - auto nit = owner_.playerNameCache.find(duelChallengerGuid_); - if (nit != owner_.playerNameCache.end()) duelChallengerName_ = nit->second; + auto nit = owner_.getPlayerNameCache().find(duelChallengerGuid_); + if (nit != owner_.getPlayerNameCache().end()) duelChallengerName_ = nit->second; } if (duelChallengerName_.empty()) { char tmp[32]; @@ -1745,9 +1745,9 @@ void SocialHandler::handleFriendList(network::Packet& packet) { classId = packet.readUInt32(); } owner_.friendGuids_.insert(guid); - auto nit = owner_.playerNameCache.find(guid); + auto nit = owner_.getPlayerNameCache().find(guid); std::string name; - if (nit != owner_.playerNameCache.end()) { + if (nit != owner_.getPlayerNameCache().end()) { name = nit->second; owner_.friendsCache[name] = guid; } else { @@ -1780,15 +1780,15 @@ void SocialHandler::handleContactList(network::Packet& packet) { areaId = packet.readUInt32(); level = packet.readUInt32(); classId = packet.readUInt32(); } owner_.friendGuids_.insert(guid); - auto nit = owner_.playerNameCache.find(guid); - if (nit != owner_.playerNameCache.end()) owner_.friendsCache[nit->second] = guid; + auto nit = owner_.getPlayerNameCache().find(guid); + if (nit != owner_.getPlayerNameCache().end()) owner_.friendsCache[nit->second] = guid; else owner_.queryPlayerName(guid); } ContactEntry entry; entry.guid = guid; entry.flags = flags; entry.note = std::move(note); entry.status = status; entry.areaId = areaId; entry.level = level; entry.classId = classId; - auto nit = owner_.playerNameCache.find(guid); - if (nit != owner_.playerNameCache.end()) entry.name = nit->second; + auto nit = owner_.getPlayerNameCache().find(guid); + if (nit != owner_.getPlayerNameCache().end()) entry.name = nit->second; owner_.contacts_.push_back(std::move(entry)); } if (owner_.addonEventCallback_) { @@ -1810,8 +1810,8 @@ void SocialHandler::handleFriendStatus(network::Packet& packet) { if (cit != owner_.contacts_.end() && !cit->name.empty()) { playerName = cit->name; } else { - auto it = owner_.playerNameCache.find(data.guid); - if (it != owner_.playerNameCache.end()) playerName = it->second; + auto it = owner_.getPlayerNameCache().find(data.guid); + if (it != owner_.getPlayerNameCache().end()) playerName = it->second; } if (data.status == 1 || data.status == 2) owner_.friendsCache[playerName] = data.guid; @@ -1850,8 +1850,8 @@ void SocialHandler::handleRandomRoll(network::Packet& packet) { if (!RandomRollParser::parse(packet, data)) return; std::string rollerName = (data.rollerGuid == owner_.playerGuid) ? "You" : "Someone"; if (data.rollerGuid != owner_.playerGuid) { - auto it = owner_.playerNameCache.find(data.rollerGuid); - if (it != owner_.playerNameCache.end()) rollerName = it->second; + auto it = owner_.getPlayerNameCache().find(data.rollerGuid); + if (it != owner_.getPlayerNameCache().end()) rollerName = it->second; } std::string msg = rollerName + ((data.rollerGuid == owner_.playerGuid) ? " roll " : " rolls "); msg += std::to_string(data.result) + " (" + std::to_string(data.minRoll) + "-" + std::to_string(data.maxRoll) + ")"; @@ -2394,7 +2394,7 @@ void SocialHandler::handlePvpLogData(network::Packet& packet) { ps.guid = packet.readUInt64(); ps.team = packet.readUInt8(); ps.killingBlows = packet.readUInt32(); ps.honorableKills = packet.readUInt32(); ps.deaths = packet.readUInt32(); ps.bonusHonor = packet.readUInt32(); - { auto ent = owner_.entityManager.getEntity(ps.guid); + { auto ent = owner_.getEntityManager().getEntity(ps.guid); if (ent && (ent->getType() == game::ObjectType::PLAYER || ent->getType() == game::ObjectType::UNIT)) { auto u = std::static_pointer_cast(ent); if (!u->getName().empty()) ps.name = u->getName(); } } if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; } diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index 3639d654..d64cfc4f 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -248,7 +248,7 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { owner_.addSystemChatMessage("You have no target."); return; } - auto entity = owner_.entityManager.getEntity(target); + auto entity = owner_.getEntityManager().getEntity(target); if (!entity) { owner_.addSystemChatMessage("You have no target."); return; @@ -284,7 +284,7 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { isMeleeAbility = true; } if (isMeleeAbility && target != 0) { - auto entity = owner_.entityManager.getEntity(target); + auto entity = owner_.getEntityManager().getEntity(target); if (entity) { float dx = entity->getX() - owner_.movementInfo.x; float dy = entity->getY() - owner_.movementInfo.y; @@ -305,7 +305,7 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { // Send both SET_FACING and a HEARTBEAT so the server has the updated orientation // before it processes the cast packet. if (target != 0) { - auto entity = owner_.entityManager.getEntity(target); + auto entity = owner_.getEntityManager().getEntity(target); if (entity) { float dx = entity->getX() - owner_.movementInfo.x; float dy = entity->getY() - owner_.movementInfo.y; @@ -819,7 +819,7 @@ void SpellHandler::handleCastFailed(network::Packet& packet) { // Show failure reason int powerType = -1; - auto playerEntity = owner_.entityManager.getEntity(owner_.playerGuid); + auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid); if (auto playerUnit = std::dynamic_pointer_cast(playerEntity)) { powerType = playerUnit->getPowerType(); } @@ -1418,13 +1418,13 @@ void SpellHandler::handleAchievementEarned(network::Packet& packet) { } } else { std::string senderName; - auto entity = owner_.entityManager.getEntity(guid); + auto entity = owner_.getEntityManager().getEntity(guid); if (auto* unit = dynamic_cast(entity.get())) { senderName = unit->getName(); } if (senderName.empty()) { - auto nit = owner_.playerNameCache.find(guid); - if (nit != owner_.playerNameCache.end()) + auto nit = owner_.getPlayerNameCache().find(guid); + if (nit != owner_.getPlayerNameCache().end()) senderName = nit->second; } if (senderName.empty()) { @@ -2073,7 +2073,7 @@ void SpellHandler::handleCastResult(network::Packet& packet) { owner_.craftQueueSpellId_ = 0; owner_.craftQueueRemaining_ = 0; owner_.queuedSpellId_ = 0; owner_.queuedSpellTarget_ = 0; int playerPowerType = -1; - if (auto pe = owner_.entityManager.getEntity(owner_.playerGuid)) { + if (auto pe = owner_.getEntityManager().getEntity(owner_.playerGuid)) { if (auto pu = std::dynamic_pointer_cast(pe)) playerPowerType = static_cast(pu->getPowerType()); } @@ -2151,7 +2151,7 @@ void SpellHandler::handlePlaySpellVisual(network::Packet& packet) { if (casterGuid == owner_.playerGuid) { spawnPos = renderer->getCharacterPosition(); } else { - auto entity = owner_.entityManager.getEntity(casterGuid); + auto entity = owner_.getEntityManager().getEntity(casterGuid); if (!entity) return; glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); spawnPos = core::coords::canonicalToRender(canonical); @@ -2308,7 +2308,7 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) { if (failGuid == owner_.playerGuid && failReason != 0) { // Show interruption/failure reason in chat and error overlay for player int pt = -1; - if (auto pe = owner_.entityManager.getEntity(owner_.playerGuid)) + if (auto pe = owner_.getEntityManager().getEntity(owner_.playerGuid)) if (auto pu = std::dynamic_pointer_cast(pe)) pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); diff --git a/test.sh b/test.sh index ef600a66..7b69156b 100755 --- a/test.sh +++ b/test.sh @@ -75,8 +75,19 @@ echo "Linting ${#SOURCE_FILES[@]} source files..." EXTRA_TIDY_ARGS=() # for direct clang-tidy: --extra-arg=... EXTRA_RUN_ARGS=() # for run-clang-tidy: -extra-arg=... if command -v gcc >/dev/null 2>&1; then + # Prepend clang's own resource include dir first so clang uses its own + # versions of xmmintrin.h, ia32intrin.h, etc. rather than GCC's. + clang_resource_inc="$($CLANG_TIDY -print-resource-dir 2>/dev/null || true)/include" + if [[ -d "$clang_resource_inc" ]]; then + EXTRA_TIDY_ARGS+=("--extra-arg=-isystem${clang_resource_inc}") + EXTRA_RUN_ARGS+=("-extra-arg=-isystem${clang_resource_inc}") + fi + while IFS= read -r inc_path; do [[ -d "$inc_path" ]] || continue + # Skip the GCC compiler built-in include dir — clang's resource dir above + # provides compatible replacements for xmmintrin.h, ia32intrin.h, etc. + [[ "$inc_path" == */gcc/* ]] && continue EXTRA_TIDY_ARGS+=("--extra-arg=-isystem${inc_path}") EXTRA_RUN_ARGS+=("-extra-arg=-isystem${inc_path}") done < <(