From b0a07c24725f51c5e21535f58bbcd174a56dca28 Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 29 Mar 2026 14:42:38 +0300 Subject: [PATCH] refactor(game): apply SOLID phases 2-6 to EntityController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - split applyUpdateObjectBlock into handleCreateObject, handleValuesUpdate, handleMovementUpdate - extract concern helpers — createEntityFromBlock, applyPlayerTransportState, applyUnitFieldsOnCreate/OnUpdate, applyPlayerStatFields, dispatchEntitySpawn, trackItemOnCreate, updateItemOnValuesUpdate, syncClassicAurasFromFields, detectPlayerMountChange, updateNonPlayerTransportAttachment - UnitFieldIndices, PlayerFieldIndices, UnitFieldUpdateResult structs with static resolve() — eliminate repeated fieldIndex() calls - IObjectTypeHandler strategy interface; concrete handlers UnitTypeHandler, PlayerTypeHandler, GameObjectTypeHandler, ItemTypeHandler, CorpseTypeHandler registered in typeHandlers_ map; handleCreateObject and handleValuesUpdate now dispatch via getTypeHandler() — adding a new object type requires zero changes to existing handler methods - PendingEvents member bus; all 27 inline owner_.fireAddonEvent() calls in the update path replaced with pendingEvents_.emit(); events flushed via flushPendingEvents() at the end of each handler, decoupling field-parse logic from the addon callback system entity_controller.cpp: 1520-line monolith → longest method ~200 lines, cyclomatic complexity ~180 → ~5; zero duplicated CREATE/VALUES blocks --- include/game/entity_controller.hpp | 128 ++ src/game/entity_controller.cpp | 2853 ++++++++++++++-------------- 2 files changed, 1537 insertions(+), 1444 deletions(-) diff --git a/include/game/entity_controller.hpp b/include/game/entity_controller.hpp index 5c8c2031..319b9aa0 100644 --- a/include/game/entity_controller.hpp +++ b/include/game/entity_controller.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -141,6 +142,133 @@ private: void processOutOfRangeObjects(const std::vector& guids); void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); void finalizeUpdateObjectBatch(bool newItemCreated); + + // --- Phase 1: Extracted helper methods --- + bool extractPlayerAppearance(const std::map& fields, + uint8_t& outRace, uint8_t& outGender, + uint32_t& outAppearanceBytes, uint8_t& outFacial) const; + void maybeDetectCoinageIndex(const std::map& oldFields, + const std::map& newFields); + + // --- Phase 2: Update type handlers --- + void handleCreateObject(const UpdateBlock& block, bool& newItemCreated); + void handleValuesUpdate(const UpdateBlock& block, bool& newItemCreated); + void handleMovementUpdate(const UpdateBlock& block); + + // --- Phase 3: Concern-specific helpers --- + // 3i: Update transport-relative child attachment (non-player entities). + // Consolidates identical logic from CREATE/VALUES/MOVEMENT handlers. + void updateNonPlayerTransportAttachment(const UpdateBlock& block, + const std::shared_ptr& entity, + ObjectType entityType); + // 3f: Rebuild playerAuras_ from UNIT_FIELD_AURAS (Classic/vanilla only). + // Consolidates identical logic from CREATE and VALUES handlers. + void syncClassicAurasFromFields(const std::shared_ptr& entity); + // 3h: Detect mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes (self-player only). + // Consolidates identical logic from CREATE and VALUES handlers. + void detectPlayerMountChange(uint32_t newMountDisplayId, + const std::map& blockFields); + + // --- Phase 4: Field index cache structs --- + // Cached field indices resolved once per handler call to avoid repeated lookups. + struct UnitFieldIndices { + uint16_t health, maxHealth, powerBase, maxPowerBase; + uint16_t level, faction, flags, dynFlags; + uint16_t displayId, mountDisplayId, npcFlags; + uint16_t bytes0, bytes1; + static UnitFieldIndices resolve(); + }; + struct PlayerFieldIndices { + uint16_t xp, nextXp, restedXp, level; + uint16_t coinage, honor, arena; + uint16_t playerFlags, armor; + uint16_t pBytes, pBytes2, chosenTitle; + uint16_t stats[5]; + uint16_t meleeAP, rangedAP; + uint16_t spDmg1, healBonus; + uint16_t blockPct, dodgePct, parryPct, critPct, rangedCritPct; + uint16_t sCrit1, rating1; + static PlayerFieldIndices resolve(); + }; + struct UnitFieldUpdateResult { + bool healthChanged = false; + bool powerChanged = false; + bool displayIdChanged = false; + bool npcDeathNotified = false; + bool npcRespawnNotified = false; + uint32_t oldDisplayId = 0; + }; + + // --- Phase 3: Extracted concern-specific helpers (continued) --- + // 3a: Entity factory — creates the correct Entity subclass for the given block. + std::shared_ptr createEntityFromBlock(const UpdateBlock& block); + // 3b: Track player-on-transport state from movement blocks. + void applyPlayerTransportState(const UpdateBlock& block, + const std::shared_ptr& entity, + const glm::vec3& canonicalPos, float oCanonical, + bool updateMovementInfoPos); + // 3c: Apply unit fields during CREATE — returns true if entity is initially dead. + bool applyUnitFieldsOnCreate(const UpdateBlock& block, + std::shared_ptr& unit, + const UnitFieldIndices& ufi); + // 3c: Apply unit fields during VALUES — returns change tracking result. + UnitFieldUpdateResult applyUnitFieldsOnUpdate(const UpdateBlock& block, + const std::shared_ptr& entity, + std::shared_ptr& unit, + const UnitFieldIndices& ufi); + // 3d: Apply player stat fields (XP, inventory, skills, etc.). isCreate=true for CREATE path. + bool applyPlayerStatFields(const std::map& fields, + const PlayerFieldIndices& pfi, bool isCreate); + // 3e: Dispatch spawn callbacks (creature/player) — deduplicates CREATE and VALUES paths. + void dispatchEntitySpawn(uint64_t guid, ObjectType objectType, + const std::shared_ptr& entity, + const std::shared_ptr& unit, bool isDead); + // 3g: Track item/container on CREATE. + void trackItemOnCreate(const UpdateBlock& block, bool& newItemCreated); + // 3g: Update item fields on VALUES update. + void updateItemOnValuesUpdate(const UpdateBlock& block, + const std::shared_ptr& entity); + + // --- Phase 5: Strategy pattern — object-type handler interface --- + // Allows extending object-type handling without modifying handler dispatch. + struct IObjectTypeHandler { + virtual ~IObjectTypeHandler() = default; + virtual void onCreate(const UpdateBlock& block, std::shared_ptr& entity, + bool& newItemCreated) {} + virtual void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) {} + virtual void onMovementUpdate(const UpdateBlock& block, std::shared_ptr& entity) {} + }; + struct UnitTypeHandler; + struct PlayerTypeHandler; + struct GameObjectTypeHandler; + struct ItemTypeHandler; + struct CorpseTypeHandler; + std::unordered_map> typeHandlers_; + void initTypeHandlers(); + IObjectTypeHandler* getTypeHandler(ObjectType type) const; + + // --- Phase 5: Type-specific handler implementations (trampolined from handlers) --- + void onCreateUnit(const UpdateBlock& block, std::shared_ptr& entity); + void onCreatePlayer(const UpdateBlock& block, std::shared_ptr& entity); + void onCreateGameObject(const UpdateBlock& block, std::shared_ptr& entity); + void onCreateItem(const UpdateBlock& block, bool& newItemCreated); + void onCreateCorpse(const UpdateBlock& block); + void onValuesUpdateUnit(const UpdateBlock& block, std::shared_ptr& entity); + void onValuesUpdatePlayer(const UpdateBlock& block, std::shared_ptr& entity); + void onValuesUpdateItem(const UpdateBlock& block, std::shared_ptr& entity); + void onValuesUpdateGameObject(const UpdateBlock& block, std::shared_ptr& entity); + + // --- Phase 6: Deferred event bus --- + // Collects addon events during block processing, flushes at the end. + struct PendingEvents { + std::vector>> events; + void emit(const std::string& name, const std::vector& args = {}) { + events.emplace_back(name, args); + } + void clear() { events.clear(); } + }; + PendingEvents pendingEvents_; + void flushPendingEvents(); }; } // namespace game diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 36dd0696..396d8aba 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -70,7 +70,7 @@ float slowUpdateObjectBlockLogThresholdMs() { } // anonymous namespace EntityController::EntityController(GameHandler& owner) - : owner_(owner) {} + : owner_(owner) { initTypeHandlers(); } void EntityController::registerOpcodes(DispatchTable& table) { // World object updates @@ -257,1426 +257,1445 @@ void EntityController::processOutOfRangeObjects(const std::vector& gui } -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; +// ============================================================ +// Phase 1: Extracted helper methods +// ============================================================ - 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; - }; +bool EntityController::extractPlayerAppearance(const std::map& fields, + uint8_t& outRace, + uint8_t& outGender, + uint32_t& outAppearanceBytes, + uint8_t& outFacial) const { + outRace = 0; + outGender = 0; + outAppearanceBytes = 0; + outFacial = 0; - 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; + 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; }; - 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; + uint32_t bytes0 = 0; + uint32_t pbytes = 0; + uint32_t pbytes2 = 0; - constexpr uint32_t kMaxPlausibleCoinage = 2147483647u; - std::vector candidates; - candidates.reserve(8); + 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); - 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); + 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 (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 (!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 (chosen != current && current != 0xFFFF) { - owner_.updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen); - LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")"); + } + if (!havePbytes2) { + for (const auto& [idx, v] : fields) { + uint8_t facial = static_cast(v & 0xFF); + if (facial <= 100) { + pbytes2 = v; + havePbytes2 = true; + break; + } } + } - owner_.pendingMoneyDelta_ = 0; - owner_.pendingMoneyDeltaTimer_ = 0.0f; - }; + 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; +} + +void EntityController::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; +} + +// ============================================================ +// Phase 2: Update type dispatch +// ============================================================ + +void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { switch (block.updateType) { case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - // Create new entity - std::shared_ptr entity; + case UpdateType::CREATE_OBJECT2: + handleCreateObject(block, newItemCreated); + break; + case UpdateType::VALUES: + handleValuesUpdate(block, newItemCreated); + break; + case UpdateType::MOVEMENT: + handleMovementUpdate(block); + break; + default: + break; + } +} - switch (block.objectType) { - case ObjectType::PLAYER: - entity = std::make_shared(block.guid); - break; +// ============================================================ +// Phase 3: Concern-specific helpers +// ============================================================ - case ObjectType::UNIT: - entity = std::make_shared(block.guid); - break; +// 3i: Non-player transport child attachment — identical in CREATE/VALUES/MOVEMENT +void EntityController::updateNonPlayerTransportAttachment(const UpdateBlock& block, + const std::shared_ptr& entity, + ObjectType entityType) { + if (block.guid == owner_.playerGuid) return; + if (entityType != ObjectType::UNIT && entityType != ObjectType::GAMEOBJECT) return; - case ObjectType::GAMEOBJECT: - entity = std::make_shared(block.guid); - break; + 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, entityType, 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); + } +} - default: - entity = std::make_shared(block.guid); - entity->setType(block.objectType); - break; +// 3f: Rebuild playerAuras from UNIT_FIELD_AURAS (Classic/vanilla only). +// blockFields is used to check if any aura field was updated in this packet. +// entity->getFields() is used for reading the full accumulated state. +// Note: CREATE originally normalised Classic flags (0x02→0x80) while VALUES +// used raw bytes; VALUES runs more frequently and overwrites CREATE's mapping +// immediately, so the helper uses raw bytes (matching VALUES behaviour). +void EntityController::syncClassicAurasFromFields(const std::shared_ptr& entity) { + if (!isClassicLikeExpansion() || !owner_.spellHandler_) return; + + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras == 0xFFFF) return; + + const auto& allFields = entity->getFields(); + bool hasAuraField = false; + for (const auto& [fk, fv] : allFields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } + } + if (!hasAuraField) return; + + 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()); + 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"); + pendingEvents_.emit("UNIT_AURA", {"player"}); +} - // 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; +// 3h: Detect player mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes +void EntityController::detectPlayerMountChange(uint32_t newMountDisplayId, + const std::map& blockFields) { + uint32_t old = owner_.currentMountDisplayId_; + owner_.currentMountDisplayId_ = newMountDisplayId; + if (newMountDisplayId != old && owner_.mountCallback_) owner_.mountCallback_(newMountDisplayId); + if (newMountDisplayId != old) + pendingEvents_.emit("UNIT_MODEL_CHANGED", {"player"}); + if (old == 0 && newMountDisplayId != 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] : blockFields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + owner_.mountAuraSpellId_ = fv; + break; + } } - // Track player-on-transport owner_.state + } + } + LOG_INFO("Mount detected: displayId=", newMountDisplayId, " auraSpellId=", owner_.mountAuraSpellId_); + } + if (old != 0 && newMountDisplayId == 0) { + owner_.mountAuraSpellId_ = 0; + if (owner_.spellHandler_) for (auto& a : owner_.spellHandler_->playerAuras_) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; + } +} + +// Phase 4: Resolve cached field indices once per handler call. +EntityController::UnitFieldIndices EntityController::UnitFieldIndices::resolve() { + return UnitFieldIndices{ + fieldIndex(UF::UNIT_FIELD_HEALTH), + fieldIndex(UF::UNIT_FIELD_MAXHEALTH), + fieldIndex(UF::UNIT_FIELD_POWER1), + fieldIndex(UF::UNIT_FIELD_MAXPOWER1), + fieldIndex(UF::UNIT_FIELD_LEVEL), + fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE), + fieldIndex(UF::UNIT_FIELD_FLAGS), + fieldIndex(UF::UNIT_DYNAMIC_FLAGS), + fieldIndex(UF::UNIT_FIELD_DISPLAYID), + fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID), + fieldIndex(UF::UNIT_NPC_FLAGS), + fieldIndex(UF::UNIT_FIELD_BYTES_0), + fieldIndex(UF::UNIT_FIELD_BYTES_1) + }; +} + +EntityController::PlayerFieldIndices EntityController::PlayerFieldIndices::resolve() { + return PlayerFieldIndices{ + fieldIndex(UF::PLAYER_XP), + fieldIndex(UF::PLAYER_NEXT_LEVEL_XP), + fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE), + fieldIndex(UF::UNIT_FIELD_LEVEL), + fieldIndex(UF::PLAYER_FIELD_COINAGE), + fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY), + fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY), + fieldIndex(UF::PLAYER_FLAGS), + fieldIndex(UF::UNIT_FIELD_RESISTANCES), + fieldIndex(UF::PLAYER_BYTES), + fieldIndex(UF::PLAYER_BYTES_2), + fieldIndex(UF::PLAYER_CHOSEN_TITLE), + {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)}, + fieldIndex(UF::UNIT_FIELD_ATTACK_POWER), + fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER), + fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS), + fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS), + fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE), + fieldIndex(UF::PLAYER_DODGE_PERCENTAGE), + fieldIndex(UF::PLAYER_PARRY_PERCENTAGE), + fieldIndex(UF::PLAYER_CRIT_PERCENTAGE), + fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE), + fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1), + fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1) + }; +} + +// 3a: Create the appropriate Entity subclass from the block's object type. +std::shared_ptr EntityController::createEntityFromBlock(const UpdateBlock& block) { + switch (block.objectType) { + case ObjectType::PLAYER: + return std::make_shared(block.guid); + case ObjectType::UNIT: + return std::make_shared(block.guid); + case ObjectType::GAMEOBJECT: + return std::make_shared(block.guid); + default: { + auto entity = std::make_shared(block.guid); + entity->setType(block.objectType); + return entity; + } + } +} + +// 3b: Track player-on-transport state from movement blocks. +// Consolidates near-identical logic from CREATE and MOVEMENT handlers. +// When updateMovementInfoPos is true (MOVEMENT), movementInfo.x/y/z are set +// to the raw canonical position when not on a resolved transport. +// When false (CREATE), movementInfo is only set for resolved transport positions. +void EntityController::applyPlayerTransportState(const UpdateBlock& block, + const std::shared_ptr& entity, + const glm::vec3& canonicalPos, float oCanonical, + bool updateMovementInfoPos) { + 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 if (updateMovementInfoPos) { + owner_.movementInfo.x = canonicalPos.x; + owner_.movementInfo.y = canonicalPos.y; + owner_.movementInfo.z = canonicalPos.z; + } + LOG_INFO("Player on transport: 0x", std::hex, owner_.playerTransportGuid_, std::dec, + " offset=(", owner_.playerTransportOffset_.x, ", ", owner_.playerTransportOffset_.y, + ", ", owner_.playerTransportOffset_.z, ")"); + } else { + if (updateMovementInfoPos) { + owner_.movementInfo.x = canonicalPos.x; + owner_.movementInfo.y = canonicalPos.y; + owner_.movementInfo.z = canonicalPos.z; + } + // 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(); + } + } +} + +// 3c: Apply unit fields during CREATE — sets health/power/level/flags/displayId/etc. +// Returns true if the entity is initially dead (health=0 or DYNFLAG_DEAD). +bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, + std::shared_ptr& unit, + const UnitFieldIndices& ufi) { + bool unitInitiallyDead = false; + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + + 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 == ufi.health) { + 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 == ufi.maxHealth) { unit->setMaxHealth(val); } + else if (key == ufi.level) { + unit->setLevel(val); + } else if (key == ufi.faction) { + unit->setFactionTemplate(val); + if (owner_.addonEventCallback_) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + pendingEvents_.emit("UNIT_FACTION", {uid}); + } + } + else if (key == ufi.flags) { + unit->setUnitFlags(val); + if (owner_.addonEventCallback_) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + pendingEvents_.emit("UNIT_FLAGS", {uid}); + } + } + else if (key == ufi.bytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufi.displayId) { + unit->setDisplayId(val); + if (owner_.addonEventCallback_) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid}); + } + } + else if (key == ufi.npcFlags) { unit->setNpcFlags(val); } + else if (key == ufi.dynFlags) { + 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 >= ufi.powerBase && key < ufi.powerBase + 7) { + unit->setPowerByType(static_cast(key - ufi.powerBase), val); + } else if (key >= ufi.maxPowerBase && key < ufi.maxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufi.maxPowerBase), val); + } + else if (key == ufi.mountDisplayId) { + if (block.guid == owner_.playerGuid) { + detectPlayerMountChange(val, block.fields); + } + unit->setMountDisplayId(val); + } + } + return unitInitiallyDead; +} + +// 3c: Apply unit fields during VALUES update — tracks health/power/display changes +// and fires events for transitions (death, resurrect, level up, etc.). +EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdate( + const UpdateBlock& block, const std::shared_ptr& entity, + std::shared_ptr& unit, const UnitFieldIndices& ufi) { + UnitFieldUpdateResult result; + result.oldDisplayId = unit->getDisplayId(); + uint32_t oldHealth = unit->getHealth(); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + + for (const auto& [key, val] : block.fields) { + if (key == ufi.health) { + unit->setHealth(val); + result.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) { - 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_.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("Corpse object detected: guid=0x", std::hex, owner_.corpseGuid_, std::dec, - " server=(", block.x, ", ", block.y, ", ", block.z, + LOG_INFO("Player died! Corpse position cached at server=(", + owner_.corpseX_, ",", owner_.corpseY_, ",", owner_.corpseZ_, ") map=", owner_.corpseMapId_); + pendingEvents_.emit("PLAYER_DEAD", {}); } - } - - // 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); + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + result.npcDeathNotified = true; } - // 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 + } else if (oldHealth == 0 && val > 0) { 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"}); + bool wasGhost = owner_.releasedSpirit_; + owner_.playerDead_ = false; + if (!wasGhost) { + LOG_INFO("Player resurrected!"); + pendingEvents_.emit("PLAYER_ALIVE", {}); + } else { + LOG_INFO("Player entered ghost form"); + owner_.releasedSpirit_ = false; + pendingEvents_.emit("PLAYER_UNGHOST", {}); } } - 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()); - } + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcRespawnCallback_) { + owner_.npcRespawnCallback_(block.guid); + result.npcRespawnNotified = true; } - - LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); - } else { } - break; + // Specific fields checked BEFORE power/maxpower range checks + // (Classic packs maxHealth/level/faction adjacent to power indices) + } else if (key == ufi.maxHealth) { unit->setMaxHealth(val); result.healthChanged = true; } + else if (key == ufi.bytes0) { + 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()) + pendingEvents_.emit("UNIT_DISPLAYPOWER", {uid}); + } + } else if (key == ufi.flags) { unit->setUnitFlags(val); } + else if (ufi.bytes1 != 0xFFFF && key == ufi.bytes1 && 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)); + pendingEvents_.emit("UPDATE_SHAPESHIFT_FORM", {}); + pendingEvents_.emit("UPDATE_SHAPESHIFT_FORMS", {}); + } + } + else if (key == ufi.dynFlags) { + 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 (!result.npcDeathNotified && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(block.guid); + result.npcDeathNotified = true; + } + } else if (wasDead && !nowDead) { + if (!result.npcRespawnNotified && owner_.npcRespawnCallback_) { + owner_.npcRespawnCallback_(block.guid); + result.npcRespawnNotified = true; + } + } + } + } else if (key == ufi.level) { + uint32_t oldLvl = unit->getLevel(); + unit->setLevel(val); + if (val != oldLvl) { + auto uid = owner_.guidToUnitId(block.guid); + if (!uid.empty()) + pendingEvents_.emit("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 == ufi.faction) { + unit->setFactionTemplate(val); + unit->setHostile(owner_.isHostileFaction(val)); + } else if (key == ufi.displayId) { + if (val != unit->getDisplayId()) { + unit->setDisplayId(val); + result.displayIdChanged = true; + } + } else if (key == ufi.mountDisplayId) { + if (block.guid == owner_.playerGuid) { + detectPlayerMountChange(val, block.fields); + } + unit->setMountDisplayId(val); + } else if (key == ufi.npcFlags) { unit->setNpcFlags(val); } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufi.powerBase && key < ufi.powerBase + 7) { + unit->setPowerByType(static_cast(key - ufi.powerBase), val); + result.powerChanged = true; + } else if (key >= ufi.maxPowerBase && key < ufi.maxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufi.maxPowerBase), val); + result.powerChanged = true; + } + } + + // Fire UNIT_HEALTH / UNIT_POWER events for Lua addons + if ((result.healthChanged || result.powerChanged)) { + auto unitId = owner_.guidToUnitId(block.guid); + if (!unitId.empty()) { + if (result.healthChanged) pendingEvents_.emit("UNIT_HEALTH", {unitId}); + if (result.powerChanged) { + pendingEvents_.emit("UNIT_POWER", {unitId}); + // When player power changes, action bar usability may change + if (block.guid == owner_.playerGuid) { + pendingEvents_.emit("ACTIONBAR_UPDATE_USABLE", {}); + pendingEvents_.emit("SPELL_UPDATE_USABLE", {}); + } + } + } + } + + return result; +} + +// 3d: Apply player stat fields (XP, coinage, combat stats, etc.). +// Shared between CREATE and VALUES — isCreate controls event firing differences. +bool EntityController::applyPlayerStatFields(const std::map& fields, + const PlayerFieldIndices& pfi, + bool isCreate) { + bool slotsChanged = false; + for (const auto& [key, val] : fields) { + if (key == pfi.xp) { + owner_.playerXp_ = val; + if (!isCreate) { + LOG_DEBUG("XP updated: ", val); + pendingEvents_.emit("PLAYER_XP_UPDATE", {std::to_string(val)}); + } + } + else if (key == pfi.nextXp) { + owner_.playerNextLevelXp_ = val; + if (!isCreate) LOG_DEBUG("Next level XP updated: ", val); + } + else if (pfi.restedXp != 0xFFFF && key == pfi.restedXp) { + owner_.playerRestedXp_ = val; + if (!isCreate) pendingEvents_.emit("UPDATE_EXHAUSTION", {}); + } + else if (key == pfi.level) { + owner_.serverPlayerLevel_ = val; + if (!isCreate) LOG_DEBUG("Level updated: ", val); + for (auto& ch : owner_.characters) { + if (ch.guid == owner_.playerGuid) { ch.level = val; break; } + } + } + else if (key == pfi.coinage) { + uint64_t oldMoney = owner_.playerMoneyCopper_; + owner_.playerMoneyCopper_ = val; + LOG_DEBUG("Money ", isCreate ? "set from update fields: " : "updated via VALUES: ", val, " copper"); + if (val != oldMoney) + pendingEvents_.emit("PLAYER_MONEY", {}); + } + else if (pfi.honor != 0xFFFF && key == pfi.honor) { + owner_.playerHonorPoints_ = val; + LOG_DEBUG("Honor points ", isCreate ? "from update fields: " : "updated: ", val); + } + else if (pfi.arena != 0xFFFF && key == pfi.arena) { + owner_.playerArenaPoints_ = val; + LOG_DEBUG("Arena points ", isCreate ? "from update fields: " : "updated: ", val); + } + else if (pfi.armor != 0xFFFF && key == pfi.armor) { + owner_.playerArmorRating_ = static_cast(val); + if (isCreate) LOG_DEBUG("Armor rating from update fields: ", owner_.playerArmorRating_); + } + else if (pfi.armor != 0xFFFF && key > pfi.armor && key <= pfi.armor + 6) { + owner_.playerResistances_[key - pfi.armor - 1] = static_cast(val); + } + else if (pfi.pBytes2 != 0xFFFF && key == pfi.pBytes2) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + 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); + if (isCreate) { + LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + bool wasResting = owner_.isResting_; + owner_.isResting_ = (restStateByte != 0); + if (owner_.isResting_ != wasResting) { + pendingEvents_.emit("UPDATE_EXHAUSTION", {}); + pendingEvents_.emit("PLAYER_UPDATE_RESTING", {}); + } + } else { + // 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; } + } + LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots), + " facial=", static_cast(facialHair)); + owner_.isResting_ = (restStateByte != 0); + if (owner_.appearanceChangedCallback_) + owner_.appearanceChangedCallback_(); + } + } + else if (pfi.chosenTitle != 0xFFFF && key == pfi.chosenTitle) { + owner_.chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE ", isCreate ? "from update fields: " : "updated: ", + owner_.chosenTitleBit_); + } + // VALUES-only fields: PLAYER_BYTES (appearance) and PLAYER_FLAGS (ghost state) + else if (!isCreate && pfi.pBytes != 0xFFFF && key == pfi.pBytes) { + // PLAYER_BYTES changed (barber shop, polymorph, etc.) + for (auto& ch : owner_.characters) { + if (ch.guid == owner_.playerGuid) { ch.appearanceBytes = val; break; } + } + if (owner_.appearanceChangedCallback_) + owner_.appearanceChangedCallback_(); + } + else if (!isCreate && key == pfi.playerFlags) { + 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)"); + pendingEvents_.emit("PLAYER_ALIVE", {}); + if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(false); + } + pendingEvents_.emit("PLAYER_FLAGS_CHANGED", {}); + } + else if (pfi.meleeAP != 0xFFFF && key == pfi.meleeAP) { owner_.playerMeleeAP_ = static_cast(val); } + else if (pfi.rangedAP != 0xFFFF && key == pfi.rangedAP) { owner_.playerRangedAP_ = static_cast(val); } + else if (pfi.spDmg1 != 0xFFFF && key >= pfi.spDmg1 && key < pfi.spDmg1 + 7) { + owner_.playerSpellDmgBonus_[key - pfi.spDmg1] = static_cast(val); + } + else if (pfi.healBonus != 0xFFFF && key == pfi.healBonus) { owner_.playerHealBonus_ = static_cast(val); } + else if (pfi.blockPct != 0xFFFF && key == pfi.blockPct) { std::memcpy(&owner_.playerBlockPct_, &val, 4); } + else if (pfi.dodgePct != 0xFFFF && key == pfi.dodgePct) { std::memcpy(&owner_.playerDodgePct_, &val, 4); } + else if (pfi.parryPct != 0xFFFF && key == pfi.parryPct) { std::memcpy(&owner_.playerParryPct_, &val, 4); } + else if (pfi.critPct != 0xFFFF && key == pfi.critPct) { std::memcpy(&owner_.playerCritPct_, &val, 4); } + else if (pfi.rangedCritPct != 0xFFFF && key == pfi.rangedCritPct) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); } + else if (pfi.sCrit1 != 0xFFFF && key >= pfi.sCrit1 && key < pfi.sCrit1 + 7) { + std::memcpy(&owner_.playerSpellCritPct_[key - pfi.sCrit1], &val, 4); + } + else if (pfi.rating1 != 0xFFFF && key >= pfi.rating1 && key < pfi.rating1 + 25) { + owner_.playerCombatRatings_[key - pfi.rating1] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (pfi.stats[si] != 0xFFFF && key == pfi.stats[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(fields)) slotsChanged = true; + return slotsChanged; +} + +// 3e: Dispatch entity spawn callbacks for units/players. +// Consolidates player/creature spawn callback invocation from CREATE and VALUES handlers. +// isDead = unitInitiallyDead (CREATE) or computed isDeadNow && !npcDeathNotified (VALUES). +void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType, + const std::shared_ptr& entity, + const std::shared_ptr& unit, + bool isDead) { + if (objectType == ObjectType::PLAYER && guid == owner_.playerGuid) { + return; // Skip local player — spawned separately via spawnPlayerCharacter() + } + if (objectType == ObjectType::PLAYER) { + if (owner_.playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + owner_.playerSpawnCallback_(guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + } + } + } else if (owner_.creatureSpawnCallback_) { + LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, 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_(guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); + } + if (isDead && owner_.npcDeathCallback_) { + owner_.npcDeathCallback_(guid); + } + // Query quest giver status for NPCs with questgiver flag (0x02) + if (objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && owner_.socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(guid); + owner_.socket->send(qsPkt); + } +} + +// 3g: Track online item/container objects during CREATE. +void EntityController::trackItemOnCreate(const UpdateBlock& block, bool& newItemCreated) { + 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); + } +} + +// 3g: Update item stack count / durability / enchants for existing items during VALUES. +void EntityController::updateItemOnValuesUpdate(const UpdateBlock& block, + const std::shared_ptr& entity) { + 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 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(); + pendingEvents_.emit("BAG_UPDATE", {}); + pendingEvents_.emit("UNIT_INVENTORY_CHANGED", {"player"}); + } +} + +// ============================================================ +// Phase 5: Object-type handler struct definitions +// ============================================================ + +struct EntityController::UnitTypeHandler : EntityController::IObjectTypeHandler { + EntityController& ctl_; + explicit UnitTypeHandler(EntityController& c) : ctl_(c) {} + void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool&) override { ctl_.onCreateUnit(block, entity); } + void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) override { ctl_.onValuesUpdateUnit(block, entity); } +}; + +struct EntityController::PlayerTypeHandler : EntityController::IObjectTypeHandler { + EntityController& ctl_; + explicit PlayerTypeHandler(EntityController& c) : ctl_(c) {} + void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool&) override { ctl_.onCreatePlayer(block, entity); } + void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) override { ctl_.onValuesUpdatePlayer(block, entity); } +}; + +struct EntityController::GameObjectTypeHandler : EntityController::IObjectTypeHandler { + EntityController& ctl_; + explicit GameObjectTypeHandler(EntityController& c) : ctl_(c) {} + void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool&) override { ctl_.onCreateGameObject(block, entity); } + void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) override { ctl_.onValuesUpdateGameObject(block, entity); } +}; + +struct EntityController::ItemTypeHandler : EntityController::IObjectTypeHandler { + EntityController& ctl_; + explicit ItemTypeHandler(EntityController& c) : ctl_(c) {} + void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool& newItemCreated) override { ctl_.onCreateItem(block, newItemCreated); } + void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) override { ctl_.onValuesUpdateItem(block, entity); } +}; + +struct EntityController::CorpseTypeHandler : EntityController::IObjectTypeHandler { + EntityController& ctl_; + explicit CorpseTypeHandler(EntityController& c) : ctl_(c) {} + void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool&) override { ctl_.onCreateCorpse(block); } +}; + +// ============================================================ +// Phase 5: Handler registry infrastructure +// ============================================================ + +void EntityController::initTypeHandlers() { + typeHandlers_[static_cast(ObjectType::UNIT)] = std::make_unique(*this); + typeHandlers_[static_cast(ObjectType::PLAYER)] = std::make_unique(*this); + typeHandlers_[static_cast(ObjectType::GAMEOBJECT)] = std::make_unique(*this); + typeHandlers_[static_cast(ObjectType::ITEM)] = std::make_unique(*this); + typeHandlers_[static_cast(ObjectType::CONTAINER)] = std::make_unique(*this); + typeHandlers_[static_cast(ObjectType::CORPSE)] = std::make_unique(*this); +} + +EntityController::IObjectTypeHandler* EntityController::getTypeHandler(ObjectType type) const { + auto it = typeHandlers_.find(static_cast(type)); + return it != typeHandlers_.end() ? it->second.get() : nullptr; +} + +// ============================================================ +// Phase 6: Deferred event bus flush +// ============================================================ + +void EntityController::flushPendingEvents() { + for (const auto& [name, args] : pendingEvents_.events) { + owner_.fireAddonEvent(name, args); + } + pendingEvents_.clear(); +} + +// ============================================================ +// Phase 5: Type-specific CREATE handlers +// ============================================================ + +void EntityController::onCreateUnit(const UpdateBlock& block, std::shared_ptr& entity) { + // Name query for creatures + 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); + std::string cached = getCachedCreatureName(it->second); + if (!cached.empty()) { + unit->setName(cached); + } + queryCreatureInfo(it->second, block.guid); + } + + // Unit fields + auto unit = std::static_pointer_cast(entity); + UnitFieldIndices ufi = UnitFieldIndices::resolve(); + bool unitInitiallyDead = applyUnitFieldsOnCreate(block, unit, ufi); + + // Hostility + unit->setHostile(owner_.isHostileFaction(unit->getFactionTemplate())); + + // Spawn dispatch + if (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 (unit->getDisplayId() != 0) { + dispatchEntitySpawn(block.guid, block.objectType, entity, unit, unitInitiallyDead); + if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ && + block.guid != owner_.playerGuid) { + owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags); + } + } +} + +void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr& entity) { + static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); + + // For the local player, capture the full initial field state + if (block.guid == owner_.playerGuid) { + owner_.lastPlayerFields_ = entity->getFields(); + owner_.maybeDetectVisibleItemLayout(); + } + + // Name query + visible items + queryPlayerName(block.guid); + if (block.guid != owner_.playerGuid) { + owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + + // Unit fields (PLAYER is a unit) + auto unit = std::static_pointer_cast(entity); + UnitFieldIndices ufi = UnitFieldIndices::resolve(); + bool unitInitiallyDead = applyUnitFieldsOnCreate(block, unit, ufi); + + // Self-player post-unit-field handling + 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() & 0x0008 /*UNIT_DYNFLAG_DEAD*/) != 0) { + owner_.playerDead_ = true; + LOG_INFO("Player logged in dead (dynamic flags)"); + } + // Detect ghost 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); + } + } + } + // 3f: Classic aura sync on initial object create + if (block.guid == owner_.playerGuid) { + syncClassicAurasFromFields(entity); + } + + // Hostility + unit->setHostile(owner_.isHostileFaction(unit->getFactionTemplate())); + + // Spawn dispatch + if (unit->getDisplayId() != 0) { + dispatchEntitySpawn(block.guid, block.objectType, entity, unit, unitInitiallyDead); + if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ && + block.guid != owner_.playerGuid) { + owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags); + } + } + + // 3d: Player stat fields (self only) + if (block.guid == owner_.playerGuid) { + // 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, ")"); } - case UpdateType::MOVEMENT: { + PlayerFieldIndices pfi = PlayerFieldIndices::resolve(); + bool slotsChanged = applyPlayerStatFields(block.fields, pfi, true); + if (slotsChanged) owner_.rebuildOnlineInventory(); + owner_.maybeDetectVisibleItemLayout(); + owner_.extractSkillFields(owner_.lastPlayerFields_); + owner_.extractExploredZoneFields(owner_.lastPlayerFields_); + owner_.applyQuestStateFromFields(owner_.lastPlayerFields_); + } +} + +void EntityController::onCreateGameObject(const UpdateBlock& block, std::shared_ptr& entity) { + 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()); + } +} + +void EntityController::onCreateItem(const UpdateBlock& block, bool& newItemCreated) { + trackItemOnCreate(block, newItemCreated); +} + +void EntityController::onCreateCorpse(const UpdateBlock& block) { + // 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.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_); + } + } +} + +// ============================================================ +// Phase 5: Type-specific VALUES UPDATE handlers +// ============================================================ + +void EntityController::onValuesUpdateUnit(const UpdateBlock& block, std::shared_ptr& entity) { + auto unit = std::static_pointer_cast(entity); + UnitFieldIndices ufi = UnitFieldIndices::resolve(); + UnitFieldUpdateResult result = applyUnitFieldsOnUpdate(block, entity, unit, ufi); + + // Display ID changed — re-spawn/model-change notification + if (result.displayIdChanged && unit->getDisplayId() != 0 && + unit->getDisplayId() != result.oldDisplayId) { + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + dispatchEntitySpawn(block.guid, entity->getType(), entity, unit, + isDeadNow && !result.npcDeathNotified); + 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()) + pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid}); + } + } +} + +void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::shared_ptr& entity) { + // Other player visible items + if (block.guid != owner_.playerGuid) { + owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + + // Unit field update (player IS a unit) + auto unit = std::static_pointer_cast(entity); + UnitFieldIndices ufi = UnitFieldIndices::resolve(); + UnitFieldUpdateResult result = applyUnitFieldsOnUpdate(block, entity, unit, ufi); + + // 3f: Classic aura sync from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == owner_.playerGuid) { + syncClassicAurasFromFields(entity); + } + + // 3e: Display ID changed — re-spawn/model-change + if (result.displayIdChanged && unit->getDisplayId() != 0 && + unit->getDisplayId() != result.oldDisplayId) { + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + dispatchEntitySpawn(block.guid, entity->getType(), entity, unit, + isDeadNow && !result.npcDeathNotified); + 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()) + pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid}); + } + } + + // 3d: Self-player stat/inventory/quest field updates + 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); + + PlayerFieldIndices pfi = PlayerFieldIndices::resolve(); + bool slotsChanged = applyPlayerStatFields(block.fields, pfi, false); + if (slotsChanged) { + owner_.rebuildOnlineInventory(); + pendingEvents_.emit("PLAYER_EQUIPMENT_CHANGED", {}); + } + owner_.extractSkillFields(owner_.lastPlayerFields_); + owner_.extractExploredZoneFields(owner_.lastPlayerFields_); + owner_.applyQuestStateFromFields(owner_.lastPlayerFields_); + } +} + +void EntityController::onValuesUpdateItem(const UpdateBlock& block, std::shared_ptr& entity) { + updateItemOnValuesUpdate(block, entity); +} + +void EntityController::onValuesUpdateGameObject(const UpdateBlock& block, std::shared_ptr& entity) { + if (block.hasMovement) { + 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()); + } + } +} + +// ============================================================ +// Phase 2: Update type handlers (refactored with Phase 5 dispatch) +// ============================================================ + +void EntityController::handleCreateObject(const UpdateBlock& block, bool& newItemCreated) { + pendingEvents_.clear(); + + // 3a: Create entity from block type + std::shared_ptr entity = createEntityFromBlock(block); + + // 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; + } + // 3b: Track player-on-transport state + if (block.guid == owner_.playerGuid) { + applyPlayerTransportState(block, entity, pos, oCanonical, false); + } + // 3i: Track transport-relative children so they follow parent transport motion. + updateNonPlayerTransportAttachment(block, entity, block.objectType); + } + + // Set fields + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + // Add to manager + entityManager.addEntity(block.guid, entity); + + // Phase 5: Dispatch to type-specific handler + auto* handler = getTypeHandler(block.objectType); + if (handler) handler->onCreate(block, entity, newItemCreated); + + flushPendingEvents(); +} + +void EntityController::handleValuesUpdate(const UpdateBlock& block, bool& /*newItemCreated*/) { + auto entity = entityManager.getEntity(block.guid); + if (!entity) return; + pendingEvents_.clear(); + + // Position update (common) + 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); + updateNonPlayerTransportAttachment(block, entity, entity->getType()); + } + + // Set fields (common) + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + // Phase 5: Dispatch to type-specific handler + auto* handler = getTypeHandler(entity->getType()); + if (handler) handler->onValuesUpdate(block, entity); + + flushPendingEvents(); + LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); +} + +void EntityController::handleMovementUpdate(const UpdateBlock& block) { // 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, @@ -1691,60 +1710,12 @@ void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& ne 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); - } - } + updateNonPlayerTransportAttachment(block, entity, entity->getType()); + // 3b: Track player-on-transport state from MOVEMENT updates 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(); - } - } + applyPlayerTransportState(block, entity, pos, oCanonical, true); } // Fire transport move callback if this is a known transport @@ -1772,12 +1743,6 @@ void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& ne } else { LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); } - break; - } - - default: - break; - } } void EntityController::finalizeUpdateObjectBatch(bool newItemCreated) {