#include "game/entity_controller.hpp" #include "game/game_handler.hpp" #include "game/game_utils.hpp" #include "game/packet_parsers.hpp" #include "game/entity.hpp" #include "game/update_field_table.hpp" #include "game/opcode_table.hpp" #include "game/chat_handler.hpp" #include "game/transport_manager.hpp" #include "core/logger.hpp" #include "core/coordinates.hpp" #include "network/world_socket.hpp" #include #include #include namespace wowee { namespace game { namespace { const char* worldStateName(WorldState state) { switch (state) { case WorldState::DISCONNECTED: return "DISCONNECTED"; case WorldState::CONNECTING: return "CONNECTING"; case WorldState::CONNECTED: return "CONNECTED"; case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED"; case WorldState::AUTH_SENT: return "AUTH_SENT"; case WorldState::AUTHENTICATED: return "AUTHENTICATED"; case WorldState::READY: return "READY"; case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED"; case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED"; case WorldState::ENTERING_WORLD: return "ENTERING_WORLD"; case WorldState::IN_WORLD: return "IN_WORLD"; case WorldState::FAILED: return "FAILED"; } return "UNKNOWN"; } bool envFlagEnabled(const char* key, bool defaultValue = false) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || raw[0] == 'n' || raw[0] == 'N'); } int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; char* end = nullptr; long parsed = std::strtol(raw, &end, 10); if (end == raw) return defaultValue; return static_cast(std::clamp(parsed, minValue, maxValue)); } int updateObjectBlocksBudgetPerUpdate(WorldState state) { static const int inWorldBudget = parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048); static const int loginBudget = parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096); return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; } float slowUpdateObjectBlockLogThresholdMs() { static const int thresholdMs = parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000); return static_cast(thresholdMs); } } // anonymous namespace EntityController::EntityController(GameHandler& owner) : owner_(owner) { initTypeHandlers(); } void EntityController::registerOpcodes(DispatchTable& table) { // World object updates table[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) { LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast(owner_.state), " size=", packet.getSize()); if (owner_.state == WorldState::IN_WORLD) handleUpdateObject(packet); }; table[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) { LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(owner_.state), " size=", packet.getSize()); if (owner_.state == WorldState::IN_WORLD) handleCompressedUpdateObject(packet); }; table[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) { if (owner_.state == WorldState::IN_WORLD) handleDestroyObject(packet); }; // Entity queries table[Opcode::SMSG_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) { handleNameQueryResponse(packet); }; table[Opcode::SMSG_CREATURE_QUERY_RESPONSE] = [this](network::Packet& packet) { handleCreatureQueryResponse(packet); }; table[Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE] = [this](network::Packet& packet) { handleGameObjectQueryResponse(packet); }; table[Opcode::SMSG_GAMEOBJECT_PAGETEXT] = [this](network::Packet& packet) { handleGameObjectPageText(packet); }; table[Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE] = [this](network::Packet& packet) { handlePageTextQueryResponse(packet); }; } void EntityController::clearAll() { pendingUpdateObjectWork_.clear(); playerNameCache.clear(); playerClassRaceCache_.clear(); pendingNameQueries.clear(); creatureInfoCache.clear(); pendingCreatureQueries.clear(); gameObjectInfoCache_.clear(); pendingGameObjectQueries_.clear(); transportGuids_.clear(); serverUpdatedTransportGuids_.clear(); entityManager.clear(); } // ============================================================ // Update Object Pipeline // ============================================================ void EntityController::enqueueUpdateObjectWork(UpdateObjectData&& data) { pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); } void EntityController::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, float budgetMs) { if (pendingUpdateObjectWork_.empty()) { return; } const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(owner_.state); int processedBlocks = 0; while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { float elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - start).count(); if (elapsedMs >= budgetMs) { break; } auto& work = pendingUpdateObjectWork_.front(); if (!work.outOfRangeProcessed) { auto outOfRangeStart = std::chrono::steady_clock::now(); processOutOfRangeObjects(work.data.outOfRangeGuids); float outOfRangeMs = std::chrono::duration( std::chrono::steady_clock::now() - outOfRangeStart).count(); if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, "ms guidCount=", work.data.outOfRangeGuids.size()); } work.outOfRangeProcessed = true; } while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - start).count(); if (elapsedMs >= budgetMs) { break; } const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; auto blockStart = std::chrono::steady_clock::now(); applyUpdateObjectBlock(block, work.newItemCreated); float blockMs = std::chrono::duration( std::chrono::steady_clock::now() - blockStart).count(); if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { LOG_WARNING("SLOW update-object block apply: ", blockMs, "ms index=", work.nextBlockIndex, " type=", static_cast(block.updateType), " guid=0x", std::hex, block.guid, std::dec, " objectType=", static_cast(block.objectType), " fieldCount=", block.fields.size(), " hasMovement=", block.hasMovement ? 1 : 0); } ++work.nextBlockIndex; ++processedBlocks; } if (work.nextBlockIndex >= work.data.blocks.size()) { finalizeUpdateObjectBatch(work.newItemCreated); pendingUpdateObjectWork_.pop_front(); continue; } break; } if (!pendingUpdateObjectWork_.empty()) { const auto& work = pendingUpdateObjectWork_.front(); LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, "/", work.data.blocks.size(), ", owner_.state=", worldStateName(owner_.state), ")"); } } void EntityController::handleUpdateObject(network::Packet& packet) { UpdateObjectData data; if (!owner_.packetParsers_->parseUpdateObject(packet, data)) { static int updateObjErrors = 0; if (++updateObjErrors <= 5) LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); if (data.blocks.empty()) return; // Fall through: process any blocks that were successfully parsed before the failure. } enqueueUpdateObjectWork(std::move(data)); } void EntityController::processOutOfRangeObjects(const std::vector& guids) { // Process out-of-range objects first for (uint64_t guid : guids) { auto entity = entityManager.getEntity(guid); if (!entity) continue; const bool isKnownTransport = transportGuids_.count(guid) > 0; if (isKnownTransport) { // Keep transports alive across out-of-range flapping. // Boats/zeppelins are global movers and removing them here can make // them disappear until a later movement snapshot happens to recreate them. const bool playerAboardNow = (owner_.playerTransportGuid_ == guid); const bool stickyAboard = (owner_.playerTransportStickyGuid_ == guid && owner_.playerTransportStickyTimer_ > 0.0f); const bool movementSaysAboard = (owner_.movementInfo.transportGuid == guid); LOG_INFO("Preserving transport on out-of-range: 0x", std::hex, guid, std::dec, " now=", playerAboardNow, " sticky=", stickyAboard, " movement=", movementSaysAboard); continue; } LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); // Trigger despawn callbacks before removing entity if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { owner_.creatureDespawnCallback_(guid); } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { owner_.playerDespawnCallback_(guid); owner_.otherPlayerVisibleItemEntries_.erase(guid); owner_.otherPlayerVisibleDirty_.erase(guid); owner_.otherPlayerMoveTimeMs_.erase(guid); owner_.inspectedPlayerItemEntries_.erase(guid); owner_.pendingAutoInspect_.erase(guid); // Clear pending name query so the query is re-sent when this player // comes back into range (entity is recreated as a new object). pendingNameQueries.erase(guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) { owner_.gameObjectDespawnCallback_(guid); } transportGuids_.erase(guid); serverUpdatedTransportGuids_.erase(guid); owner_.clearTransportAttachment(guid); if (owner_.playerTransportGuid_ == guid) { owner_.clearPlayerTransport(); } entityManager.removeEntity(guid); } } // ============================================================ // Phase 1: Extracted helper methods // ============================================================ 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; auto readField = [&](uint16_t idx, uint32_t& out) -> bool { if (idx == 0xFFFF) return false; auto it = fields.find(idx); if (it == fields.end()) return false; out = it->second; return true; }; uint32_t bytes0 = 0; uint32_t pbytes = 0; uint32_t pbytes2 = 0; const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES); const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2); bool haveBytes0 = readField(ufBytes0, bytes0); bool havePbytes = readField(ufPbytes, pbytes); bool havePbytes2 = readField(ufPbytes2, pbytes2); // Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing, // try to locate plausible packed fields by scanning. if (!haveBytes0) { for (const auto& [idx, v] : fields) { uint8_t race = static_cast(v & 0xFF); uint8_t cls = static_cast((v >> 8) & 0xFF); uint8_t gender = static_cast((v >> 16) & 0xFF); uint8_t power = static_cast((v >> 24) & 0xFF); if (race >= 1 && race <= 20 && cls >= 1 && cls <= 20 && gender <= 1 && power <= 10) { bytes0 = v; haveBytes0 = true; break; } } } if (!havePbytes) { for (const auto& [idx, v] : fields) { uint8_t skin = static_cast(v & 0xFF); uint8_t face = static_cast((v >> 8) & 0xFF); uint8_t hair = static_cast((v >> 16) & 0xFF); uint8_t color = static_cast((v >> 24) & 0xFF); if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) { pbytes = v; havePbytes = true; break; } } } if (!havePbytes2) { for (const auto& [idx, v] : fields) { uint8_t facial = static_cast(v & 0xFF); if (facial <= 100) { pbytes2 = v; havePbytes2 = true; break; } } } if (!haveBytes0 || !havePbytes) return false; outRace = static_cast(bytes0 & 0xFF); outGender = static_cast((bytes0 >> 16) & 0xFF); outAppearanceBytes = pbytes; outFacial = havePbytes2 ? static_cast(pbytes2 & 0xFF) : 0; return true; } 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: handleCreateObject(block, newItemCreated); break; case UpdateType::VALUES: handleValuesUpdate(block); break; case UpdateType::MOVEMENT: handleMovementUpdate(block); break; default: break; } } // ============================================================ // Phase 3: Concern-specific helpers // ============================================================ // 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; 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); } } // 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. // Normalises Classic harmful bit (0x02) to WotLK debuff bit (0x80) so // downstream code checking for 0x80 works consistently across expansions. 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); } // Normalize Classic harmful bit (0x02) to WotLK debuff bit (0x80) // so downstream code checking for 0x80 works consistently. if (aFlag & 0x02) aFlag = (aFlag & ~0x02) | 0x80; 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"}); } // 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; } } } } LOG_INFO("Mount detected: displayId=", newMountDisplayId, " auraSpellId=", owner_.mountAuraSpellId_); } if (old != 0 && newMountDisplayId == 0) { // Only clear the specific mount aura, not all indefinite auras. // Previously this cleared every aura with maxDurationMs < 0, which // would strip racial passives, tracking, and zone buffs on dismount. uint32_t mountSpell = owner_.mountAuraSpellId_; owner_.mountAuraSpellId_ = 0; if (mountSpell != 0 && owner_.spellHandler_) { for (auto& a : owner_.spellHandler_->playerAuras_) { if (!a.isEmpty() && a.spellId == mountSpell) { a = AuraSlot{}; break; } } } } } // 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; } // Consolidates player-death state into one place so both the health==0 and // dynFlags UNIT_DYNFLAG_DEAD paths share the same corpse-caching logic. // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so this cached position // is the primary source for canReclaimCorpse(). void EntityController::markPlayerDead(const char* source) { owner_.playerDead_ = true; owner_.releasedSpirit_ = false; // owner_.movementInfo is canonical (x=north, y=west); corpseX_/Y_ are // raw server coords (x=west, y=north) — swap axes. owner_.corpseX_ = owner_.movementInfo.y; owner_.corpseY_ = owner_.movementInfo.x; owner_.corpseZ_ = owner_.movementInfo.z; owner_.corpseMapId_ = owner_.currentMapId_; LOG_INFO("Player died (", source, "). Corpse cached at server=(", owner_.corpseX_, ",", owner_.corpseY_, ",", owner_.corpseZ_, ") map=", owner_.corpseMapId_); } // 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) { markPlayerDead("health=0"); owner_.stopAutoAttack(); pendingEvents_.emit("PLAYER_DEAD", {}); } if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcDeathCallback_) { owner_.npcDeathCallback_(block.guid); result.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!"); pendingEvents_.emit("PLAYER_ALIVE", {}); } else { LOG_INFO("Player entered ghost form"); owner_.releasedSpirit_ = false; pendingEvents_.emit("PLAYER_UNGHOST", {}); } } if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcRespawnCallback_) { owner_.npcRespawnCallback_(block.guid); result.npcRespawnNotified = true; } } // 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) { markPlayerDead("dynFlags"); } 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, ")"); } 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::handleDisplayIdChange(const UpdateBlock& block, const std::shared_ptr& entity, const std::shared_ptr& unit, const UnitFieldUpdateResult& result) { if (!result.displayIdChanged || unit->getDisplayId() == 0 || unit->getDisplayId() == result.oldDisplayId) return; 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::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); handleDisplayIdChange(block, entity, unit, result); } 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 handleDisplayIdChange(block, entity, unit, result); // 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) { 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, " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); } // Update entity position (server → canonical) auto entity = entityManager.getEntity(block.guid); if (entity) { glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); entity->setPosition(pos.x, pos.y, pos.z, oCanonical); LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); updateNonPlayerTransportAttachment(block, entity, entity->getType()); // 3b: Track player-on-transport state from MOVEMENT updates if (block.guid == owner_.playerGuid) { owner_.movementInfo.orientation = oCanonical; applyPlayerTransportState(block, entity, pos, oCanonical, true); } // Fire transport move callback if this is a known transport if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { serverUpdatedTransportGuids_.insert(block.guid); owner_.transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); } // Fire move callback for non-transport gameobjects. if (entity->getType() == ObjectType::GAMEOBJECT && transportGuids_.count(block.guid) == 0 && owner_.gameObjectMoveCallback_) { owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); } // Fire move callback for non-player units (creatures). // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many // servers (especially vanilla/Turtle WoW) communicate NPC positions // via MOVEMENT blocks instead. Use duration=0 for an instant snap. if (block.guid != owner_.playerGuid && entity->getType() == ObjectType::UNIT && transportGuids_.count(block.guid) == 0 && owner_.creatureMoveCallback_) { owner_.creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); } } else { LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); } } void EntityController::finalizeUpdateObjectBatch(bool newItemCreated) { owner_.tabCycleStale = true; // Entity count logging disabled // Deferred rebuild: if new item objects were created in this packet, rebuild // owner_.inventory so that slot GUIDs updated earlier in the same packet can resolve. if (newItemCreated) { owner_.rebuildOnlineInventory(); } // Late owner_.inventory base detection once items are known if (owner_.playerGuid != 0 && owner_.invSlotBase_ < 0 && !owner_.lastPlayerFields_.empty() && !owner_.onlineItems_.empty()) { owner_.detectInventorySlotBases(owner_.lastPlayerFields_); if (owner_.invSlotBase_ >= 0) { if (owner_.applyInventoryFields(owner_.lastPlayerFields_)) { owner_.rebuildOnlineInventory(); } } } } void EntityController::handleCompressedUpdateObject(network::Packet& packet) { LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize()); // First 4 bytes = decompressed size if (packet.getSize() < 4) { LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small"); return; } uint32_t decompressedSize = packet.readUInt32(); LOG_DEBUG(" Decompressed size: ", decompressedSize); // Capital cities and large raids can produce very large update packets. // The real WoW client handles up to ~10MB; 5MB covers all practical cases. if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) { LOG_WARNING("Invalid decompressed size: ", decompressedSize); return; } // Remaining data is zlib compressed size_t compressedSize = packet.getRemainingSize(); const uint8_t* compressedData = packet.getData().data() + packet.getReadPos(); // Decompress std::vector decompressed(decompressedSize); uLongf destLen = decompressedSize; int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize); if (ret != Z_OK) { LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret); return; } // Create packet from decompressed data and parse it network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed); handleUpdateObject(decompressedPacket); } void EntityController::handleDestroyObject(network::Packet& packet) { LOG_DEBUG("Handling SMSG_DESTROY_OBJECT"); DestroyObjectData data; if (!DestroyObjectParser::parse(packet, data)) { LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT"); return; } // Remove entity if (entityManager.hasEntity(data.guid)) { if (transportGuids_.count(data.guid) > 0) { const bool playerAboardNow = (owner_.playerTransportGuid_ == data.guid); const bool stickyAboard = (owner_.playerTransportStickyGuid_ == data.guid && owner_.playerTransportStickyTimer_ > 0.0f); const bool movementSaysAboard = (owner_.movementInfo.transportGuid == data.guid); if (playerAboardNow || stickyAboard || movementSaysAboard) { serverUpdatedTransportGuids_.erase(data.guid); LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec, " now=", playerAboardNow, " sticky=", stickyAboard, " movement=", movementSaysAboard); return; } } // Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal. auto entity = entityManager.getEntity(data.guid); if (entity) { if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { owner_.creatureDespawnCallback_(data.guid); } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. owner_.playerDespawnCallback_(data.guid); owner_.otherPlayerVisibleItemEntries_.erase(data.guid); owner_.otherPlayerVisibleDirty_.erase(data.guid); owner_.otherPlayerMoveTimeMs_.erase(data.guid); owner_.inspectedPlayerItemEntries_.erase(data.guid); owner_.pendingAutoInspect_.erase(data.guid); pendingNameQueries.erase(data.guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) { owner_.gameObjectDespawnCallback_(data.guid); } } if (transportGuids_.count(data.guid) > 0) { transportGuids_.erase(data.guid); serverUpdatedTransportGuids_.erase(data.guid); if (owner_.playerTransportGuid_ == data.guid) { owner_.clearPlayerTransport(); } } owner_.clearTransportAttachment(data.guid); entityManager.removeEntity(data.guid); LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec, " (", (data.isDeath ? "death" : "despawn"), ")"); } else { LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec); } // Clean up auto-attack and target if destroyed entity was our target if (owner_.combatHandler_ && data.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) { owner_.stopAutoAttack(); } if (data.guid == owner_.targetGuid) { owner_.targetGuid = 0; } if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(data.guid); // Remove online item/container tracking owner_.containerContents_.erase(data.guid); if (owner_.onlineItems_.erase(data.guid)) { owner_.rebuildOnlineInventory(); } // Clean up quest giver status owner_.npcQuestStatus_.erase(data.guid); // Remove combat text entries referencing the destroyed entity so floating // damage numbers don't linger after the source/target despawns. if (owner_.combatHandler_) owner_.combatHandler_->removeCombatTextForGuid(data.guid); // Clean up unit cast owner_.state (cast bar) for the destroyed unit if (owner_.spellHandler_) owner_.spellHandler_->unitCastStates_.erase(data.guid); // Clean up cached auras if (owner_.spellHandler_) owner_.spellHandler_->unitAurasCache_.erase(data.guid); owner_.tabCycleStale = true; } // Name Queries // ============================================================ void EntityController::queryPlayerName(uint64_t guid) { // If already cached, apply the name to the entity (handles entity recreation after // moving out/in range — the entity object is new but the cached name is valid). auto cacheIt = playerNameCache.find(guid); if (cacheIt != playerNameCache.end()) { auto entity = entityManager.getEntity(guid); if (entity && entity->getType() == ObjectType::PLAYER) { auto player = std::static_pointer_cast(entity); if (player->getName().empty()) { player->setName(cacheIt->second); } } return; } if (pendingNameQueries.count(guid)) return; if (!owner_.isInWorld()) { LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec, " owner_.state=", worldStateName(owner_.state), " owner_.socket=", (owner_.socket ? "yes" : "no")); return; } LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec); pendingNameQueries.insert(guid); auto packet = NameQueryPacket::build(guid); owner_.socket->send(packet); } void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return; if (!owner_.isInWorld()) return; pendingCreatureQueries.insert(entry); auto packet = CreatureQueryPacket::build(entry, guid); owner_.socket->send(packet); } void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) { if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return; if (!owner_.isInWorld()) return; pendingGameObjectQueries_.insert(entry); auto packet = GameObjectQueryPacket::build(entry, guid); owner_.socket->send(packet); } std::string EntityController::getCachedPlayerName(uint64_t guid) const { return std::string(lookupName(guid)); } std::string EntityController::getCachedCreatureName(uint32_t entry) const { auto it = creatureInfoCache.find(entry); return (it != creatureInfoCache.end()) ? it->second.name : ""; } void EntityController::handleNameQueryResponse(network::Packet& packet) { NameQueryResponseData data; if (!owner_.packetParsers_ || !owner_.packetParsers_->parseNameQueryResponse(packet, data)) { LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")"); return; } pendingNameQueries.erase(data.guid); LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec, " found=", static_cast(data.found), " name='", data.name, "'", " race=", static_cast(data.race), " class=", static_cast(data.classId)); if (data.isValid()) { playerNameCache[data.guid] = data.name; // Cache class/race from name query for UnitClass/UnitRace fallback if (data.classId != 0 || data.race != 0) { playerClassRaceCache_[data.guid] = {data.classId, data.race}; } // Update entity name auto entity = entityManager.getEntity(data.guid); if (entity && entity->getType() == ObjectType::PLAYER) { auto player = std::static_pointer_cast(entity); player->setName(data.name); } // Backfill chat history entries that arrived before we knew the name. if (owner_.chatHandler_) { for (auto& msg : owner_.chatHandler_->getChatHistory()) { if (msg.senderGuid == data.guid && msg.senderName.empty()) { msg.senderName = data.name; } } } // Backfill mail inbox sender names for (auto& mail : owner_.mailInbox_) { if (mail.messageType == 0 && mail.senderGuid == data.guid) { mail.senderName = data.name; } } // Backfill friend list: if this GUID came from a friend list packet, // register the name in owner_.friendsCache now that we know it. if (owner_.friendGuids_.count(data.guid)) { owner_.friendsCache[data.name] = data.guid; } // Backfill ignore list: SMSG_IGNORE_LIST only contains GUIDs, so // ignoreCache (name→guid for UI) is populated here once names resolve. if (owner_.ignoreListGuids_.count(data.guid)) { owner_.ignoreCache[data.name] = data.guid; } // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available if (owner_.addonEventCallback_) { std::string unitId; if (data.guid == owner_.targetGuid) unitId = "target"; else if (data.guid == owner_.focusGuid) unitId = "focus"; else if (data.guid == owner_.playerGuid) unitId = "player"; if (!unitId.empty()) owner_.fireAddonEvent("UNIT_NAME_UPDATE", {unitId}); } } } void EntityController::handleCreatureQueryResponse(network::Packet& packet) { CreatureQueryResponseData data; if (!owner_.packetParsers_->parseCreatureQueryResponse(packet, data)) return; pendingCreatureQueries.erase(data.entry); if (data.isValid()) { creatureInfoCache[data.entry] = data; // Update all unit entities with this entry for (auto& [guid, entity] : entityManager.getEntities()) { if (entity->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); if (unit->getEntry() == data.entry) { unit->setName(data.name); } } } } } // ============================================================ // GameObject Query // ============================================================ void EntityController::handleGameObjectQueryResponse(network::Packet& packet) { GameObjectQueryResponseData data; bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseGameObjectQueryResponse(packet, data) : GameObjectQueryResponseParser::parse(packet, data); if (!ok) return; pendingGameObjectQueries_.erase(data.entry); if (data.isValid()) { gameObjectInfoCache_[data.entry] = data; // Update all gameobject entities with this entry for (auto& [guid, entity] : entityManager.getEntities()) { if (entity->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(entity); if (go->getEntry() == data.entry) { go->setName(data.name); } } } // MO_TRANSPORT (type 15): assign TaxiPathNode path if available if (data.type == 15 && data.hasData && data.data[0] != 0 && owner_.transportManager_) { uint32_t taxiPathId = data.data[0]; if (owner_.transportManager_->hasTaxiPath(taxiPathId)) { if (owner_.transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) { LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId); } } else { LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId, " not found in TaxiPathNode.dbc"); } } } } void EntityController::handleGameObjectPageText(network::Packet& packet) { if (!packet.hasRemaining(8)) return; uint64_t guid = packet.readUInt64(); auto entity = entityManager.getEntity(guid); if (!entity || entity->getType() != ObjectType::GAMEOBJECT) return; auto go = std::static_pointer_cast(entity); uint32_t entry = go->getEntry(); if (entry == 0) return; auto cacheIt = gameObjectInfoCache_.find(entry); if (cacheIt == gameObjectInfoCache_.end()) { queryGameObjectInfo(entry, guid); return; } const GameObjectQueryResponseData& info = cacheIt->second; uint32_t pageId = 0; // AzerothCore layout: // type 9 (TEXT): data[0]=pageID // type 10 (GOOBER): data[7]=pageId if (info.type == 9) pageId = info.data[0]; else if (info.type == 10) pageId = info.data[7]; if (pageId != 0 && owner_.socket && owner_.state == WorldState::IN_WORLD) { owner_.bookPages_.clear(); // start a fresh book for this interaction auto req = PageTextQueryPacket::build(pageId, guid); owner_.socket->send(req); return; } if (!info.name.empty()) { owner_.addSystemChatMessage(info.name); } } void EntityController::handlePageTextQueryResponse(network::Packet& packet) { PageTextQueryResponseData data; if (!PageTextQueryResponseParser::parse(packet, data)) return; if (!data.isValid()) return; // Append page if not already collected bool alreadyHave = false; for (const auto& bp : owner_.bookPages_) { if (bp.pageId == data.pageId) { alreadyHave = true; break; } } if (!alreadyHave) { owner_.bookPages_.push_back({data.pageId, data.text}); } // Follow the chain: if there's a next page we haven't fetched yet, request it if (data.nextPageId != 0) { bool nextHave = false; for (const auto& bp : owner_.bookPages_) { if (bp.pageId == data.nextPageId) { nextHave = true; break; } } if (!nextHave && owner_.socket && owner_.state == WorldState::IN_WORLD) { auto req = PageTextQueryPacket::build(data.nextPageId, owner_.playerGuid); owner_.socket->send(req); } } LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, " nextPage=", data.nextPageId, " totalPages=", owner_.bookPages_.size()); } } // namespace game } // namespace wowee