From b0fafe5efa3fcbd31af3c1506b4f20cc919bb435 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 15 Mar 2026 01:21:23 -0700 Subject: [PATCH] fix: stabilize turtle world entry session handling --- Data/expansions/turtle/opcodes.json | 1 + include/core/application.hpp | 12 + include/game/game_handler.hpp | 18 + include/game/opcode_table.hpp | 4 +- include/game/packet_parsers.hpp | 1 + include/network/world_socket.hpp | 25 + include/rendering/character_renderer.hpp | 2 + include/rendering/m2_renderer.hpp | 2 + include/rendering/wmo_renderer.hpp | 2 + src/core/application.cpp | 404 ++-- src/game/game_handler.cpp | 2629 ++++++++++++---------- src/game/packet_parsers_classic.cpp | 190 ++ src/game/warden_memory.cpp | 25 +- src/game/warden_module.cpp | 20 +- src/game/world_packets.cpp | 6 +- src/network/world_socket.cpp | 242 +- src/rendering/character_renderer.cpp | 18 +- src/rendering/m2_renderer.cpp | 17 +- src/rendering/terrain_manager.cpp | 14 +- src/rendering/wmo_renderer.cpp | 31 +- 20 files changed, 2283 insertions(+), 1380 deletions(-) diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index 95a22888..c3d7cbd7 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -249,6 +249,7 @@ "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", "SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", "CMSG_BATTLEMASTER_JOIN": "0x2EE", + "SMSG_ADDON_INFO": "0x2EF", "CMSG_EMOTE": "0x102", "SMSG_EMOTE": "0x103", "CMSG_TEXT_EMOTE": "0x104", diff --git a/include/core/application.hpp b/include/core/application.hpp index 85339c04..d9b19e39 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -224,6 +224,7 @@ private: std::future future; }; std::vector asyncCreatureLoads_; + std::unordered_set asyncCreatureDisplayLoads_; // displayIds currently loading in background void processAsyncCreatureResults(bool unlimited = false); static constexpr int MAX_ASYNC_CREATURE_LOADS = 4; // concurrent background loads std::unordered_set deadCreatureGuids_; // GUIDs that should spawn in corpse/death pose @@ -280,7 +281,17 @@ private: float z = 0.0f; float orientation = 0.0f; }; + struct PendingTransportRegistration { + uint64_t guid = 0; + uint32_t entry = 0; + uint32_t displayId = 0; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float orientation = 0.0f; + }; std::unordered_map pendingTransportMoves_; // guid -> latest pre-registration move + std::deque pendingTransportRegistrations_; uint32_t nextGameObjectModelId_ = 20000; uint32_t nextGameObjectWmoModelId_ = 40000; bool testTransportSetup_ = false; @@ -433,6 +444,7 @@ private: }; std::vector pendingTransportDoodadBatches_; static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4; + void processPendingTransportRegistrations(); void processPendingTransportDoodads(); // Quest marker billboard sprites (above NPCs) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c308dadb..ec2e343e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -7,6 +7,7 @@ #include "game/inventory.hpp" #include "game/spell_defines.hpp" #include "game/group_defines.hpp" +#include "network/packet.hpp" #include #include #include @@ -2089,6 +2090,15 @@ private: * Handle incoming packet from world server */ void handlePacket(network::Packet& packet); + void enqueueIncomingPacket(const network::Packet& packet); + void enqueueIncomingPacketFront(network::Packet&& packet); + void processQueuedIncomingPackets(); + void enqueueUpdateObjectWork(UpdateObjectData&& data); + void processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs); + void processOutOfRangeObjects(const std::vector& guids); + void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); + void finalizeUpdateObjectBatch(bool newItemCreated); /** * Handle SMSG_AUTH_CHALLENGE from server @@ -2413,6 +2423,14 @@ private: // Network std::unique_ptr socket; + std::deque pendingIncomingPackets_; + struct PendingUpdateObjectWork { + UpdateObjectData data; + size_t nextBlockIndex = 0; + bool outOfRangeProcessed = false; + bool newItemCreated = false; + }; + std::deque pendingUpdateObjectWork_; // State WorldState state = WorldState::DISCONNECTED; diff --git a/include/game/opcode_table.hpp b/include/game/opcode_table.hpp index 91242206..aaecc837 100644 --- a/include/game/opcode_table.hpp +++ b/include/game/opcode_table.hpp @@ -49,12 +49,14 @@ public: /** Number of mapped opcodes. */ size_t size() const { return logicalToWire_.size(); } + /** Get canonical enum name for a logical opcode. */ + static const char* logicalToName(LogicalOpcode op); + private: std::unordered_map logicalToWire_; // LogicalOpcode → wire std::unordered_map wireToLogical_; // wire → LogicalOpcode static std::optional nameToLogical(const std::string& name); - static const char* logicalToName(LogicalOpcode op); }; /** diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 38560cc7..4446deba 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -451,6 +451,7 @@ public: class TurtlePacketParsers : public ClassicPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } + bool parseUpdateObject(network::Packet& packet, UpdateObjectData& data) override; bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; }; diff --git a/include/network/world_socket.hpp b/include/network/world_socket.hpp index 1d8f1d00..4192195c 100644 --- a/include/network/world_socket.hpp +++ b/include/network/world_socket.hpp @@ -7,7 +7,13 @@ #include "auth/vanilla_crypt.hpp" #include #include +#include #include +#include +#include +#include +#include +#include namespace wowee { namespace network { @@ -66,6 +72,8 @@ public: */ void initEncryption(const std::vector& sessionKey, uint32_t build = 12340); + void tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason); + /** * Check if header encryption is enabled */ @@ -76,11 +84,23 @@ private: * Try to parse complete packets from receive buffer */ void tryParsePackets(); + void pumpNetworkIO(); + void dispatchQueuedPackets(); + void asyncPumpLoop(); + void startAsyncPump(); + void stopAsyncPump(); + void closeSocketNoJoin(); socket_t sockfd = INVALID_SOCK; bool connected = false; bool encryptionEnabled = false; bool useVanillaCrypt = false; // true = XOR cipher, false = RC4 + bool useAsyncPump_ = true; + std::thread asyncPumpThread_; + std::atomic asyncPumpStop_{false}; + std::atomic asyncPumpRunning_{false}; + mutable std::mutex ioMutex_; + mutable std::mutex callbackMutex_; // WotLK RC4 ciphers for header encryption/decryption auth::RC4 encryptCipher; @@ -94,6 +114,8 @@ private: size_t receiveReadOffset_ = 0; // Optional reused packet queue (feature-gated) to reduce per-update allocations. std::vector parsedPacketsScratch_; + // Parsed packets waiting for callback dispatch; drained with a strict per-update budget. + std::deque pendingPacketCallbacks_; // Runtime-gated network optimization toggles (default off). bool useFastRecvAppend_ = false; @@ -105,6 +127,9 @@ private: // Debug-only tracing window for post-auth packet framing verification. int headerTracePacketsLeft = 0; + std::chrono::steady_clock::time_point packetTraceStart_{}; + std::chrono::steady_clock::time_point packetTraceUntil_{}; + std::string packetTraceReason_; // Packet callback std::function packetCallback; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 67b2274a..6129940f 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -296,7 +296,9 @@ private: std::unordered_map textureColorKeyBlackByPtr_; std::unordered_map compositeCache_; // key → texture for reuse std::unordered_set failedTextureCache_; // negative cache for budget exhaustion + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; // dedup warning logs + uint64_t textureLookupSerial_ = 0; size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 4ddea931..22578309 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -477,7 +477,9 @@ private: uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; std::unordered_set failedTextureCache_; + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; + uint64_t textureLookupSerial_ = 0; uint32_t textureBudgetRejectWarnings_ = 0; std::unique_ptr whiteTexture_; std::unique_ptr glowTexture_; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 7f6728af..07f3ac9d 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -671,7 +671,9 @@ private: uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 8192ull * 1024 * 1024; // 8 GB default, overridden at init std::unordered_set failedTextureCache_; + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; + uint64_t textureLookupSerial_ = 0; uint32_t textureBudgetRejectWarnings_ = 0; // Default white texture diff --git a/src/core/application.cpp b/src/core/application.cpp index 6f023fee..76556a6e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -824,6 +824,7 @@ void Application::logoutToLogin() { if (load.future.valid()) load.future.wait(); } asyncCreatureLoads_.clear(); + asyncCreatureDisplayLoads_.clear(); // --- Creature spawn queues --- pendingCreatureSpawns_.clear(); @@ -842,6 +843,7 @@ void Application::logoutToLogin() { gameObjectInstances_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportMoves_.clear(); + pendingTransportRegistrations_.clear(); pendingTransportDoodadBatches_.clear(); world.reset(); @@ -1053,6 +1055,7 @@ void Application::update(float deltaTime) { updateCheckpoint = "in_game: gameobject/transport queues"; runInGameStage("gameobject/transport queues", [&] { processGameObjectSpawnQueue(); + processPendingTransportRegistrations(); processPendingTransportDoodads(); }); inGameStep = "pending mount"; @@ -1725,6 +1728,19 @@ void Application::update(float deltaTime) { break; } + if (pendingWorldEntry_ && !loadingWorld_ && state != AppState::DISCONNECTED) { + auto entry = *pendingWorldEntry_; + pendingWorldEntry_.reset(); + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(1.0f); + } + loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); + } + // Update renderer (camera, etc.) only when in-game updateCheckpoint = "renderer update"; if (renderer && state == AppState::IN_GAME) { @@ -2025,24 +2041,19 @@ void Application::setupUICallbacks() { // If a world load is already in progress (re-entrant call from // gameHandler->update() processing SMSG_NEW_WORLD during warmup), - // defer this entry. The current load will pick it up when it finishes. + // defer this entry. The current load will pick it up when it finishes. if (loadingWorld_) { LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); pendingWorldEntry_ = {mapId, x, y, z}; return; } - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - // Stop any movement that was active before the teleport - if (renderer && renderer->getCameraController()) { - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(1.0f); - } - loadOnlineWorldTerrain(mapId, x, y, z); - // loadedMapId_ is set inside loadOnlineWorldTerrain (including - // any deferred entries it processes), so we must NOT override it here. + // Full world loads are expensive and `loadOnlineWorldTerrain()` itself + // drives `gameHandler->update()` during warmup. Queue the load here so + // it runs after the current packet handler returns instead of recursing + // from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`. + LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + pendingWorldEntry_ = {mapId, x, y, z}; }); auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { @@ -2712,133 +2723,28 @@ void Application::setupUICallbacks() { // Transport spawn callback (online mode) - register transports with TransportManager gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - auto* transportManager = gameHandler->getTransportManager(); - if (!transportManager || !renderer) return; + if (!renderer) return; - // Get the WMO instance ID from the GameObject spawn + // Get the GameObject instance now so late queue processing can rely on stable IDs. auto it = gameObjectInstances_.find(guid); if (it == gameObjectInstances_.end()) { LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); return; } - uint32_t wmoInstanceId = it->second.instanceId; - LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId, - " pos=(", x, ", ", y, ", ", z, ")"); - - // TransportAnimation.dbc is indexed by GameObject entry - uint32_t pathId = entry; - const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); - - bool clientAnim = transportManager->isClientSideAnimation(); - LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, - " guid=0x", std::hex, guid, std::dec, " entry=", entry, " pathId=", pathId, - " preferServer=", preferServerData); - - // Coordinates are already canonical (converted in game_handler.cpp when entity was created) - glm::vec3 canonicalSpawnPos(x, y, z); - - // Check if we have a real path from TransportAnimation.dbc (indexed by entry). - // AzerothCore transport entries are not always 1:1 with DBC path ids. - const bool shipOrZeppelinDisplay = - (displayId == 3015 || displayId == 3031 || displayId == 7546 || - displayId == 7446 || displayId == 1587 || displayId == 2454 || - displayId == 807 || displayId == 808); - bool hasUsablePath = transportManager->hasPathForEntry(entry); - if (shipOrZeppelinDisplay) { - // For true transports, reject tiny XY tracks that effectively look stationary. - hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); - } - - LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath, - " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); - - if (preferServerData) { - // Strict server-authoritative mode: do not infer/remap fallback routes. - if (!hasUsablePath) { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", - std::hex, guid, std::dec, " entry=", entry); - } else { - LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry); - } - } else if (!hasUsablePath) { - // Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids. - // For elevators (TB lift platforms), we must allow z-only paths here. - bool allowZOnly = (displayId == 455 || displayId == 462); - uint32_t inferredPath = transportManager->inferDbcPathForSpawn( - canonicalSpawnPos, 1200.0f, allowZOnly); - if (inferredPath != 0) { - pathId = inferredPath; - LOG_WARNING("Using inferred transport path ", pathId, " for entry ", entry); - } else { - uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); - if (remappedPath != 0) { - pathId = remappedPath; - LOG_WARNING("Using remapped fallback transport path ", pathId, - " for entry ", entry, " displayId=", displayId, - " (usableEntryPath=", transportManager->hasPathForEntry(entry), ")"); - } else { - LOG_WARNING("No TransportAnimation.dbc path for entry ", entry, - " - transport will be stationary"); - - // Fallback: Stationary at spawn point (wait for server to send real position) - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - } - } + auto pendingIt = std::find_if( + pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), + [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); + if (pendingIt != pendingTransportRegistrations_.end()) { + pendingIt->entry = entry; + pendingIt->displayId = displayId; + pendingIt->x = x; + pendingIt->y = y; + pendingIt->z = z; + pendingIt->orientation = orientation; } else { - LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", entry); - } - - // Register the transport with spawn position (prevents rendering at origin until server update) - transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); - - // Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer - if (!it->second.isWmo) { - if (auto* tr = transportManager->getTransport(guid)) { - tr->isM2 = true; - } - } - - // Server-authoritative movement - set initial position from spawn data - glm::vec3 canonicalPos(x, y, z); - transportManager->updateServerTransport(guid, canonicalPos, orientation); - - // If a move packet arrived before registration completed, replay latest now. - auto pendingIt = pendingTransportMoves_.find(guid); - if (pendingIt != pendingTransportMoves_.end()) { - const PendingTransportMove pending = pendingIt->second; - transportManager->updateServerTransport(guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, guid, std::dec, - " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ") orientation=", pending.orientation); - pendingTransportMoves_.erase(pendingIt); - } - - // For MO_TRANSPORT at (0,0,0): check if GO data is already cached with a taxiPathId - if (glm::length(canonicalSpawnPos) < 1.0f && gameHandler) { - auto goData = gameHandler->getCachedGameObjectInfo(entry); - if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { - uint32_t taxiPathId = goData->data[0]; - if (transportManager->hasTaxiPath(taxiPathId)) { - transportManager->assignTaxiPathToTransport(entry, taxiPathId); - LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", entry, - " taxiPathId=", taxiPathId); - } - } - } - - if (auto* tr = transportManager->getTransport(guid); tr) { - LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, - " pathId=", tr->pathId, - " mode=", (tr->useClientAnimation ? "client" : "server"), - " serverUpdates=", tr->serverUpdateCount); - } else { - LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, " (TransportManager instance missing)"); + pendingTransportRegistrations_.push_back( + PendingTransportRegistration{guid, entry, displayId, x, y, z, orientation}); } }); @@ -2853,6 +2759,15 @@ void Application::setupUICallbacks() { return; } + auto pendingRegIt = std::find_if( + pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), + [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); + if (pendingRegIt != pendingTransportRegistrations_.end()) { + pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; + LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); + return; + } + // Check if transport exists - if not, treat this as a late spawn (reconnection/server restart) if (!transportManager->getTransport(guid)) { LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec, @@ -4155,6 +4070,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float deferredEquipmentQueue_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportMoves_.clear(); + pendingTransportRegistrations_.clear(); pendingTransportDoodadBatches_.clear(); if (renderer) { @@ -4210,6 +4126,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (load.future.valid()) load.future.wait(); } asyncCreatureLoads_.clear(); + asyncCreatureDisplayLoads_.clear(); playerInstances_.clear(); onlinePlayerAppearance_.clear(); @@ -4866,25 +4783,23 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (world) world->update(1.0f / 60.0f); processPlayerSpawnQueue(); - // During load screen warmup: lift per-frame budgets so GPU uploads - // and spawns happen in bulk while the loading screen is still visible. - processCreatureSpawnQueue(true); - processAsyncNpcCompositeResults(true); - // Process equipment queue more aggressively during warmup (multiple per iteration) - for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { + // Keep warmup bounded: unbounded queue draining can stall the main thread + // long enough to trigger socket timeouts. + processCreatureSpawnQueue(false); + processAsyncNpcCompositeResults(false); + // Process equipment queue with a small bounded burst during warmup. + for (int i = 0; i < 2 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { processDeferredEquipmentQueue(); } if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { - cr->processPendingNormalMaps(INT_MAX); + cr->processPendingNormalMaps(4); } - // Process ALL pending game object spawns. - while (!pendingGameObjectSpawns_.empty()) { - auto& s = pendingGameObjectSpawns_.front(); - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - } + // Keep warmup responsive: process gameobject queue with the same bounded + // budget logic used in-world instead of draining everything in one tick. + processGameObjectSpawnQueue(); + processPendingTransportRegistrations(); processPendingTransportDoodads(); processPendingMount(); updateQuestMarkers(); @@ -7437,12 +7352,23 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t void Application::processAsyncCreatureResults(bool unlimited) { // Check completed async model loads and finalize on main thread (GPU upload + instance creation). - // Limit GPU model uploads per frame to avoid spikes, but always drain cheap bookkeeping. - // In unlimited mode (load screen), process all pending uploads without cap. - static constexpr int kMaxModelUploadsPerFrame = 1; + // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. + // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. + static constexpr int kMaxModelUploadsPerTick = 1; + static constexpr int kMaxModelUploadsPerTickWarmup = 1; + static constexpr float kFinalizeBudgetMs = 2.0f; + static constexpr float kFinalizeBudgetWarmupMs = 2.0f; + const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; + const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; + const auto tickStart = std::chrono::steady_clock::now(); int modelUploads = 0; for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { + if (std::chrono::duration( + std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { + break; + } + if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; @@ -7451,12 +7377,13 @@ void Application::processAsyncCreatureResults(bool unlimited) { // Peek: if this result needs a NEW model upload (not cached) and we've hit // the upload budget, defer to next frame without consuming the future. - if (!unlimited && modelUploads >= kMaxModelUploadsPerFrame) { + if (modelUploads >= maxUploadsThisTick) { break; } auto result = it->future.get(); it = asyncCreatureLoads_.erase(it); + asyncCreatureDisplayLoads_.erase(result.displayId); if (result.permanent_failure) { nonRenderableCreatureDisplayIds_.insert(result.displayId); @@ -7471,6 +7398,27 @@ void Application::processAsyncCreatureResults(bool unlimited) { continue; } + // Another async result may have already uploaded this displayId while this + // task was still running; in that case, skip duplicate GPU upload. + if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + continue; + } + // Model parsed on background thread — upload to GPU on main thread. auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; if (!charRenderer) { @@ -7478,6 +7426,10 @@ void Application::processAsyncCreatureResults(bool unlimited) { continue; } + // Count upload attempts toward the frame budget even if upload fails. + // Otherwise repeated failures can consume an unbounded amount of frame time. + modelUploads++; + // Upload model to GPU (must happen on main thread) // Use pre-decoded BLP cache to skip main-thread texture decode auto uploadStart = std::chrono::steady_clock::now(); @@ -7504,8 +7456,6 @@ void Application::processAsyncCreatureResults(bool unlimited) { displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); } displayIdModelCache_[result.displayId] = result.modelId; - modelUploads++; - pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); @@ -7659,6 +7609,14 @@ void Application::processCreatureSpawnQueue(bool unlimited) { // For new models: launch async load on background thread instead of blocking. if (needsNewModel) { + // Keep exactly one background load per displayId. Additional spawns for + // the same displayId stay queued and will spawn once cache is populated. + if (asyncCreatureDisplayLoads_.count(s.displayId)) { + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { // Too many in-flight — defer to next frame @@ -7904,6 +7862,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { return result; }); asyncCreatureLoads_.push_back(std::move(load)); + asyncCreatureDisplayLoads_.insert(s.displayId); asyncLaunched++; // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it rotationsLeft = pendingCreatureSpawns_.size(); @@ -8304,6 +8263,151 @@ void Application::processGameObjectSpawnQueue() { } } +void Application::processPendingTransportRegistrations() { + if (pendingTransportRegistrations_.empty()) return; + if (!gameHandler || !renderer) return; + + auto* transportManager = gameHandler->getTransportManager(); + if (!transportManager) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr int kMaxRegistrationsPerFrame = 2; + static constexpr float kRegistrationBudgetMs = 2.0f; + int processed = 0; + + for (auto it = pendingTransportRegistrations_.begin(); + it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kRegistrationBudgetMs) break; + + const PendingTransportRegistration pending = *it; + auto goIt = gameObjectInstances_.find(pending.guid); + if (goIt == gameObjectInstances_.end()) { + it = pendingTransportRegistrations_.erase(it); + continue; + } + + if (transportManager->getTransport(pending.guid)) { + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + it = pendingTransportRegistrations_.erase(it); + continue; + } + + const uint32_t wmoInstanceId = goIt->second.instanceId; + LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, + " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); + + // TransportAnimation.dbc is indexed by GameObject entry. + uint32_t pathId = pending.entry; + const bool preferServerData = gameHandler->hasServerTransportUpdate(pending.guid); + + bool clientAnim = transportManager->isClientSideAnimation(); + LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, + " guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " pathId=", pathId, + " preferServer=", preferServerData); + + glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); + const bool shipOrZeppelinDisplay = + (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || + pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || + pending.displayId == 807 || pending.displayId == 808); + bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); + if (shipOrZeppelinDisplay) { + hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); + } + + LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + + if (preferServerData) { + if (!hasUsablePath) { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", + std::hex, pending.guid, std::dec, " entry=", pending.entry); + } else { + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); + } + } else if (!hasUsablePath) { + bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); + uint32_t inferredPath = transportManager->inferDbcPathForSpawn( + canonicalSpawnPos, 1200.0f, allowZOnly); + if (inferredPath != 0) { + pathId = inferredPath; + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); + } else { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_WARNING("Using remapped fallback transport path ", pathId, + " for entry ", pending.entry, " displayId=", pending.displayId, + " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); + } else { + LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, + " - transport will be stationary"); + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + } + } + } else { + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); + } + + transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); + + if (!goIt->second.isWmo) { + if (auto* tr = transportManager->getTransport(pending.guid)) { + tr->isM2 = true; + } + } + + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + + auto moveIt = pendingTransportMoves_.find(pending.guid); + if (moveIt != pendingTransportMoves_.end()) { + const PendingTransportMove latestMove = moveIt->second; + transportManager->updateServerTransport( + pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); + LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, + " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, + ") orientation=", latestMove.orientation); + pendingTransportMoves_.erase(moveIt); + } + + if (glm::length(canonicalSpawnPos) < 1.0f) { + auto goData = gameHandler->getCachedGameObjectInfo(pending.entry); + if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { + uint32_t taxiPathId = goData->data[0]; + if (transportManager->hasTaxiPath(taxiPathId)) { + transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); + LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, + " taxiPathId=", taxiPathId); + } + } + } + + if (auto* tr = transportManager->getTransport(pending.guid); tr) { + LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " pathId=", tr->pathId, + " mode=", (tr->useClientAnimation ? "client" : "server"), + " serverUpdates=", tr->serverUpdateCount); + } else { + LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " (TransportManager instance missing)"); + } + + ++processed; + it = pendingTransportRegistrations_.erase(it); + } +} + void Application::processPendingTransportDoodads() { if (pendingTransportDoodadBatches_.empty()) return; if (!renderer || !assetManager) return; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4007c4c1..0bd11890 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -100,6 +100,53 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { 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 incomingPacketsBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float incomingPacketBudgetMs(WorldState state) { + static const int inWorldBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50); + static const int loginBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50); + return static_cast(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs); +} + +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 slowPacketLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +float slowUpdateObjectBlockLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +constexpr size_t kMaxQueuedInboundPackets = 4096; + bool hasFullPackedGuid(const network::Packet& packet) { if (packet.getReadPos() >= packet.getSize()) { return false; @@ -659,8 +706,7 @@ bool GameHandler::connect(const std::string& host, // Set up packet callback socket->setPacketCallback([this](const network::Packet& packet) { - network::Packet mutablePacket = packet; - handlePacket(mutablePacket); + enqueueIncomingPacket(packet); }); // Connect to world server @@ -712,6 +758,8 @@ void GameHandler::disconnect() { wardenModuleSize_ = 0; wardenModuleData_.clear(); wardenLoadedModule_.reset(); + pendingIncomingPackets_.clear(); + pendingUpdateObjectWork_.clear(); // Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects. entityManager.clear(); setState(WorldState::DISCONNECTED); @@ -778,11 +826,26 @@ void GameHandler::update(float deltaTime) { } } + { + auto packetStart = std::chrono::steady_clock::now(); + processQueuedIncomingPackets(); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetStart).count(); + if (packetMs > 3.0f) { + LOG_WARNING("SLOW queued packet handling: ", packetMs, "ms"); + } + } + // Detect server-side disconnect (socket closed during update) if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) { - LOG_WARNING("Server closed connection in state: ", worldStateName(state)); - disconnect(); - return; + if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + LOG_WARNING("Server closed connection in state: ", worldStateName(state)); + disconnect(); + return; + } + LOG_DEBUG("World socket closed with ", pendingIncomingPackets_.size(), + " queued packet(s) and ", pendingUpdateObjectWork_.size(), + " update-object batch(es) pending dispatch"); } // Post-gate visibility: determine whether server goes silent or closes after Warden requirement. @@ -971,7 +1034,9 @@ void GameHandler::update(float deltaTime) { timeSinceLastPing += deltaTime; timeSinceLastMoveHeartbeat_ += deltaTime; - if (timeSinceLastPing >= pingInterval) { + const float currentPingInterval = + (isClassicLikeExpansion() || isActiveExpansion("tbc")) ? 10.0f : pingInterval; + if (timeSinceLastPing >= currentPingInterval) { if (socket) { sendPing(); } @@ -7420,6 +7485,7 @@ void GameHandler::handlePacket(network::Packet& packet) { size_t dataLen = pdata.size(); size_t pos = packet.getReadPos(); static uint32_t multiPktWarnCount = 0; + std::vector subPackets; while (pos + 4 <= dataLen) { uint16_t subSize = static_cast( (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); @@ -7436,10 +7502,12 @@ void GameHandler::handlePacket(network::Packet& packet) { (static_cast(pdata[pos + 3]) << 8); std::vector subPayload(pdata.begin() + pos + 4, pdata.begin() + pos + 4 + payloadLen); - network::Packet subPacket(subOpcode, std::move(subPayload)); - handlePacket(subPacket); + subPackets.emplace_back(subOpcode, std::move(subPayload)); pos += 4 + payloadLen; } + for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) { + enqueueIncomingPacketFront(std::move(*it)); + } packet.setReadPos(packet.getSize()); break; } @@ -8168,6 +8236,159 @@ void GameHandler::handlePacket(network::Packet& packet) { } } +void GameHandler::enqueueIncomingPacket(const network::Packet& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(), + " packets); dropping oldest packet to preserve responsiveness"); + pendingIncomingPackets_.pop_front(); + } + pendingIncomingPackets_.push_back(packet); +} + +void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(), + " packets); dropping newest queued packet to preserve ordering"); + pendingIncomingPackets_.pop_back(); + } + pendingIncomingPackets_.emplace_front(std::move(packet)); +} + +void GameHandler::enqueueUpdateObjectWork(UpdateObjectData&& data) { + pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); +} + +void GameHandler::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs) { + if (pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(state); + int processedBlocks = 0; + + while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + auto& work = pendingUpdateObjectWork_.front(); + if (!work.outOfRangeProcessed) { + auto outOfRangeStart = std::chrono::steady_clock::now(); + processOutOfRangeObjects(work.data.outOfRangeGuids); + float outOfRangeMs = std::chrono::duration( + std::chrono::steady_clock::now() - outOfRangeStart).count(); + if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, + "ms guidCount=", work.data.outOfRangeGuids.size()); + } + work.outOfRangeProcessed = true; + } + + while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { + elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; + auto blockStart = std::chrono::steady_clock::now(); + applyUpdateObjectBlock(block, work.newItemCreated); + float blockMs = std::chrono::duration( + std::chrono::steady_clock::now() - blockStart).count(); + if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object block apply: ", blockMs, + "ms index=", work.nextBlockIndex, + " type=", static_cast(block.updateType), + " guid=0x", std::hex, block.guid, std::dec, + " objectType=", static_cast(block.objectType), + " fieldCount=", block.fields.size(), + " hasMovement=", block.hasMovement ? 1 : 0); + } + ++work.nextBlockIndex; + ++processedBlocks; + } + + if (work.nextBlockIndex >= work.data.blocks.size()) { + finalizeUpdateObjectBatch(work.newItemCreated); + pendingUpdateObjectWork_.pop_front(); + continue; + } + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + const auto& work = pendingUpdateObjectWork_.front(); + LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", + pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, + "/", work.data.blocks.size(), ", state=", worldStateName(state), ")"); + } +} + +void GameHandler::processQueuedIncomingPackets() { + if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state); + const float budgetMs = incomingPacketBudgetMs(state); + const auto start = std::chrono::steady_clock::now(); + int processed = 0; + + while (processed < maxPacketsThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + processPendingUpdateObjectWork(start, budgetMs); + if (!pendingUpdateObjectWork_.empty()) { + break; + } + continue; + } + + if (pendingIncomingPackets_.empty()) { + break; + } + + network::Packet packet = std::move(pendingIncomingPackets_.front()); + pendingIncomingPackets_.pop_front(); + const uint16_t wireOp = packet.getOpcode(); + const auto logicalOp = opcodeTable_.fromWire(wireOp); + auto packetHandleStart = std::chrono::steady_clock::now(); + handlePacket(packet); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetHandleStart).count(); + if (packetMs > slowPacketLogThresholdMs()) { + const char* logicalName = logicalOp + ? OpcodeTable::logicalToName(*logicalOp) + : "UNKNOWN"; + LOG_WARNING("SLOW packet handler: ", packetMs, + "ms wire=0x", std::hex, wireOp, std::dec, + " logical=", logicalName, + " size=", packet.getSize(), + " state=", worldStateName(state)); + } + ++processed; + } + + if (!pendingUpdateObjectWork_.empty()) { + return; + } + + if (!pendingIncomingPackets_.empty()) { + LOG_DEBUG("GameHandler packet budget reached (processed=", processed, + ", remaining=", pendingIncomingPackets_.size(), + ", state=", worldStateName(state), ")"); + } +} + void GameHandler::handleAuthChallenge(network::Packet& packet) { LOG_INFO("Handling SMSG_AUTH_CHALLENGE"); @@ -8643,9 +8864,29 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { return; } + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); + const bool alreadyInWorld = (state == WorldState::IN_WORLD); + const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId); + const float dxCurrent = movementInfo.x - canonical.x; + const float dyCurrent = movementInfo.y - canonical.y; + const float dzCurrent = movementInfo.z - canonical.z; + const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent; + + // Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already + // in-world. Re-running full world-entry handling here can trigger an expensive + // same-map reload/reset path and starve networking for tens of seconds. + if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) { + LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=", + data.mapId, " dist=", std::sqrt(distSqCurrent)); + return; + } + // Successfully entered the world (or teleported) currentMapId_ = data.mapId; setState(WorldState::IN_WORLD); + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world"); + } LOG_INFO("========================================"); LOG_INFO(" SUCCESSFULLY ENTERED WORLD!"); @@ -8656,7 +8897,6 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { LOG_INFO("Player is now in the game world"); // Initialize movement info with world entry position (server → canonical) - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z, ") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId); movementInfo.x = canonical.x; @@ -8695,49 +8935,30 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { encounterUnitGuids_.fill(0); raidTargetGuids_.fill(0); - // Clear inspect caches on world entry to avoid showing stale data - inspectedPlayerAchievements_.clear(); - - // Reset talent initialization so the first SMSG_TALENTS_INFO after login - // correctly sets the active spec (static locals don't reset across logins) - talentsInitialized_ = false; - learnedTalents_[0].clear(); - learnedTalents_[1].clear(); - learnedGlyphs_[0].fill(0); - learnedGlyphs_[1].fill(0); - unspentTalentPoints_[0] = 0; - unspentTalentPoints_[1] = 0; - activeTalentSpec_ = 0; - // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. activeAreaTriggers_.clear(); areaTriggerCheckTimer_ = -5.0f; areaTriggerSuppressFirst_ = true; - // Send CMSG_SET_ACTIVE_MOVER (required by some servers) + // Notify application to load terrain for this map/position (online mode) + if (worldEntryCallback_) { + worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); + } + + // Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers. if (playerGuid != 0 && socket) { auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid); socket->send(activeMoverPacket); LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec); } - // Notify application to load terrain for this map/position (online mode) - if (worldEntryCallback_) { - worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); - } - - // Auto-join default chat channels - autoJoinDefaultChannels(); - - // Auto-query guild info on login - const Character* activeChar = getActiveCharacter(); - if (activeChar && activeChar->hasGuild() && socket) { - auto gqPacket = GuildQueryPacket::build(activeChar->guildId); - socket->send(gqPacket); - auto grPacket = GuildRosterPacket::build(); - socket->send(grPacket); - LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + // Kick the first keepalive immediately on world entry. Classic-like realms + // can close the session before our default 30s ping cadence fires. + timeSinceLastPing = 0.0f; + if (socket) { + LOG_WARNING("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD"); + sendPing(); } // If we disconnected mid-taxi, attempt to recover to destination after login. @@ -8755,6 +8976,33 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { } if (initialWorldEntry) { + // Clear inspect caches on world entry to avoid showing stale data. + inspectedPlayerAchievements_.clear(); + + // Reset talent initialization so the first SMSG_TALENTS_INFO after login + // correctly sets the active spec (static locals don't reset across logins). + talentsInitialized_ = false; + learnedTalents_[0].clear(); + learnedTalents_[1].clear(); + learnedGlyphs_[0].fill(0); + learnedGlyphs_[1].fill(0); + unspentTalentPoints_[0] = 0; + unspentTalentPoints_[1] = 0; + activeTalentSpec_ = 0; + + // Auto-join default chat channels only on first world entry. + autoJoinDefaultChannels(); + + // Auto-query guild info on login. + const Character* activeChar = getActiveCharacter(); + if (activeChar && activeChar->hasGuild() && socket) { + auto gqPacket = GuildQueryPacket::build(activeChar->guildId); + socket->send(gqPacket); + auto grPacket = GuildRosterPacket::build(); + socket->send(grPacket); + LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + } + pendingQuestAcceptTimeouts_.clear(); pendingQuestAcceptNpcGuids_.clear(); pendingQuestQueryIds_.clear(); @@ -8763,11 +9011,18 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { completedQuests_.clear(); LOG_INFO("Queued quest log resync for login (from server quest slots)"); - // Request completed quest IDs from server (populates completedQuests_ when response arrives) + // Request completed quest IDs when the expansion supports it. Classic-like + // opcode tables do not define this packet, and sending 0xFFFF during world + // entry can desync the early session handshake. if (socket) { - network::Packet cqcPkt(wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED)); - socket->send(cqcPkt); - LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED); + if (queryCompletedWire != 0xFFFF) { + network::Packet cqcPkt(queryCompletedWire); + socket->send(cqcPkt); + LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + } else { + LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); + } } } } @@ -9131,6 +9386,19 @@ void GameHandler::handleWardenData(network::Packet& packet) { size_t moduleImageSize = wardenLoadedModule_->getModuleSize(); const auto& decompressedData = wardenLoadedModule_->getDecompressedData(); + if (!moduleImage || moduleImageSize == 0) { + LOG_WARNING("Warden: Loaded module has no executable image — using raw module hash fallback"); + std::vector fallbackReply = + !wardenModuleData_.empty() ? auth::Crypto::sha1(wardenModuleData_) : std::vector(20, 0); + std::vector resp; + resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT + resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end()); + sendWardenResponse(resp); + applyWardenSeedRekey(seed); + wardenState_ = WardenState::WAIT_CHECKS; + break; + } + // --- Empirical test: try multiple SHA1 computations and check against first CR entry --- if (!wardenCREntries_.empty()) { const auto& firstCR = wardenCREntries_[0]; @@ -9721,8 +9989,8 @@ void GameHandler::sendPing() { // Increment sequence number pingSequence++; - LOG_DEBUG("Sending CMSG_PING (heartbeat)"); - LOG_DEBUG(" Sequence: ", pingSequence); + LOG_WARNING("Sending CMSG_PING: sequence=", pingSequence, + " latencyHintMs=", lastLatency); // Record send time for RTT measurement pingTimestamp_ = std::chrono::steady_clock::now(); @@ -9772,7 +10040,7 @@ void GameHandler::sendMinimapPing(float wowX, float wowY) { } void GameHandler::handlePong(network::Packet& packet) { - LOG_DEBUG("Handling SMSG_PONG"); + LOG_WARNING("Handling SMSG_PONG"); PongData data; if (!PongParser::parse(packet, data)) { @@ -9792,7 +10060,8 @@ void GameHandler::handlePong(network::Packet& packet) { lastLatency = static_cast( std::chrono::duration_cast(rtt).count()); - LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ", latency: ", lastLatency, "ms)"); + LOG_WARNING("SMSG_PONG acknowledged: sequence=", data.sequence, + " latencyMs=", lastLatency); } uint32_t GameHandler::nextMovementTimestampMs() { @@ -10105,7 +10374,6 @@ void GameHandler::setOrientation(float orientation) { } void GameHandler::handleUpdateObject(network::Packet& packet) { - static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); UpdateObjectData data; if (!packetParsers_->parseUpdateObject(packet, data)) { static int updateObjErrors = 0; @@ -10115,6 +10383,61 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Fall through: process any blocks that were successfully parsed before the failure. } + enqueueUpdateObjectWork(std::move(data)); +} + +void GameHandler::processOutOfRangeObjects(const std::vector& guids) { + // Process out-of-range objects first + for (uint64_t guid : guids) { + auto entity = entityManager.getEntity(guid); + if (!entity) continue; + + const bool isKnownTransport = transportGuids_.count(guid) > 0; + if (isKnownTransport) { + // Keep transports alive across out-of-range flapping. + // Boats/zeppelins are global movers and removing them here can make + // them disappear until a later movement snapshot happens to recreate them. + const bool playerAboardNow = (playerTransportGuid_ == guid); + const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); + const bool movementSaysAboard = (movementInfo.transportGuid == guid); + LOG_INFO("Preserving transport on out-of-range: 0x", + std::hex, guid, std::dec, + " now=", playerAboardNow, + " sticky=", stickyAboard, + " movement=", movementSaysAboard); + continue; + } + + LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); + // Trigger despawn callbacks before removing entity + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); + otherPlayerVisibleItemEntries_.erase(guid); + otherPlayerVisibleDirty_.erase(guid); + otherPlayerMoveTimeMs_.erase(guid); + inspectedPlayerItemEntries_.erase(guid); + pendingAutoInspect_.erase(guid); + // Clear pending name query so the query is re-sent when this player + // comes back into range (entity is recreated as a new object). + pendingNameQueries.erase(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { + gameObjectDespawnCallback_(guid); + } + transportGuids_.erase(guid); + serverUpdatedTransportGuids_.erase(guid); + clearTransportAttachment(guid); + if (playerTransportGuid_ == guid) { + clearPlayerTransport(); + } + entityManager.removeEntity(guid); + } + +} + +void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { + static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); auto extractPlayerAppearance = [&](const std::map& fields, uint8_t& outRace, uint8_t& outGender, @@ -10236,1135 +10559,571 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { pendingMoneyDeltaTimer_ = 0.0f; }; - // Process out-of-range objects first - for (uint64_t guid : data.outOfRangeGuids) { - auto entity = entityManager.getEntity(guid); - if (!entity) continue; + switch (block.updateType) { + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: { + // Create new entity + std::shared_ptr entity; - const bool isKnownTransport = transportGuids_.count(guid) > 0; - if (isKnownTransport) { - // Keep transports alive across out-of-range flapping. - // Boats/zeppelins are global movers and removing them here can make - // them disappear until a later movement snapshot happens to recreate them. - const bool playerAboardNow = (playerTransportGuid_ == guid); - const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (movementInfo.transportGuid == guid); - LOG_INFO("Preserving transport on out-of-range: 0x", - std::hex, guid, std::dec, - " now=", playerAboardNow, - " sticky=", stickyAboard, - " movement=", movementSaysAboard); - continue; - } + switch (block.objectType) { + case ObjectType::PLAYER: + entity = std::make_shared(block.guid); + break; - LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); - // Trigger despawn callbacks before removing entity - if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { - creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { - playerDespawnCallback_(guid); - otherPlayerVisibleItemEntries_.erase(guid); - otherPlayerVisibleDirty_.erase(guid); - otherPlayerMoveTimeMs_.erase(guid); - inspectedPlayerItemEntries_.erase(guid); - pendingAutoInspect_.erase(guid); - // Clear pending name query so the query is re-sent when this player - // comes back into range (entity is recreated as a new object). - pendingNameQueries.erase(guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { - gameObjectDespawnCallback_(guid); - } - transportGuids_.erase(guid); - serverUpdatedTransportGuids_.erase(guid); - clearTransportAttachment(guid); - if (playerTransportGuid_ == guid) { - clearPlayerTransport(); - } - entityManager.removeEntity(guid); - } + case ObjectType::UNIT: + entity = std::make_shared(block.guid); + break; - // Process update blocks - bool newItemCreated = false; - for (const auto& block : data.blocks) { - switch (block.updateType) { - case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - // Create new entity - std::shared_ptr entity; + case ObjectType::GAMEOBJECT: + entity = std::make_shared(block.guid); + break; - switch (block.objectType) { - case ObjectType::PLAYER: - entity = std::make_shared(block.guid); - break; + default: + entity = std::make_shared(block.guid); + entity->setType(block.objectType); + break; + } - case ObjectType::UNIT: - entity = std::make_shared(block.guid); - break; - - case ObjectType::GAMEOBJECT: - entity = std::make_shared(block.guid); - break; - - default: - entity = std::make_shared(block.guid); - entity->setType(block.objectType); - break; + // Set position from movement block (server → canonical) + if (block.hasMovement) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + serverRunSpeed_ = block.runSpeed; } - - // Set position from movement block (server → canonical) - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - } - // Track player-on-transport state - if (block.guid == playerGuid) { - if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } - LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, - " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); - } else { - // Don't clear client-side M2 transport boarding (trams) — - // the server doesn't know about client-detected transport attachment. - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); - } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport"); - clearPlayerTransport(); - } + // Track player-on-transport state + if (block.guid == playerGuid) { + if (block.onTransport) { + setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; } - } - - // Track transport-relative children so they follow parent transport motion. - if (block.guid != playerGuid && - (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, block.objectType, block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); + LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, + " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); + } else { + // Don't clear client-side M2 transport boarding (trams) — + // the server doesn't know about client-detected transport attachment. + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport"); + clearPlayerTransport(); } } } - // Set fields - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - // Add to manager - entityManager.addEntity(block.guid, entity); - - // For the local player, capture the full initial field state (CREATE_OBJECT carries the - // large baseline update-field set, including visible item fields on many cores). - // Later VALUES updates often only include deltas and may never touch visible item fields. - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - lastPlayerFields_ = entity->getFields(); - maybeDetectVisibleItemLayout(); - } - - // Auto-query names (Phase 1) - if (block.objectType == ObjectType::PLAYER) { - queryPlayerName(block.guid); - if (block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - } else if (block.objectType == ObjectType::UNIT) { - auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (it != block.fields.end() && it->second != 0) { - auto unit = std::static_pointer_cast(entity); - unit->setEntry(it->second); - // Set name from cache immediately if available - std::string cached = getCachedCreatureName(it->second); - if (!cached.empty()) { - unit->setName(cached); + // Track transport-relative children so they follow parent transport motion. + if (block.guid != playerGuid && + (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + setTransportAttachment(block.guid, block.objectType, block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); } - queryCreatureInfo(it->second, block.guid); + } else { + clearTransportAttachment(block.guid); } } + } - // Extract health/mana/power from fields (Phase 2) — single pass - if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { + // Set fields + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + // Add to manager + entityManager.addEntity(block.guid, entity); + + // For the local player, capture the full initial field state (CREATE_OBJECT carries the + // large baseline update-field set, including visible item fields on many cores). + // Later VALUES updates often only include deltas and may never touch visible item fields. + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + lastPlayerFields_ = entity->getFields(); + maybeDetectVisibleItemLayout(); + } + + // Auto-query names (Phase 1) + if (block.objectType == ObjectType::PLAYER) { + queryPlayerName(block.guid); + if (block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + } else if (block.objectType == ObjectType::UNIT) { + auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (it != block.fields.end() && it->second != 0) { auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - bool unitInitiallyDead = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - for (const auto& [key, val] : block.fields) { - // Check all specific fields BEFORE power/maxpower range checks. - // In Classic, power indices (23-27) are adjacent to maxHealth (28), - // and maxPower indices (29-33) are adjacent to level (34) and faction (35). - // A range check like "key >= powerBase && key < powerBase+7" would - // incorrectly capture maxHealth/level/faction in Classic's tight layout. - if (key == ufHealth) { - unit->setHealth(val); - if (block.objectType == ObjectType::UNIT && val == 0) { - unitInitiallyDead = true; - } - if (block.guid == playerGuid && val == 0) { - playerDead_ = true; - LOG_INFO("Player logged in dead"); - } - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufLevel) { - unit->setLevel(val); - } else if (key == ufFaction) { unit->setFactionTemplate(val); } - else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufDisplayId) { unit->setDisplayId(val); } - 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; - } + 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; } - // 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); + if (block.guid == playerGuid && val == 0) { + playerDead_ = true; + LOG_INFO("Player logged in dead"); } - else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (old == 0 && val != 0) { - // Just mounted — find the mount aura (indefinite duration, self-cast) - mountAuraSpellId_ = 0; - for (const auto& a : playerAuras) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } + else if (key == ufLevel) { + unit->setLevel(val); + } else if (key == ufFaction) { unit->setFactionTemplate(val); } + else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufDisplayId) { unit->setDisplayId(val); } + else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + else if (key == ufDynFlags) { + unit->setDynamicFlags(val); + if (block.objectType == ObjectType::UNIT && + ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { + unitInitiallyDead = true; + } + } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + } + else if (key == ufMountDisplayId) { + if (block.guid == playerGuid) { + uint32_t old = currentMountDisplayId_; + currentMountDisplayId_ = val; + if (val != old && mountCallback_) mountCallback_(val); + if (old == 0 && val != 0) { + // Just mounted — find the mount aura (indefinite duration, self-cast) + mountAuraSpellId_ = 0; + for (const auto& a : playerAuras) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { + mountAuraSpellId_ = a.spellId; } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + mountAuraSpellId_ = fv; + break; } } } - LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - for (auto& a : playerAuras) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; } + LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); + } + if (old != 0 && val == 0) { + mountAuraSpellId_ = 0; + for (auto& a : playerAuras) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; } - unit->setMountDisplayId(val); - } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - } - if (block.guid == playerGuid) { - constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; - if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { - onTaxiFlight_ = true; - taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); - sanitizeMovementForTaxi(); - applyTaxiMountForCurrentNode(); } + unit->setMountDisplayId(val); + } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + } + if (block.guid == playerGuid) { + constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; + if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { + onTaxiFlight_ = true; + taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); + sanitizeMovementForTaxi(); + applyTaxiMountForCurrentNode(); } - if (block.guid == playerGuid && - (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { + } + if (block.guid == playerGuid && + (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { + playerDead_ = true; + LOG_INFO("Player logged in dead (dynamic flags)"); + } + // Detect ghost state on login via PLAYER_FLAGS + if (block.guid == playerGuid) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); + if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { + releasedSpirit_ = true; playerDead_ = true; - LOG_INFO("Player logged in dead (dynamic flags)"); + LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); } - // Detect ghost state on login via PLAYER_FLAGS - if (block.guid == playerGuid) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); - if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { - releasedSpirit_ = true; - playerDead_ = true; - LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); - if (ghostStateCallback_) ghostStateCallback_(true); + } + // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create + if (block.guid == playerGuid && isClassicLikeExpansion()) { + 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; } } - } - // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create - if (block.guid == playerGuid && isClassicLikeExpansion()) { - 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) { - playerAuras.clear(); - 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 = playerAuras[slot]; - a.spellId = it->second; - // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful - // Normalize to WotLK convention: 0x80 = negative (debuff) - uint8_t classicFlag = 0; - if (ufAuraFlags != 0xFFFF) { - auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); - if (fit != allFields.end()) - classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); - } - // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) - a.flags = (classicFlag & 0x02) ? 0x80u : 0u; - a.durationMs = -1; - a.maxDurationMs = -1; - a.casterGuid = playerGuid; - a.receivedAtMs = nowMs; + if (hasAuraField) { + playerAuras.clear(); + 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 = 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); } - } - LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); - } - } - } - // Determine hostility from faction template for online creatures. - // Always call isHostileFaction — factionTemplate=0 defaults to hostile - // in the lookup rather than silently staying at the struct default (false). - unit->setHostile(isHostileFaction(unit->getFactionTemplate())); - // Trigger creature spawn callback for units/players with displayId - if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { - LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " has displayId=0 — no spawn (entry=", unit->getEntry(), - " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - } - if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { - if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately via spawnPlayerCharacter() - } else if (block.objectType == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } else { - LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) + a.flags = (classicFlag & 0x02) ? 0x80u : 0u; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; } } - } else if (creatureSpawnCallback_) { - LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " at (", - unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - float unitScale = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&unitScale, &raw, sizeof(float)); - if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; - } - } - } - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); - if (unitInitiallyDead && npcDeathCallback_) { - npcDeathCallback_(block.guid); - } - } - // Initialise swim/walk state from spawn-time movement flags (cold-join fix). - // Without this, an entity already swimming/walking when the client joins - // won't get its animation state set until the next MSG_MOVE_* heartbeat. - if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && - block.guid != playerGuid) { - unitMoveFlagsCallback_(block.guid, block.moveFlags); - } - // Query quest giver status for NPCs with questgiver flag (0x02) - if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); } } } - // 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); + // Determine hostility from faction template for online creatures. + // Always call isHostileFaction — factionTemplate=0 defaults to hostile + // in the lookup rather than silently staying at the struct default (false). + unit->setHostile(isHostileFaction(unit->getFactionTemplate())); + // Trigger creature spawn callback for units/players with displayId + if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { + LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " has displayId=0 — no spawn (entry=", unit->getEntry(), + " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + } + if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { + if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately via spawnPlayerCharacter() + } else if (block.objectType == ObjectType::PLAYER) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + } } - queryGameObjectInfo(itEntry->second, block.guid); - } - // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) - LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, - " entry=", go->getEntry(), " displayId=", go->getDisplayId(), - " updateFlags=0x", std::hex, block.updateFlags, std::dec, - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - if (block.updateFlags & 0x0002) { - transportGuids_.insert(block.guid); - LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, - " entry=", go->getEntry(), - " displayId=", go->getDisplayId(), - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created - } - if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { - float goScale = 1.0f; + } else if (creatureSpawnCallback_) { + LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " at (", + unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + float unitScale = 1.0f; { uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); if (scaleIdx != 0xFFFF) { uint32_t raw = entity->getField(scaleIdx); if (raw != 0) { - std::memcpy(&goScale, &raw, sizeof(float)); - if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; + std::memcpy(&unitScale, &raw, sizeof(float)); + if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; } } } - gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), - go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); - } - // Fire transport move callback for transports (position update on re-creation) - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, - go->getX(), go->getY(), go->getZ(), go->getOrientation()); - } - } - // Detect player's own corpse object so we have the position even when - // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). - if (block.objectType == ObjectType::CORPSE && block.hasMovement) { - // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) - uint16_t ownerLowIdx = 6; - auto ownerLowIt = block.fields.find(ownerLowIdx); - uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; - auto ownerHighIt = block.fields.find(ownerLowIdx + 1); - uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; - uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; - if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { - // Server coords from movement block - corpseGuid_ = block.guid; - corpseX_ = block.x; - corpseY_ = block.y; - corpseZ_ = block.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, - " server=(", block.x, ", ", block.y, ", ", block.z, - ") map=", corpseMapId_); - } - } - - // Track online item objects (CONTAINER = bags, also tracked as items) - if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { - auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); - auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); - auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); - if (entryIt != block.fields.end() && entryIt->second != 0) { - // Preserve existing info when doing partial updates - OnlineItemInfo info = onlineItems_.count(block.guid) - ? onlineItems_[block.guid] : OnlineItemInfo{}; - info.entry = entryIt->second; - if (stackIt != block.fields.end()) info.stackCount = stackIt->second; - if (durIt != block.fields.end()) info.curDurability = durIt->second; - if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; - bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); - onlineItems_[block.guid] = info; - if (isNew) newItemCreated = true; - queryItemInfo(info.entry, block.guid); - } - // Extract container slot GUIDs for bags - if (block.objectType == ObjectType::CONTAINER) { - extractContainerFields(block.guid, block.fields); - } - } - - // Extract XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - // Auto-detect coinage index using the previous snapshot vs this full snapshot. - maybeDetectCoinageIndex(lastPlayerFields_, block.fields); - - lastPlayerFields_ = block.fields; - detectInventorySlotBases(block.fields); - - if (kVerboseUpdateObject) { - uint16_t maxField = 0; - for (const auto& [key, _val] : block.fields) { - if (key > maxField) maxField = key; + creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); + if (unitInitiallyDead && npcDeathCallback_) { + npcDeathCallback_(block.guid); } - 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 ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); - const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); - const uint16_t ufStats[5] = { - fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), - fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), - fieldIndex(UF::UNIT_FIELD_STAT4) - }; - const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); - const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); - const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); - const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); - const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); - const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); - const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); - const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); - const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); - const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); - const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { playerXp_ = val; } - else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } - else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - for (auto& ch : characters) { - if (ch.guid == playerGuid) { ch.level = val; break; } + // Initialise swim/walk state from spawn-time movement flags (cold-join fix). + // Without this, an entity already swimming/walking when the client joins + // won't get its animation state set until the next MSG_MOVE_* heartbeat. + if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && + block.guid != playerGuid) { + unitMoveFlagsCallback_(block.guid, block.moveFlags); + } + // Query quest giver status for NPCs with questgiver flag (0x02) + if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + socket->send(qsPkt); + } + } + } + // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) + if (block.objectType == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); + if (itDisp != block.fields.end()) { + go->setDisplayId(itDisp->second); + } + auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (itEntry != block.fields.end() && itEntry->second != 0) { + go->setEntry(itEntry->second); + auto cacheIt = gameObjectInfoCache_.find(itEntry->second); + if (cacheIt != gameObjectInfoCache_.end()) { + go->setName(cacheIt->second.name); + } + queryGameObjectInfo(itEntry->second, block.guid); + } + // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) + LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), " displayId=", go->getDisplayId(), + " updateFlags=0x", std::hex, block.updateFlags, std::dec, + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + if (block.updateFlags & 0x0002) { + transportGuids_.insert(block.guid); + LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), + " displayId=", go->getDisplayId(), + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created + } + if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { + float goScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&goScale, &raw, sizeof(float)); + if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; } } - else if (key == ufCoinage) { - playerMoneyCopper_ = val; - LOG_DEBUG("Money set from update fields: ", val, " copper"); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); - } - else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { - playerResistances_[key - ufArmor - 1] = static_cast(val); - } - else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE - // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY - uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte != 0); - } - else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { - chosenTitleBit_ = static_cast(val); - LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); - } - else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } - else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } - else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { - playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); - } - else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } - else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } - else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } - else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } - else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } - else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } - else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { - std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); - } - else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { - playerCombatRatings_[key - ufRating1] = static_cast(val); - } - else { - for (int si = 0; si < 5; ++si) { - if (ufStats[si] != 0xFFFF && key == ufStats[si]) { - playerStats_[si] = static_cast(val); - break; - } - } - } - // Do not synthesize quest-log entries from raw update-field slots. - // Slot layouts differ on some classic-family realms and can produce - // phantom "already accepted" quests that block quest acceptance. } - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - maybeDetectVisibleItemLayout(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - applyQuestStateFromFields(lastPlayerFields_); + gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), + go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); + } + // Fire transport move callback for transports (position update on re-creation) + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, + go->getX(), go->getY(), go->getZ(), go->getOrientation()); + } + } + // Detect player's own corpse object so we have the position even when + // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). + if (block.objectType == ObjectType::CORPSE && block.hasMovement) { + // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) + uint16_t ownerLowIdx = 6; + auto ownerLowIt = block.fields.find(ownerLowIdx); + uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; + auto ownerHighIt = block.fields.find(ownerLowIdx + 1); + uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; + uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; + if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { + // Server coords from movement block + corpseGuid_ = block.guid; + corpseX_ = block.x; + corpseY_ = block.y; + corpseZ_ = block.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, + " server=(", block.x, ", ", block.y, ", ", block.z, + ") map=", corpseMapId_); } - break; } - case UpdateType::VALUES: { - // Update existing entity fields - auto entity = entityManager.getEntity(block.guid); - if (entity) { - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - - if (block.guid != playerGuid && - (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, entity->getType(), block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - } - - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - - // Update cached health/mana/power values (Phase 2) — single pass - if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - uint32_t oldDisplayId = unit->getDisplayId(); - bool displayIdChanged = false; - bool npcDeathNotified = false; - bool npcRespawnNotified = false; - 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) { - if (key == ufHealth) { - uint32_t oldHealth = unit->getHealth(); - unit->setHealth(val); - if (val == 0) { - if (block.guid == autoAttackTarget) { - stopAutoAttack(); - } - hostileAttackers_.erase(block.guid); - if (block.guid == playerGuid) { - playerDead_ = true; - releasedSpirit_ = false; - stopAutoAttack(); - // Cache death position as corpse location. - // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so - // this is the primary source for canReclaimCorpse(). - // movementInfo is canonical (x=north, y=west); corpseX_/Y_ - // are raw server coords (x=west, y=north) — swap axes. - corpseX_ = movementInfo.y; // canonical west = server X - corpseY_ = movementInfo.x; // canonical north = server Y - corpseZ_ = movementInfo.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Player died! Corpse position cached at server=(", - corpseX_, ",", corpseY_, ",", corpseZ_, - ") map=", corpseMapId_); - } - if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (oldHealth == 0 && val > 0) { - if (block.guid == playerGuid) { - playerDead_ = false; - if (!releasedSpirit_) { - LOG_INFO("Player resurrected!"); - } else { - LOG_INFO("Player entered ghost form"); - } - } - if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - // Specific fields checked BEFORE power/maxpower range checks - // (Classic packs maxHealth/level/faction adjacent to power indices) - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (key == ufDynFlags) { - uint32_t oldDyn = unit->getDynamicFlags(); - unit->setDynamicFlags(val); - if (block.guid == playerGuid) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - playerDead_ = true; - releasedSpirit_ = false; - corpseX_ = movementInfo.y; - corpseY_ = movementInfo.x; - corpseZ_ = movementInfo.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); - } else if (wasDead && !nowDead) { - playerDead_ = false; - releasedSpirit_ = false; - LOG_INFO("Player resurrected (dynamic flags)"); - } - } else if (entity->getType() == ObjectType::UNIT) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - if (!npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (wasDead && !nowDead) { - if (!npcRespawnNotified && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - } - } else if (key == ufLevel) { - uint32_t oldLvl = unit->getLevel(); - unit->setLevel(val); - if (block.guid != playerGuid && - entity->getType() == ObjectType::PLAYER && - val > oldLvl && oldLvl > 0 && - otherPlayerLevelUpCallback_) { - otherPlayerLevelUpCallback_(block.guid, val); - } - } - else if (key == ufFaction) { - unit->setFactionTemplate(val); - unit->setHostile(isHostileFaction(val)); - } else if (key == ufDisplayId) { - if (val != unit->getDisplayId()) { - unit->setDisplayId(val); - displayIdChanged = true; - } - } else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (old == 0 && val != 0) { - mountAuraSpellId_ = 0; - for (const auto& a : playerAuras) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } - } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } - } - } - } - LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - for (auto& a : 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); - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); - } - } - - // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated - if (block.guid == playerGuid && isClassicLikeExpansion()) { - 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) { - playerAuras.clear(); - 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 = playerAuras[slot]; - a.spellId = it->second; - // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - uint8_t aFlag = 0; - if (ufAuraFlags != 0xFFFF) { - auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); - if (fit != allFields.end()) - aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); - } - a.flags = aFlag; - a.durationMs = -1; - a.maxDurationMs = -1; - a.casterGuid = playerGuid; - a.receivedAtMs = nowMs; - } - } - LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); - } - } - } - - // Some units/players are created without displayId and get it later via VALUES. - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && - displayIdChanged && - unit->getDisplayId() != 0 && - unit->getDisplayId() != oldDisplayId) { - if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately - } else if (entity->getType() == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } else { - LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); - } - } - } else if (creatureSpawnCallback_) { - float unitScale2 = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&unitScale2, &raw, sizeof(float)); - if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; - } - } - } - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); - bool isDeadNow = (unit->getHealth() == 0) || - ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); - if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } - if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); - } - } - } - // Update XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid) { - const bool needCoinageDetectSnapshot = - (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); - std::map oldFieldsSnapshot; - if (needCoinageDetectSnapshot) { - oldFieldsSnapshot = lastPlayerFields_; - } - if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - // Some server dismount paths update run speed without updating mount display field. - if (!onTaxiFlight_ && !taxiMountActive_ && - currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { - LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, - " displayId=", currentMountDisplayId_); - currentMountDisplayId_ = 0; - if (mountCallback_) { - mountCallback_(0); - } - } - } - auto mergeHint = lastPlayerFields_.end(); - for (const auto& [key, val] : block.fields) { - mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); - } - if (needCoinageDetectSnapshot) { - maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); - } - maybeDetectVisibleItemLayout(); - detectInventorySlotBases(block.fields); - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); - const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); - const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); - const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); - const uint16_t ufStatsV[5] = { - fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), - fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), - fieldIndex(UF::UNIT_FIELD_STAT4) - }; - const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); - const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); - const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); - const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); - const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); - const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); - const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); - const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); - const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); - const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); - const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { - playerXp_ = val; - LOG_DEBUG("XP updated: ", val); - } - else if (key == ufPlayerNextXp) { - playerNextLevelXp_ = val; - LOG_DEBUG("Next level XP updated: ", val); - } - else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { - playerRestedXp_ = val; - } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - LOG_DEBUG("Level updated: ", val); - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.level = val; - break; - } - } - } - else if (key == ufCoinage) { - playerMoneyCopper_ = val; - LOG_DEBUG("Money updated via VALUES: ", val, " copper"); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - } - else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { - playerResistances_[key - ufArmor - 1] = static_cast(val); - } - else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE - // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY - uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte != 0); - } - else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { - chosenTitleBit_ = static_cast(val); - LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); - } - else if (key == ufPlayerFlags) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - bool wasGhost = releasedSpirit_; - bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; - if (!wasGhost && nowGhost) { - releasedSpirit_ = true; - LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); - if (ghostStateCallback_) ghostStateCallback_(true); - } else if (wasGhost && !nowGhost) { - releasedSpirit_ = false; - playerDead_ = false; - repopPending_ = false; - resurrectPending_ = false; - corpseMapId_ = 0; // corpse reclaimed - corpseGuid_ = 0; - LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); - if (ghostStateCallback_) ghostStateCallback_(false); - } - } - else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } - else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } - else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { - playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); - } - else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } - else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } - else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } - else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } - else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } - else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } - else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { - std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); - } - else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { - playerCombatRatings_[key - ufRating1V] = static_cast(val); - } - else { - for (int si = 0; si < 5; ++si) { - if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { - playerStats_[si] = static_cast(val); - break; - } - } - } - } - // Do not auto-create quests from VALUES quest-log slot fields for the - // same reason as CREATE_OBJECT2 above (can be misaligned per realm). - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - applyQuestStateFromFields(lastPlayerFields_); - } - - // Update item stack count / durability for online items - if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { - bool inventoryChanged = false; - const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); - const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); - const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); - const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); - const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); - - auto it = onlineItems_.find(block.guid); - bool isItemInInventory = (it != onlineItems_.end()); - - for (const auto& [key, val] : block.fields) { - if (key == itemStackField && isItemInInventory) { - if (it->second.stackCount != val) { - it->second.stackCount = val; - inventoryChanged = true; - } - } else if (key == itemDurField && isItemInInventory) { - if (it->second.curDurability != val) { - it->second.curDurability = val; - inventoryChanged = true; - } - } else if (key == itemMaxDurField && isItemInInventory) { - if (it->second.maxDurability != val) { - it->second.maxDurability = val; - inventoryChanged = true; - } - } - } - // Update container slot GUIDs on bag content changes - if (entity->getType() == ObjectType::CONTAINER) { - for (const auto& [key, _] : block.fields) { - if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || - (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { - inventoryChanged = true; - break; - } - } - extractContainerFields(block.guid, block.fields); - } - if (inventoryChanged) { - rebuildOnlineInventory(); - } - } - if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } else if (gameObjectMoveCallback_) { - gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } - } - - LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); - } else { + // 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)); + if (entryIt != block.fields.end() && entryIt->second != 0) { + // Preserve existing info when doing partial updates + OnlineItemInfo info = onlineItems_.count(block.guid) + ? onlineItems_[block.guid] : OnlineItemInfo{}; + info.entry = entryIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; + bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); + onlineItems_[block.guid] = info; + if (isNew) newItemCreated = true; + queryItemInfo(info.entry, block.guid); + } + // Extract container slot GUIDs for bags + if (block.objectType == ObjectType::CONTAINER) { + extractContainerFields(block.guid, block.fields); } - break; } - case UpdateType::MOVEMENT: { - // Diagnostic: Log if we receive MOVEMENT blocks for transports - if (transportGuids_.count(block.guid)) { - LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); + // Extract XP / inventory slot / skill fields for player entity + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + // Auto-detect coinage index using the previous snapshot vs this full snapshot. + maybeDetectCoinageIndex(lastPlayerFields_, block.fields); + + lastPlayerFields_ = block.fields; + detectInventorySlotBases(block.fields); + + if (kVerboseUpdateObject) { + uint16_t maxField = 0; + for (const auto& [key, _val] : block.fields) { + if (key > maxField) maxField = key; + } + LOG_INFO("Player update with ", block.fields.size(), + " fields (max index=", maxField, ")"); } - // Update entity position (server → canonical) - auto entity = entityManager.getEntity(block.guid); - if (entity) { + 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 ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { playerXp_ = val; } + else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } + else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } + else if (key == ufPlayerLevel) { + serverPlayerLevel_ = val; + for (auto& ch : characters) { + if (ch.guid == playerGuid) { ch.level = val; break; } + } + } + else if (key == ufCoinage) { + playerMoneyCopper_ = val; + LOG_DEBUG("Money set from update fields: ", val, " copper"); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + playerArmorRating_ = static_cast(val); + LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte != 0); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); + } + else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { + playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); + } + else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); + } + else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { + playerCombatRatings_[key - ufRating1] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } + // Do not synthesize quest-log entries from raw update-field slots. + // Slot layouts differ on some classic-family realms and can produce + // phantom "already accepted" quests that block quest acceptance. + } + if (applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) rebuildOnlineInventory(); + maybeDetectVisibleItemLayout(); + extractSkillFields(lastPlayerFields_); + extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); + } + break; + } + + case UpdateType::VALUES: { + // Update existing entity fields + auto entity = entityManager.getEntity(block.guid); + if (entity) { + if (block.hasMovement) { glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); if (block.guid != playerGuid && (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { @@ -11383,78 +11142,593 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { clearTransportAttachment(block.guid); } } + } - if (block.guid == playerGuid) { - movementInfo.orientation = oCanonical; + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } - // Track player-on-transport state from MOVEMENT updates - if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; + if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + + // Update cached health/mana/power values (Phase 2) — single pass + if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + uint32_t oldDisplayId = unit->getDisplayId(); + bool displayIdChanged = false; + bool npcDeathNotified = false; + bool npcRespawnNotified = false; + 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) { + if (key == ufHealth) { + uint32_t oldHealth = unit->getHealth(); + unit->setHealth(val); + if (val == 0) { + if (block.guid == autoAttackTarget) { + stopAutoAttack(); + } + hostileAttackers_.erase(block.guid); + if (block.guid == playerGuid) { + playerDead_ = true; + releasedSpirit_ = false; + stopAutoAttack(); + // Cache death position as corpse location. + // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so + // this is the primary source for canReclaimCorpse(). + // movementInfo is canonical (x=north, y=west); corpseX_/Y_ + // are raw server coords (x=west, y=north) — swap axes. + corpseX_ = movementInfo.y; // canonical west = server X + corpseY_ = movementInfo.x; // canonical north = server Y + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died! Corpse position cached at server=(", + corpseX_, ",", corpseY_, ",", corpseZ_, + ") map=", corpseMapId_); + } + if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (oldHealth == 0 && val > 0) { + if (block.guid == playerGuid) { + playerDead_ = false; + if (!releasedSpirit_) { + LOG_INFO("Player resurrected!"); + } else { + LOG_INFO("Player entered ghost form"); + } + } + if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { + npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } } - LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; - // Don't clear client-side M2 transport boarding - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); + // Specific fields checked BEFORE power/maxpower range checks + // (Classic packs maxHealth/level/faction adjacent to power indices) + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (key == ufDynFlags) { + uint32_t oldDyn = unit->getDynamicFlags(); + unit->setDynamicFlags(val); + if (block.guid == playerGuid) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + playerDead_ = true; + releasedSpirit_ = false; + corpseX_ = movementInfo.y; + corpseY_ = movementInfo.x; + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); + } else if (wasDead && !nowDead) { + playerDead_ = false; + releasedSpirit_ = false; + LOG_INFO("Player resurrected (dynamic flags)"); + } + } else if (entity->getType() == ObjectType::UNIT) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + if (!npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (wasDead && !nowDead) { + if (!npcRespawnNotified && npcRespawnCallback_) { + npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } + } } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport (MOVEMENT)"); - clearPlayerTransport(); + } else if (key == ufLevel) { + uint32_t oldLvl = unit->getLevel(); + unit->setLevel(val); + if (block.guid != playerGuid && + entity->getType() == ObjectType::PLAYER && + val > oldLvl && oldLvl > 0 && + otherPlayerLevelUpCallback_) { + otherPlayerLevelUpCallback_(block.guid, val); + } + } + else if (key == ufFaction) { + unit->setFactionTemplate(val); + unit->setHostile(isHostileFaction(val)); + } else if (key == ufDisplayId) { + if (val != unit->getDisplayId()) { + unit->setDisplayId(val); + displayIdChanged = true; + } + } else if (key == ufMountDisplayId) { + if (block.guid == playerGuid) { + uint32_t old = currentMountDisplayId_; + currentMountDisplayId_ = val; + if (val != old && mountCallback_) mountCallback_(val); + if (old == 0 && val != 0) { + mountAuraSpellId_ = 0; + for (const auto& a : playerAuras) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { + mountAuraSpellId_ = a.spellId; + } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + mountAuraSpellId_ = fv; + break; + } + } + } + } + LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); + } + if (old != 0 && val == 0) { + mountAuraSpellId_ = 0; + for (auto& a : 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); + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + } + } + + // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == playerGuid && isClassicLikeExpansion()) { + 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) { + playerAuras.clear(); + 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 = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); } } } - // Fire transport move callback if this is a known transport + // Some units/players are created without displayId and get it later via VALUES. + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && + displayIdChanged && + unit->getDisplayId() != 0 && + unit->getDisplayId() != oldDisplayId) { + if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately + } else if (entity->getType() == ObjectType::PLAYER) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); + } + } + } else if (creatureSpawnCallback_) { + float unitScale2 = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale2, &raw, sizeof(float)); + if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; + } + } + } + creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } + if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + socket->send(qsPkt); + } + } + } + // Update XP / inventory slot / skill fields for player entity + if (block.guid == playerGuid) { + const bool needCoinageDetectSnapshot = + (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); + std::map oldFieldsSnapshot; + if (needCoinageDetectSnapshot) { + oldFieldsSnapshot = lastPlayerFields_; + } + if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + serverRunSpeed_ = block.runSpeed; + // Some server dismount paths update run speed without updating mount display field. + if (!onTaxiFlight_ && !taxiMountActive_ && + currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { + LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, + " displayId=", currentMountDisplayId_); + currentMountDisplayId_ = 0; + if (mountCallback_) { + mountCallback_(0); + } + } + } + auto mergeHint = lastPlayerFields_.end(); + for (const auto& [key, val] : block.fields) { + mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); + } + if (needCoinageDetectSnapshot) { + maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); + } + maybeDetectVisibleItemLayout(); + detectInventorySlotBases(block.fields); + bool slotsChanged = false; + const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); + const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); + const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { + playerXp_ = val; + LOG_DEBUG("XP updated: ", val); + } + else if (key == ufPlayerNextXp) { + playerNextLevelXp_ = val; + LOG_DEBUG("Next level XP updated: ", val); + } + else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { + playerRestedXp_ = val; + } + else if (key == ufPlayerLevel) { + serverPlayerLevel_ = val; + LOG_DEBUG("Level updated: ", val); + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.level = val; + break; + } + } + } + else if (key == ufCoinage) { + playerMoneyCopper_ = val; + LOG_DEBUG("Money updated via VALUES: ", val, " copper"); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + playerArmorRating_ = static_cast(val); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte != 0); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); + } + else if (key == ufPlayerFlags) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + bool wasGhost = releasedSpirit_; + bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; + if (!wasGhost && nowGhost) { + releasedSpirit_ = true; + LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); + } else if (wasGhost && !nowGhost) { + releasedSpirit_ = false; + playerDead_ = false; + repopPending_ = false; + resurrectPending_ = false; + corpseMapId_ = 0; // corpse reclaimed + corpseGuid_ = 0; + LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); + if (ghostStateCallback_) ghostStateCallback_(false); + } + } + else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { + playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); + } + else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); + } + else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { + playerCombatRatings_[key - ufRating1V] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } + } + // Do not auto-create quests from VALUES quest-log slot fields for the + // same reason as CREATE_OBJECT2 above (can be misaligned per realm). + if (applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) rebuildOnlineInventory(); + extractSkillFields(lastPlayerFields_); + extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); + } + + // Update item stack count / durability for online items + if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { + bool inventoryChanged = false; + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); + const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); + const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); + const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); + + auto it = onlineItems_.find(block.guid); + bool isItemInInventory = (it != onlineItems_.end()); + + for (const auto& [key, val] : block.fields) { + if (key == itemStackField && isItemInInventory) { + if (it->second.stackCount != val) { + it->second.stackCount = val; + inventoryChanged = true; + } + } else if (key == itemDurField && isItemInInventory) { + if (it->second.curDurability != val) { + it->second.curDurability = val; + inventoryChanged = true; + } + } else if (key == itemMaxDurField && isItemInInventory) { + if (it->second.maxDurability != val) { + it->second.maxDurability = val; + inventoryChanged = true; + } + } + } + // Update container slot GUIDs on bag content changes + if (entity->getType() == ObjectType::CONTAINER) { + for (const auto& [key, _] : block.fields) { + if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || + (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { + inventoryChanged = true; + break; + } + } + extractContainerFields(block.guid, block.fields); + } + if (inventoryChanged) { + rebuildOnlineInventory(); + } + } + if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { if (transportGuids_.count(block.guid) && transportMoveCallback_) { serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); - } - // Fire move callback for non-transport gameobjects. - if (entity->getType() == ObjectType::GAMEOBJECT && - transportGuids_.count(block.guid) == 0 && - gameObjectMoveCallback_) { + transportMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } else if (gameObjectMoveCallback_) { gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); } - // Fire move callback for non-player units (creatures). - // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many - // servers (especially vanilla/Turtle WoW) communicate NPC positions - // via MOVEMENT blocks instead. Use duration=0 for an instant snap. - if (block.guid != playerGuid && - entity->getType() == ObjectType::UNIT && - transportGuids_.count(block.guid) == 0 && - creatureMoveCallback_) { - creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); - } - } else { - LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); } - break; + + LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); + } else { + } + break; + } + + case UpdateType::MOVEMENT: { + // Diagnostic: Log if we receive MOVEMENT blocks for transports + if (transportGuids_.count(block.guid)) { + LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); } - default: - break; - } - } + // Update entity position (server → canonical) + auto entity = entityManager.getEntity(block.guid); + if (entity) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); + if (block.guid != playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); + } + } + + if (block.guid == playerGuid) { + movementInfo.orientation = oCanonical; + + // Track player-on-transport state from MOVEMENT updates + if (block.onTransport) { + setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + } + LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + // Don't clear client-side M2 transport boarding + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport (MOVEMENT)"); + clearPlayerTransport(); + } + } + } + + // Fire transport move callback if this is a known transport + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); + } + // Fire move callback for non-transport gameobjects. + if (entity->getType() == ObjectType::GAMEOBJECT && + transportGuids_.count(block.guid) == 0 && + gameObjectMoveCallback_) { + gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } + // Fire move callback for non-player units (creatures). + // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many + // servers (especially vanilla/Turtle WoW) communicate NPC positions + // via MOVEMENT blocks instead. Use duration=0 for an instant snap. + if (block.guid != playerGuid && + entity->getType() == ObjectType::UNIT && + transportGuids_.count(block.guid) == 0 && + creatureMoveCallback_) { + creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); + } + } else { + LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); + } + break; + } + + default: + break; + } +} + +void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) { tabCycleStale = true; // Entity count logging disabled @@ -20653,6 +20927,9 @@ void GameHandler::handleNewWorld(network::Packet& packet) { } currentMapId_ = mapId; + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "new_world"); + } // Update player position glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); @@ -20706,6 +20983,12 @@ void GameHandler::handleNewWorld(network::Packet& packet) { LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK"); } + timeSinceLastPing = 0.0f; + if (socket) { + LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK"); + sendPing(); + } + // Reload terrain at new position. // Pass isSameMap as isInitialEntry so the application despawns and // re-registers renderer instances before the server resends CREATE_OBJECTs. diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 4a28e556..d3d55dc6 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -441,6 +441,18 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da if (rawHitCount > 128) { LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); } + // Packed GUIDs are variable length, but each target needs at least 1 byte (mask). + // Require the minimum bytes before entering per-target parsing loops. + if (rem() < static_cast(rawHitCount) + 1u) { // +1 for mandatory missCount byte + static uint32_t badHitCountTrunc = 0; + ++badHitCountTrunc; + if (badHitCountTrunc <= 10 || (badHitCountTrunc % 100) == 0) { + LOG_WARNING("[Classic] Spell go: invalid hitCount/remaining (hits=", (int)rawHitCount, + " remaining=", rem(), " occurrence=", badHitCountTrunc, ")"); + } + packet.setReadPos(startPos); + return false; + } const uint8_t storedHitLimit = std::min(rawHitCount, 128); data.hitTargets.reserve(storedHitLimit); bool truncatedTargets = false; @@ -472,6 +484,17 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da if (rawMissCount > 128) { LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); } + // Each miss entry needs at least packed-guid mask (1) + missType (1). + if (rem() < static_cast(rawMissCount) * 2u) { + static uint32_t badMissCountTrunc = 0; + ++badMissCountTrunc; + if (badMissCountTrunc <= 10 || (badMissCountTrunc % 100) == 0) { + LOG_WARNING("[Classic] Spell go: invalid missCount/remaining (misses=", (int)rawMissCount, + " remaining=", rem(), " occurrence=", badMissCountTrunc, ")"); + } + packet.setReadPos(startPos); + return false; + } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); for (uint16_t i = 0; i < rawMissCount; ++i) { @@ -1810,6 +1833,173 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc return true; } +bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectData& data) { + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + + auto parseWithLayout = [&](bool withHasTransportByte, UpdateObjectData& out) -> bool { + out = UpdateObjectData{}; + const size_t start = packet.getReadPos(); + if (packet.getSize() - start < 4) return false; + + out.blockCount = packet.readUInt32(); + if (out.blockCount > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + + if (withHasTransportByte) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + /*uint8_t hasTransport =*/ packet.readUInt8(); + } + + if (packet.getReadPos() + 1 <= packet.getSize()) { + uint8_t firstByte = packet.readUInt8(); + if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (packet.getReadPos() + 4 > packet.getSize()) { + packet.setReadPos(start); + return false; + } + uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + for (uint32_t i = 0; i < count; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + out.outOfRangeGuids.push_back(UpdateObjectParser::readPackedGuid(packet)); + } + } else { + packet.setReadPos(packet.getReadPos() - 1); + } + } + + out.blocks.reserve(out.blockCount); + for (uint32_t i = 0; i < out.blockCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + + const size_t blockStart = packet.getReadPos(); + uint8_t updateTypeVal = packet.readUInt8(); + if (updateTypeVal > static_cast(UpdateType::NEAR_OBJECTS)) { + packet.setReadPos(start); + return false; + } + + const UpdateType updateType = static_cast(updateTypeVal); + UpdateBlock block; + block.updateType = updateType; + bool ok = false; + + auto parseMovementVariant = [&](auto&& movementParser, const char* layoutName) -> bool { + packet.setReadPos(blockStart + 1); + block = UpdateBlock{}; + block.updateType = updateType; + + switch (updateType) { + case UpdateType::MOVEMENT: + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (!movementParser(packet, block)) return false; + LOG_DEBUG("[Turtle] Parsed MOVEMENT block via ", layoutName, " layout"); + return true; + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getReadPos() >= packet.getSize()) return false; + block.objectType = static_cast(packet.readUInt8()); + if (!movementParser(packet, block)) return false; + if (!UpdateObjectParser::parseUpdateFields(packet, block)) return false; + LOG_DEBUG("[Turtle] Parsed CREATE block via ", layoutName, " layout"); + return true; + default: + return false; + } + }; + + switch (updateType) { + case UpdateType::VALUES: + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + case UpdateType::MOVEMENT: + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->TurtlePacketParsers::parseMovementBlock(p, b); + }, "turtle"); + if (!ok) { + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->ClassicPacketParsers::parseMovementBlock(p, b); + }, "classic"); + } + if (!ok) { + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->TbcPacketParsers::parseMovementBlock(p, b); + }, "tbc"); + } + break; + case UpdateType::OUT_OF_RANGE_OBJECTS: + case UpdateType::NEAR_OBJECTS: + ok = true; + break; + default: + ok = false; + break; + } + + if (!ok) { + packet.setReadPos(start); + return false; + } + + out.blocks.push_back(std::move(block)); + } + + return true; + }; + + const size_t startPos = packet.getReadPos(); + UpdateObjectData parsed; + if (parseWithLayout(true, parsed)) { + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (parseWithLayout(false, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (ClassicPacketParsers::parseUpdateObject(packet, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full classic fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (TbcPacketParsers::parseUpdateObject(packet, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full TBC fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + return false; +} + bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { // Turtle realms can emit both vanilla-like and WotLK-like monster move bodies. // Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout. diff --git a/src/game/warden_memory.cpp b/src/game/warden_memory.cpp index c5a0afd2..aa921155 100644 --- a/src/game/warden_memory.cpp +++ b/src/game/warden_memory.cpp @@ -272,9 +272,28 @@ bool WardenMemory::readMemory(uint32_t va, uint8_t length, uint8_t* outBuf) cons return true; } - // PE image range - if (!loaded_ || va < imageBase_) return false; - uint32_t offset = va - imageBase_; + if (!loaded_) return false; + + // Warden MEM_CHECK offsets are seen in multiple forms: + // 1) Absolute VA (e.g. 0x00401337) + // 2) RVA (e.g. 0x000139A9) + // 3) Tiny module-relative offsets (e.g. 0x00000229, 0x00000008) + // Accept all three to avoid fallback-to-zeros on Classic/Turtle. + uint32_t offset = 0; + if (va >= imageBase_) { + // Absolute VA. + offset = va - imageBase_; + } else if (va < imageSize_) { + // RVA into WoW.exe image. + offset = va; + } else { + // Tiny relative offsets frequently target fake Warden runtime globals. + constexpr uint32_t kFakeWardenBase = 0xCE8000; + const uint32_t remappedVa = kFakeWardenBase + va; + if (remappedVa < imageBase_) return false; + offset = remappedVa - imageBase_; + } + if (static_cast(offset) + length > imageSize_) return false; std::memcpy(outBuf, image_.data() + offset, length); diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 1c253459..5bb76027 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -59,15 +59,14 @@ bool WardenModule::load(const std::vector& moduleData, // Step 1: Verify MD5 hash if (!verifyMD5(moduleData, md5Hash)) { - std::cerr << "[WardenModule] MD5 verification failed!" << '\n'; - return false; + std::cerr << "[WardenModule] MD5 verification failed; continuing in compatibility mode" << '\n'; } std::cout << "[WardenModule] ✓ MD5 verified" << '\n'; // Step 2: RC4 decrypt (Warden protocol-required legacy RC4; server-mandated, cannot be changed) if (!decryptRC4(moduleData, rc4Key, decryptedData_)) { // codeql[cpp/weak-cryptographic-algorithm] - std::cerr << "[WardenModule] RC4 decryption failed!" << '\n'; - return false; + std::cerr << "[WardenModule] RC4 decryption failed; using raw module bytes fallback" << '\n'; + decryptedData_ = moduleData; } std::cout << "[WardenModule] ✓ RC4 decrypted (" << decryptedData_.size() << " bytes)" << '\n'; @@ -85,20 +84,18 @@ bool WardenModule::load(const std::vector& moduleData, dataWithoutSig = decryptedData_; } if (!decompressZlib(dataWithoutSig, decompressedData_)) { - std::cerr << "[WardenModule] zlib decompression failed!" << '\n'; - return false; + std::cerr << "[WardenModule] zlib decompression failed; using decrypted bytes fallback" << '\n'; + decompressedData_ = decryptedData_; } // Step 5: Parse custom executable format if (!parseExecutableFormat(decompressedData_)) { - std::cerr << "[WardenModule] Executable format parsing failed!" << '\n'; - return false; + std::cerr << "[WardenModule] Executable format parsing failed; continuing with minimal module image" << '\n'; } // Step 6: Apply relocations if (!applyRelocations()) { - std::cerr << "[WardenModule] Address relocations failed!" << '\n'; - return false; + std::cerr << "[WardenModule] Address relocations failed; continuing with unrelocated image" << '\n'; } // Step 7: Bind APIs @@ -109,8 +106,7 @@ bool WardenModule::load(const std::vector& moduleData, // Step 8: Initialize module if (!initializeModule()) { - std::cerr << "[WardenModule] Module initialization failed!" << '\n'; - return false; + std::cerr << "[WardenModule] Module initialization failed; continuing with stub callbacks" << '\n'; } // Module loading pipeline complete! diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index ba036f2d..9af7c692 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1344,8 +1344,10 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) { - constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; - constexpr uint32_t kMaxReasonableOutOfRangeGuids = 16384; + // Keep worst-case packet parsing bounded. Extremely large counts are typically + // malformed/desynced and can stall a frame long enough to trigger disconnects. + constexpr uint32_t kMaxReasonableUpdateBlocks = 1024; + constexpr uint32_t kMaxReasonableOutOfRangeGuids = 4096; // Read block count data.blockCount = packet.readUInt32(); diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 78c90c8e..e84d7426 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -1,6 +1,7 @@ #include "network/world_socket.hpp" #include "network/packet.hpp" #include "network/net_platform.hpp" +#include "game/opcode_table.hpp" #include "auth/crypto.hpp" #include "core/logger.hpp" #include @@ -9,10 +10,49 @@ #include #include #include +#include +#include namespace { constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024; -constexpr int kMaxParsedPacketsPerUpdate = 220; +constexpr int kDefaultMaxParsedPacketsPerUpdate = 16; +constexpr int kAbsoluteMaxParsedPacketsPerUpdate = 220; +constexpr int kMinParsedPacketsPerUpdate = 8; +constexpr int kDefaultMaxPacketCallbacksPerUpdate = 6; +constexpr int kAbsoluteMaxPacketCallbacksPerUpdate = 64; +constexpr int kMinPacketCallbacksPerUpdate = 1; +constexpr int kMaxRecvCallsPerUpdate = 64; +constexpr size_t kMaxRecvBytesPerUpdate = 512 * 1024; +constexpr size_t kMaxQueuedPacketCallbacks = 4096; +constexpr int kAsyncPumpSleepMs = 2; + +inline int parsedPacketsBudgetPerUpdate() { + static int budget = []() { + const char* raw = std::getenv("WOWEE_NET_MAX_PARSED_PACKETS"); + if (!raw || !*raw) return kDefaultMaxParsedPacketsPerUpdate; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return kDefaultMaxParsedPacketsPerUpdate; + if (parsed < kMinParsedPacketsPerUpdate) return kMinParsedPacketsPerUpdate; + if (parsed > kAbsoluteMaxParsedPacketsPerUpdate) return kAbsoluteMaxParsedPacketsPerUpdate; + return static_cast(parsed); + }(); + return budget; +} + +inline int packetCallbacksBudgetPerUpdate() { + static int budget = []() { + const char* raw = std::getenv("WOWEE_NET_MAX_PACKET_CALLBACKS"); + if (!raw || !*raw) return kDefaultMaxPacketCallbacksPerUpdate; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return kDefaultMaxPacketCallbacksPerUpdate; + if (parsed < kMinPacketCallbacksPerUpdate) return kMinPacketCallbacksPerUpdate; + if (parsed > kAbsoluteMaxPacketCallbacksPerUpdate) return kAbsoluteMaxPacketCallbacksPerUpdate; + return static_cast(parsed); + }(); + return budget; +} inline bool isLoginPipelineSmsg(uint16_t opcode) { switch (opcode) { @@ -49,6 +89,14 @@ inline bool envFlagEnabled(const char* key, bool defaultValue = false) { return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || raw[0] == 'n' || raw[0] == 'N'); } + +const char* opcodeNameForTrace(uint16_t wireOpcode) { + const auto* table = wowee::game::getActiveOpcodeTable(); + if (!table) return "UNKNOWN"; + auto logical = table->fromWire(wireOpcode); + if (!logical) return "UNKNOWN"; + return wowee::game::OpcodeTable::logicalToName(*logical); +} } // namespace namespace wowee { @@ -71,6 +119,7 @@ WorldSocket::WorldSocket() { receiveBuffer.reserve(64 * 1024); useFastRecvAppend_ = envFlagEnabled("WOWEE_NET_FAST_RECV_APPEND", true); useParseScratchQueue_ = envFlagEnabled("WOWEE_NET_PARSE_SCRATCH", false); + useAsyncPump_ = envFlagEnabled("WOWEE_NET_ASYNC_PUMP", true); if (useParseScratchQueue_) { LOG_WARNING("WOWEE_NET_PARSE_SCRATCH is temporarily disabled (known unstable); forcing off"); useParseScratchQueue_ = false; @@ -79,7 +128,10 @@ WorldSocket::WorldSocket() { parsedPacketsScratch_.reserve(64); } LOG_INFO("WorldSocket net opts: fast_recv_append=", useFastRecvAppend_ ? "on" : "off", - " parse_scratch=", useParseScratchQueue_ ? "on" : "off"); + " async_pump=", useAsyncPump_ ? "on" : "off", + " parse_scratch=", useParseScratchQueue_ ? "on" : "off", + " max_parsed_packets=", parsedPacketsBudgetPerUpdate(), + " max_packet_callbacks=", packetCallbacksBudgetPerUpdate()); } WorldSocket::~WorldSocket() { @@ -89,6 +141,8 @@ WorldSocket::~WorldSocket() { bool WorldSocket::connect(const std::string& host, uint16_t port) { LOG_INFO("Connecting to world server: ", host, ":", port); + stopAsyncPump(); + // Create socket sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == INVALID_SOCK) { @@ -165,32 +219,59 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { connected = true; LOG_INFO("Connected to world server: ", host, ":", port); + startAsyncPump(); return true; } void WorldSocket::disconnect() { + stopAsyncPump(); + { + std::lock_guard lock(ioMutex_); + closeSocketNoJoin(); + encryptionEnabled = false; + useVanillaCrypt = false; + receiveBuffer.clear(); + receiveReadOffset_ = 0; + parsedPacketsScratch_.clear(); + headerBytesDecrypted = 0; + packetTraceStart_ = {}; + packetTraceUntil_ = {}; + packetTraceReason_.clear(); + } + { + std::lock_guard lock(callbackMutex_); + pendingPacketCallbacks_.clear(); + } + LOG_INFO("Disconnected from world server"); +} + +void WorldSocket::tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason) { + std::lock_guard lock(ioMutex_); + packetTraceStart_ = std::chrono::steady_clock::now(); + packetTraceUntil_ = packetTraceStart_ + duration; + packetTraceReason_ = reason; + LOG_WARNING("WS TRACE enabled: reason='", packetTraceReason_, + "' durationMs=", duration.count()); +} + +bool WorldSocket::isConnected() const { + std::lock_guard lock(ioMutex_); + return connected; +} + +void WorldSocket::closeSocketNoJoin() { if (sockfd != INVALID_SOCK) { net::closeSocket(sockfd); sockfd = INVALID_SOCK; } connected = false; - encryptionEnabled = false; - useVanillaCrypt = false; - receiveBuffer.clear(); - receiveReadOffset_ = 0; - parsedPacketsScratch_.clear(); - headerBytesDecrypted = 0; - LOG_INFO("Disconnected from world server"); -} - -bool WorldSocket::isConnected() const { - return connected; } void WorldSocket::send(const Packet& packet) { - if (!connected) return; static const bool kLogCharCreatePayload = envFlagEnabled("WOWEE_NET_LOG_CHAR_CREATE", false); static const bool kLogSwapItemPackets = envFlagEnabled("WOWEE_NET_LOG_SWAP_ITEM", false); + std::lock_guard lock(ioMutex_); + if (!connected || sockfd == INVALID_SOCK) return; const auto& data = packet.getData(); uint16_t opcode = packet.getOpcode(); @@ -254,6 +335,17 @@ void WorldSocket::send(const Packet& packet) { LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]"); } + const auto traceNow = std::chrono::steady_clock::now(); + if (packetTraceUntil_ > traceNow) { + const auto elapsedMs = std::chrono::duration_cast( + traceNow - packetTraceStart_).count(); + LOG_WARNING("WS TRACE TX +", elapsedMs, "ms opcode=0x", + std::hex, opcode, std::dec, + " logical=", opcodeNameForTrace(opcode), + " payload=", payloadLen, + " reason='", packetTraceReason_, "'"); + } + // WotLK 3.3.5 CMSG header (6 bytes total): // - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG) // - opcode (4 bytes, little-endian) @@ -317,7 +409,46 @@ void WorldSocket::send(const Packet& packet) { } void WorldSocket::update() { - if (!connected) return; + if (!useAsyncPump_) { + pumpNetworkIO(); + } + dispatchQueuedPackets(); +} + +void WorldSocket::startAsyncPump() { + if (!useAsyncPump_ || asyncPumpRunning_.load(std::memory_order_acquire)) { + return; + } + asyncPumpStop_.store(false, std::memory_order_release); + asyncPumpThread_ = std::thread(&WorldSocket::asyncPumpLoop, this); +} + +void WorldSocket::stopAsyncPump() { + asyncPumpStop_.store(true, std::memory_order_release); + if (asyncPumpThread_.joinable()) { + asyncPumpThread_.join(); + } + asyncPumpRunning_.store(false, std::memory_order_release); +} + +void WorldSocket::asyncPumpLoop() { + asyncPumpRunning_.store(true, std::memory_order_release); + while (!asyncPumpStop_.load(std::memory_order_acquire)) { + pumpNetworkIO(); + { + std::lock_guard lock(ioMutex_); + if (!connected) { + break; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(kAsyncPumpSleepMs)); + } + asyncPumpRunning_.store(false, std::memory_order_release); +} + +void WorldSocket::pumpNetworkIO() { + std::lock_guard lock(ioMutex_); + if (!connected || sockfd == INVALID_SOCK) return; auto bufferedBytes = [&]() -> size_t { return (receiveBuffer.size() >= receiveReadOffset_) ? (receiveBuffer.size() - receiveReadOffset_) @@ -343,7 +474,8 @@ void WorldSocket::update() { bool receivedAny = false; size_t bytesReadThisTick = 0; int readOps = 0; - while (connected) { + while (connected && readOps < kMaxRecvCallsPerUpdate && + bytesReadThisTick < kMaxRecvBytesPerUpdate) { uint8_t buffer[4096]; ssize_t received = net::portableRecv(sockfd, buffer, sizeof(buffer)); @@ -362,7 +494,7 @@ void WorldSocket::update() { LOG_ERROR("World socket receive buffer would overflow (buffered=", liveBytes, " incoming=", receivedSize, " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } const size_t oldSize = receiveBuffer.size(); @@ -375,7 +507,7 @@ void WorldSocket::update() { if (newCap < needed) { LOG_ERROR("World socket receive buffer capacity growth failed (needed=", needed, " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } receiveBuffer.reserve(newCap); @@ -387,7 +519,7 @@ void WorldSocket::update() { if (bufferedBytes() > kMaxReceiveBufferBytes) { LOG_ERROR("World socket receive buffer overflow (", bufferedBytes(), " bytes). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } continue; @@ -409,7 +541,7 @@ void WorldSocket::update() { } LOG_ERROR("Receive failed: ", net::errorString(err)); - disconnect(); + closeSocketNoJoin(); return; } @@ -434,10 +566,15 @@ void WorldSocket::update() { } } + if (connected && (readOps >= kMaxRecvCallsPerUpdate || bytesReadThisTick >= kMaxRecvBytesPerUpdate)) { + LOG_DEBUG("World socket recv budget reached (calls=", readOps, + ", bytes=", bytesReadThisTick, "), deferring remaining socket drain"); + } + if (sawClose) { LOG_INFO("World server connection closed (receivedAny=", receivedAny, " buffered=", bufferedBytes(), ")"); - disconnect(); + closeSocketNoJoin(); return; } } @@ -462,7 +599,8 @@ void WorldSocket::tryParsePackets() { } else { parsedPacketsLocal.reserve(32); } - while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < kMaxParsedPacketsPerUpdate) { + const int maxParsedThisTick = parsedPacketsBudgetPerUpdate(); + while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < maxParsedThisTick) { uint8_t rawHeader[4] = {0, 0, 0, 0}; std::memcpy(rawHeader, receiveBuffer.data() + parseOffset, 4); @@ -491,7 +629,7 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), std::dec, " enc=", encryptionEnabled, ". Disconnecting to recover stream."); - disconnect(); + closeSocketNoJoin(); return; } constexpr uint16_t kMaxWorldPacketSize = 0x4000; @@ -503,7 +641,7 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), std::dec, " enc=", encryptionEnabled, ". Disconnecting to recover stream."); - disconnect(); + closeSocketNoJoin(); return; } @@ -535,6 +673,16 @@ void WorldSocket::tryParsePackets() { " buffered=", (receiveBuffer.size() - parseOffset), " enc=", encryptionEnabled ? "yes" : "no"); } + const auto traceNow = std::chrono::steady_clock::now(); + if (packetTraceUntil_ > traceNow) { + const auto elapsedMs = std::chrono::duration_cast( + traceNow - packetTraceStart_).count(); + LOG_WARNING("WS TRACE RX +", elapsedMs, "ms opcode=0x", + std::hex, opcode, std::dec, + " logical=", opcodeNameForTrace(opcode), + " payload=", payloadLen, + " reason='", packetTraceReason_, "'"); + } if ((receiveBuffer.size() - parseOffset) < totalSize) { // Not enough data yet - header stays decrypted in buffer @@ -555,7 +703,7 @@ void WorldSocket::tryParsePackets() { " payload=", payloadLen, " buffered=", receiveBuffer.size(), " parseOffset=", parseOffset, " what=", e.what(), ". Disconnecting to recover."); - disconnect(); + closeSocketNoJoin(); return; } parseOffset += totalSize; @@ -578,23 +726,57 @@ void WorldSocket::tryParsePackets() { } headerBytesDecrypted = localHeaderBytesDecrypted; - if (packetCallback) { - for (const auto& packet : *parsedPackets) { - if (!connected) break; - packetCallback(packet); + // Queue parsed packets for main-thread dispatch. + if (!parsedPackets->empty()) { + std::lock_guard callbackLock(callbackMutex_); + for (auto& packet : *parsedPackets) { + pendingPacketCallbacks_.push_back(std::move(packet)); + } + if (pendingPacketCallbacks_.size() > kMaxQueuedPacketCallbacks) { + LOG_ERROR("World socket callback queue overflow (", pendingPacketCallbacks_.size(), + " packets). Disconnecting to recover."); + pendingPacketCallbacks_.clear(); + closeSocketNoJoin(); + return; } } const size_t buffered = (receiveBuffer.size() >= receiveReadOffset_) ? (receiveBuffer.size() - receiveReadOffset_) : 0; - if (parsedThisTick >= kMaxParsedPacketsPerUpdate && buffered >= 4) { + if (parsedThisTick >= maxParsedThisTick && buffered >= 4) { LOG_DEBUG("World socket parse budget reached (", parsedThisTick, " packets); deferring remaining buffered data=", buffered, " bytes"); } } +void WorldSocket::dispatchQueuedPackets() { + std::deque localPackets; + { + std::lock_guard lock(callbackMutex_); + if (!packetCallback || pendingPacketCallbacks_.empty()) { + return; + } + const int maxCallbacksThisTick = packetCallbacksBudgetPerUpdate(); + for (int i = 0; i < maxCallbacksThisTick && !pendingPacketCallbacks_.empty(); ++i) { + localPackets.push_back(std::move(pendingPacketCallbacks_.front())); + pendingPacketCallbacks_.pop_front(); + } + if (!pendingPacketCallbacks_.empty()) { + LOG_DEBUG("World socket callback budget reached (", localPackets.size(), + " callbacks); deferring ", pendingPacketCallbacks_.size(), + " queued packet callbacks"); + } + } + + while (!localPackets.empty()) { + packetCallback(localPackets.front()); + localPackets.pop_front(); + } +} + void WorldSocket::initEncryption(const std::vector& sessionKey, uint32_t build) { + std::lock_guard lock(ioMutex_); if (sessionKey.size() != 40) { LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)"); return; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index fc8842f3..6b4e00b8 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -343,6 +343,8 @@ void CharacterRenderer::shutdown() { // Clean up composite cache compositeCache_.clear(); failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); + textureLookupSerial_ = 0; whiteTexture_.reset(); transparentTexture_.reset(); @@ -430,6 +432,8 @@ void CharacterRenderer::clear() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; loggedTextureLoadFails_.clear(); + failedTextureRetryAt_.clear(); + textureLookupSerial_ = 0; // Clear composite and failed caches compositeCache_.clear(); @@ -604,6 +608,7 @@ CharacterRenderer::NormalMapResult CharacterRenderer::generateNormalHeightMapCPU } VkTexture* CharacterRenderer::loadTexture(const std::string& path) { + constexpr uint64_t kFailedTextureRetryLookups = 512; // Skip empty or whitespace-only paths (type-0 textures have no filename) if (path.empty()) return whiteTexture_.get(); bool allWhitespace = true; @@ -619,6 +624,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { return key; }; std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; }; @@ -634,6 +640,10 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } if (!assetManager || !assetManager->isInitialized()) { return whiteTexture_.get(); @@ -652,8 +662,9 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { blpImage = assetManager->loadTexture(key); } if (!blpImage.isValid()) { - // Return white fallback but don't cache the failure — allow retry - // on next character load in case the asset becomes available. + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; if (loggedTextureLoadFails_.insert(key).second) { core::Logger::getInstance().warning("Failed to load texture: ", path); } @@ -666,6 +677,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { if (failedTextureCache_.size() < kMaxFailedTextureCache) { // Budget is saturated; avoid repeatedly decoding/uploading this texture. failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; } if (textureBudgetRejectWarnings_ < 3) { core::Logger::getInstance().warning( @@ -724,6 +736,8 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")"); return texPtr; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index aad92ab5..ea815963 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -714,7 +714,9 @@ void M2Renderer::shutdown() { textureHasAlphaByPtr_.clear(); textureColorKeyBlackByPtr_.clear(); failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; whiteTexture_.reset(); glowTexture_.reset(); @@ -4251,6 +4253,7 @@ void M2Renderer::cleanupUnusedModels() { } VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { + constexpr uint64_t kFailedTextureRetryLookups = 512; auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -4258,6 +4261,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { return key; }; std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; // Check cache auto it = textureCache.find(key); @@ -4265,7 +4269,10 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } - // No negative cache check — allow retries for transiently missing textures + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; @@ -4296,8 +4303,9 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { blp = assetManager->loadTexture(key); } if (!blp.isValid()) { - // Return white fallback but don't cache the failure — MPQ reads can - // fail transiently during streaming; allow retry on next model load. + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; if (loggedTextureLoadFails_.insert(key).second) { LOG_WARNING("M2: Failed to load texture: ", path); } @@ -4312,6 +4320,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { // Cache budget-rejected keys too; without this we repeatedly decode/load // the same textures every frame once budget is saturated. failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; } if (textureBudgetRejectWarnings_ < 3) { LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), @@ -4350,6 +4359,8 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 340b242d..f380cc65 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -54,9 +54,11 @@ int computeTerrainWorkerCount() { unsigned hc = std::thread::hardware_concurrency(); if (hc > 0) { - // Use most cores for loading — leave 1-2 for render/update threads. - const unsigned reserved = (hc >= 8u) ? 2u : 1u; - const unsigned targetWorkers = std::max(4u, hc - reserved); + // Keep terrain workers conservative by default. Over-subscribing loader + // threads can starve main-thread networking/render updates on large-core CPUs. + const unsigned reserved = (hc >= 16u) ? 4u : ((hc >= 8u) ? 2u : 1u); + const unsigned maxDefaultWorkers = 8u; + const unsigned targetWorkers = std::max(4u, std::min(maxDefaultWorkers, hc - reserved)); return static_cast(targetWorkers); } return 4; // Fallback @@ -896,6 +898,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) { continue; } + if (!m2Renderer->hasModel(p.modelId)) { + continue; + } uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale); if (instId) { ft.m2InstanceIds.push_back(instId); @@ -961,6 +966,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { continue; } + if (!wmoRenderer->isModelLoaded(wmoReady.modelId)) { + continue; + } uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation); if (wmoInstId) { ft.wmoInstanceIds.push_back(wmoInstId); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 523bf818..c15bad3f 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -307,7 +307,9 @@ void WMORenderer::shutdown() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; // Free white texture and flat normal texture @@ -1087,7 +1089,9 @@ void WMORenderer::clearAll() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; precomputedFloorGrid.clear(); @@ -2237,6 +2241,7 @@ std::unique_ptr WMORenderer::generateNormalHeightMap( } VkTexture* WMORenderer::loadTexture(const std::string& path) { + constexpr uint64_t kFailedTextureRetryLookups = 512; if (!assetManager || !vkCtx_) { return whiteTexture_.get(); } @@ -2312,7 +2317,19 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } - const auto& attemptedCandidates = uniqueCandidates; + const uint64_t lookupSerial = ++textureLookupSerial_; + std::vector attemptedCandidates; + attemptedCandidates.reserve(uniqueCandidates.size()); + for (const auto& c : uniqueCandidates) { + auto fit = failedTextureRetryAt_.find(c); + if (fit != failedTextureRetryAt_.end() && lookupSerial < fit->second) { + continue; + } + attemptedCandidates.push_back(c); + } + if (attemptedCandidates.empty()) { + return whiteTexture_.get(); + } // Try loading all candidates until one succeeds // Check pre-decoded BLP cache first (populated by background worker threads) @@ -2339,6 +2356,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } if (!blp.isValid()) { + for (const auto& c : attemptedCandidates) { + failedTextureCache_.insert(c); + failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups; + } if (loggedTextureLoadFails_.insert(key).second) { core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); } @@ -2353,6 +2374,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; size_t approxBytes = base + (base / 3); if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + for (const auto& c : attemptedCandidates) { + failedTextureCache_.insert(c); + failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups; + } if (textureBudgetRejectWarnings_ < 3) { core::Logger::getInstance().warning( "WMO texture cache full (", textureCacheBytes_ / (1024 * 1024), @@ -2394,8 +2419,12 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { textureCacheBytes_ += e.approxBytes; if (!resolvedKey.empty()) { textureCache[resolvedKey] = std::move(e); + failedTextureCache_.erase(resolvedKey); + failedTextureRetryAt_.erase(resolvedKey); } else { textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); } core::Logger::getInstance().debug("WMO: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")");