diff --git a/include/core/application.hpp b/include/core/application.hpp index c49bbf30..adc677e3 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -162,6 +162,8 @@ private: uint32_t gryphonDisplayId_ = 0; uint32_t wyvernDisplayId_ = 0; bool lastTaxiFlight_ = false; + float taxiLandingClampTimer_ = 0.0f; + float worldEntryMovementGraceTimer_ = 0.0f; float taxiStreamCooldown_ = 0.0f; bool idleYawned_ = false; @@ -193,7 +195,7 @@ private: float x, y, z, orientation; }; std::vector pendingCreatureSpawns_; - static constexpr int MAX_SPAWNS_PER_FRAME = 24; + static constexpr int MAX_SPAWNS_PER_FRAME = 96; static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300; std::unordered_set pendingCreatureSpawnGuids_; std::unordered_map creatureSpawnRetryCounts_; diff --git a/include/core/memory_monitor.hpp b/include/core/memory_monitor.hpp index 5bad537e..7fa2fcce 100644 --- a/include/core/memory_monitor.hpp +++ b/include/core/memory_monitor.hpp @@ -29,7 +29,7 @@ public: size_t getAvailableRAM() const; /** - * Get recommended cache budget (30% of available RAM) + * Get recommended cache budget (80% of available RAM, capped at 90% of total RAM) */ size_t getRecommendedCacheBudget() const; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3c186ca9..aae38bde 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -623,6 +623,8 @@ public: void activateTaxi(uint32_t destNodeId); bool isOnTaxiFlight() const { return onTaxiFlight_; } bool isTaxiMountActive() const { return taxiMountActive_; } + bool isTaxiActivationPending() const { return taxiActivatePending_; } + void forceClearTaxiAndMovementState(); const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; } uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; } @@ -927,6 +929,8 @@ private: uint32_t pingSequence = 0; // Ping sequence number (increments) float timeSinceLastPing = 0.0f; // Time since last ping sent (seconds) float pingInterval = 30.0f; // Ping interval (30 seconds) + float timeSinceLastMoveHeartbeat_ = 0.0f; // Periodic movement heartbeat to keep server position synced + float moveHeartbeatInterval_ = 0.5f; uint32_t lastLatency = 0; // Last measured latency (milliseconds) // Player GUID and map @@ -1086,6 +1090,7 @@ private: float taxiActivateTimer_ = 0.0f; bool taxiClientActive_ = false; float taxiLandingCooldown_ = 0.0f; // Prevent re-entering taxi right after landing + float taxiStartGrace_ = 0.0f; // Ignore transient landing/dismount checks right after takeoff size_t taxiClientIndex_ = 0; std::vector taxiClientPath_; float taxiClientSpeed_ = 32.0f; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index a2ed9021..296abb9d 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -433,6 +433,9 @@ struct MovementInfo { */ class MovementPacket { public: + static void writePackedGuid(network::Packet& packet, uint64_t guid); + static void writeMovementPayload(network::Packet& packet, const MovementInfo& info); + /** * Build a movement packet * diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 6d8d716c..79f44dd0 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -172,6 +172,15 @@ private: std::optional cachedCamWmoFloor; bool hasCachedCamFloor = false; + // Terrain-aware camera pivot lift cache (throttled for performance). + glm::vec3 lastPivotLiftQueryPos_ = glm::vec3(0.0f); + float lastPivotLiftDistance_ = 0.0f; + int pivotLiftQueryCounter_ = 0; + float cachedPivotLift_ = 0.0f; + static constexpr int PIVOT_LIFT_QUERY_INTERVAL = 3; + static constexpr float PIVOT_LIFT_POS_THRESHOLD = 0.5f; + static constexpr float PIVOT_LIFT_DIST_THRESHOLD = 0.5f; + // Cached floor height queries (update every 5 frames or 2 unit movement) glm::vec3 lastFloorQueryPos = glm::vec3(0.0f); std::optional cachedFloorHeight; diff --git a/restart-worldserver.sh b/restart-worldserver.sh new file mode 100644 index 00000000..6b24ff48 --- /dev/null +++ b/restart-worldserver.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker restart ac-worldserver diff --git a/src/core/application.cpp b/src/core/application.cpp index 5d4331d4..7e075560 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -420,6 +420,52 @@ void Application::update(float deltaTime) { auto cq2 = std::chrono::high_resolution_clock::now(); creatureQTime += std::chrono::duration(cq2 - cq1).count(); + // Self-heal missing creature visuals: if a nearby UNIT exists in + // entity state but has no render instance, queue a spawn retry. + if (gameHandler) { + static float creatureResyncTimer = 0.0f; + creatureResyncTimer += deltaTime; + if (creatureResyncTimer >= 1.0f) { + creatureResyncTimer = 0.0f; + + glm::vec3 playerPos(0.0f); + bool havePlayerPos = false; + uint64_t playerGuid = gameHandler->getPlayerGuid(); + if (auto playerEntity = gameHandler->getEntityManager().getEntity(playerGuid)) { + playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); + havePlayerPos = true; + } + + const float kResyncRadiusSq = 260.0f * 260.0f; + for (const auto& pair : gameHandler->getEntityManager().getEntities()) { + uint64_t guid = pair.first; + const auto& entity = pair.second; + if (!entity || guid == playerGuid) continue; + if (entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::dynamic_pointer_cast(entity); + if (!unit || unit->getDisplayId() == 0) continue; + if (creatureInstances_.count(guid) || pendingCreatureSpawnGuids_.count(guid)) continue; + + if (havePlayerPos) { + glm::vec3 pos(unit->getX(), unit->getY(), unit->getZ()); + glm::vec3 delta = pos - playerPos; + float distSq = glm::dot(delta, delta); + if (distSq > kResyncRadiusSq) continue; + } + + PendingCreatureSpawn retrySpawn{}; + retrySpawn.guid = guid; + retrySpawn.displayId = unit->getDisplayId(); + retrySpawn.x = unit->getX(); + retrySpawn.y = unit->getY(); + retrySpawn.z = unit->getZ(); + retrySpawn.orientation = unit->getOrientation(); + pendingCreatureSpawns_.push_back(retrySpawn); + pendingCreatureSpawnGuids_.insert(guid); + } + } + } + auto goq1 = std::chrono::high_resolution_clock::now(); processGameObjectSpawnQueue(); auto goq2 = std::chrono::high_resolution_clock::now(); @@ -447,16 +493,67 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); } - bool onTaxi = gameHandler && (gameHandler->isOnTaxiFlight() || gameHandler->isTaxiMountActive()); + bool onTaxi = gameHandler && + (gameHandler->isOnTaxiFlight() || + gameHandler->isTaxiMountActive() || + gameHandler->isTaxiActivationPending()); + if (worldEntryMovementGraceTimer_ > 0.0f) { + worldEntryMovementGraceTimer_ -= deltaTime; + } if (renderer && renderer->getCameraController()) { renderer->getCameraController()->setExternalFollow(onTaxi); renderer->getCameraController()->setExternalMoving(onTaxi); if (onTaxi) { // Drop any stale local movement toggles while server drives taxi motion. renderer->getCameraController()->clearMovementInputs(); + taxiLandingClampTimer_ = 0.0f; } if (lastTaxiFlight_ && !onTaxi) { renderer->getCameraController()->clearMovementInputs(); + // Keep clamping for a short grace window after taxi ends to avoid + // falling through WMOs while floor/collision state settles. + taxiLandingClampTimer_ = 1.5f; + } + if (!onTaxi && + worldEntryMovementGraceTimer_ <= 0.0f && + !gameHandler->isMounted() && + taxiLandingClampTimer_ > 0.0f) { + taxiLandingClampTimer_ -= deltaTime; + if (renderer && gameHandler) { + glm::vec3 p = renderer->getCharacterPosition(); + std::optional terrainFloor; + std::optional wmoFloor; + std::optional m2Floor; + if (renderer->getTerrainManager()) { + terrainFloor = renderer->getTerrainManager()->getHeightAt(p.x, p.y); + } + if (renderer->getWMORenderer()) { + // Probe from above so we can recover when current Z is already below floor. + wmoFloor = renderer->getWMORenderer()->getFloorHeight(p.x, p.y, p.z + 40.0f); + } + if (renderer->getM2Renderer()) { + // Include M2 floors (bridges/platforms) in landing recovery. + m2Floor = renderer->getM2Renderer()->getFloorHeight(p.x, p.y, p.z + 40.0f); + } + + std::optional targetFloor; + if (terrainFloor) targetFloor = terrainFloor; + if (wmoFloor && (!targetFloor || *wmoFloor > *targetFloor)) targetFloor = wmoFloor; + if (m2Floor && (!targetFloor || *m2Floor > *targetFloor)) targetFloor = m2Floor; + + if (targetFloor) { + float targetZ = *targetFloor + 0.10f; + // Only lift upward to prevent sinking through floors/bridges. + // Never force the player downward from a valid elevated surface. + if (p.z < targetZ - 0.05f) { + p.z = targetZ; + renderer->getCharacterPosition() = p; + glm::vec3 canonical = core::coords::renderToCanonical(p); + gameHandler->setPosition(canonical.x, canonical.y, canonical.z); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); + } + } + } } bool idleOrbit = renderer->getCameraController()->isIdleOrbit(); if (idleOrbit && !idleYawned_ && renderer) { @@ -494,10 +591,27 @@ void Application::update(float deltaTime) { if (onTaxi) { auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid()); + glm::vec3 canonical(0.0f); + bool haveCanonical = false; if (playerEntity) { - glm::vec3 canonical(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); + canonical = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); + haveCanonical = true; + } else { + // Fallback for brief entity gaps during taxi start/updates: + // movementInfo is still updated by client taxi simulation. + const auto& move = gameHandler->getMovementInfo(); + canonical = glm::vec3(move.x, move.y, move.z); + haveCanonical = true; + } + if (haveCanonical) { glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; + if (renderer->getCameraController()) { + glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); + if (followTarget) { + *followTarget = renderPos; + } + } } } else if (onTransport) { // Transport mode: compose world position from transport transform + local offset @@ -523,16 +637,16 @@ void Application::update(float deltaTime) { } } - // Send movement heartbeat every 500ms (keeps server position in sync) - // Skip periodic taxi heartbeats; taxi start sends explicit heartbeats already. - if (gameHandler && renderer && !onTaxi) { + // Send periodic movement heartbeats. + // Keep them active during taxi as well to avoid occasional server-side + // flight stalls waiting for movement sync updates. + if (gameHandler && renderer) { movementHeartbeatTimer += deltaTime; - if (movementHeartbeatTimer >= 0.5f) { + float hbInterval = onTaxi ? 0.25f : 0.5f; + if (movementHeartbeatTimer >= hbInterval) { movementHeartbeatTimer = 0.0f; gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); } - } else { - movementHeartbeatTimer = 0.0f; } auto sync2 = std::chrono::high_resolution_clock::now(); @@ -540,7 +654,7 @@ void Application::update(float deltaTime) { // Log profiling every 60 frames if (++appProfileCounter >= 60) { - LOG_INFO("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f, + LOG_DEBUG("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f, "ms world=", worldTime / 60.0f, "ms spawn=", spawnTime / 60.0f, "ms creatureQ=", creatureQTime / 60.0f, "ms goQ=", goQTime / 60.0f, "ms mount=", mountTime / 60.0f, "ms npcMgr=", npcMgrTime / 60.0f, @@ -578,7 +692,7 @@ void Application::update(float deltaTime) { uiTime += std::chrono::duration(u2 - u1).count(); if (state == AppState::IN_GAME && ++rendererProfileCounter >= 60) { - LOG_INFO("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f, + LOG_DEBUG("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f, "ms ui=", uiTime / 60.0f, "ms"); rendererProfileCounter = 0; rendererTime = uiTime = 0.0f; @@ -690,23 +804,129 @@ void Application::setupUICallbacks() { // World entry callback (online mode) - load terrain when entering world gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z) { LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; loadOnlineWorldTerrain(mapId, x, y, z); }); - // /unstuck — snap upward 10m to escape minor WMO cracks - gameHandler->setUnstuckCallback([this]() { + auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { + std::optional terrainFloor; + std::optional wmoFloor; + std::optional m2Floor; + + if (renderer && renderer->getTerrainManager()) { + terrainFloor = renderer->getTerrainManager()->getHeightAt(x, y); + } + if (renderer && renderer->getWMORenderer()) { + wmoFloor = renderer->getWMORenderer()->getFloorHeight(x, y, probeZ); + } + if (renderer && renderer->getM2Renderer()) { + m2Floor = renderer->getM2Renderer()->getFloorHeight(x, y, probeZ); + } + + std::optional best; + if (terrainFloor) best = terrainFloor; + if (wmoFloor && (!best || *wmoFloor > *best)) best = wmoFloor; + if (m2Floor && (!best || *m2Floor > *best)) best = m2Floor; + return best; + }; + + auto clearStuckMovement = [this]() { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->clearMovementInputs(); + } + if (gameHandler) { + gameHandler->forceClearTaxiAndMovementState(); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_STRAFE); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_TURN); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_SWIM); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); + } + }; + + auto syncTeleportedPositionToServer = [this](const glm::vec3& renderPos) { + if (!gameHandler) return; + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); + gameHandler->setPosition(canonical.x, canonical.y, canonical.z); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_STRAFE); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_TURN); + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); + }; + + auto forceServerTeleportCommand = [this](const glm::vec3& renderPos) { + if (!gameHandler) return; + // Server-authoritative reset first, then teleport. + gameHandler->sendChatMessage(game::ChatType::SAY, ".revive", ""); + gameHandler->sendChatMessage(game::ChatType::SAY, ".dismount", ""); + + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); + glm::vec3 serverPos = core::coords::canonicalToServer(canonical); + std::ostringstream cmd; + cmd.setf(std::ios::fixed); + cmd.precision(3); + cmd << ".go xyz " + << serverPos.x << " " + << serverPos.y << " " + << serverPos.z << " " + << gameHandler->getCurrentMapId() << " " + << gameHandler->getMovementInfo().orientation; + gameHandler->sendChatMessage(game::ChatType::SAY, cmd.str(), ""); + }; + + // /unstuck — prefer safe position or nearest floor, avoid blind +Z snaps. + gameHandler->setUnstuckCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; + worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); auto* cc = renderer->getCameraController(); auto* ft = cc->getFollowTargetMutable(); if (!ft) return; + glm::vec3 pos = *ft; - pos.z += 10.0f; + if (cc->hasLastSafePosition()) { + pos = cc->getLastSafePosition(); + pos.z += 1.5f; + cc->teleportTo(pos); + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: teleported to last safe position"); + return; + } + + if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) { + pos.z = *floor + 0.2f; + } else { + pos.z += 20.0f; + } + + // Nudge forward slightly to break edge-cases where unstuck lands exactly + // on problematic collision seams. + if (gameHandler) { + float renderYaw = gameHandler->getMovementInfo().orientation + glm::radians(90.0f); + pos.x += std::cos(renderYaw) * 2.0f; + pos.y += std::sin(renderYaw) * 2.0f; + } + cc->teleportTo(pos); + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: recovered to sampled floor"); }); - // /unstuckgy — snap upward 50m to clear all WMO geometry, gravity re-settles onto terrain - gameHandler->setUnstuckGyCallback([this]() { + // /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback. + gameHandler->setUnstuckGyCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; + worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); auto* cc = renderer->getCameraController(); auto* ft = cc->getFollowTargetMutable(); if (!ft) return; @@ -716,15 +936,45 @@ void Application::setupUICallbacks() { glm::vec3 safePos = cc->getLastSafePosition(); safePos.z += 5.0f; cc->teleportTo(safePos); + syncTeleportedPositionToServer(safePos); + forceServerTeleportCommand(safePos); + clearStuckMovement(); LOG_INFO("Unstuck: teleported to last safe position"); return; } - // No safe position — snap 50m upward to clear all WMO geometry + uint32_t bindMap = 0; + glm::vec3 bindPos(0.0f); + if (gameHandler && gameHandler->getHomeBind(bindMap, bindPos) && + bindMap == gameHandler->getCurrentMapId()) { + bindPos.z += 2.0f; + cc->teleportTo(bindPos); + syncTeleportedPositionToServer(bindPos); + forceServerTeleportCommand(bindPos); + clearStuckMovement(); + LOG_INFO("Unstuck: teleported to home bind position"); + return; + } + + // No safe/bind position — try current XY with a high floor probe. glm::vec3 pos = *ft; - pos.z += 50.0f; + if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 120.0f)) { + pos.z = *floor + 0.5f; + cc->teleportTo(pos); + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: teleported to sampled floor"); + return; + } + + // Last fallback: high snap to clear deeply bad geometry. + pos.z += 60.0f; cc->teleportTo(pos); - LOG_INFO("Unstuck: snapped 50m upward"); + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: high fallback snap"); }); // Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry @@ -2295,19 +2545,23 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Skip if already spawned if (creatureInstances_.count(guid)) return; - if (nonRenderableCreatureDisplayIds_.count(displayId)) { - creaturePermanentFailureGuids_.insert(guid); - return; - } - // Get model path from displayId std::string m2Path = getModelPathForDisplayId(displayId); if (m2Path.empty()) { LOG_WARNING("No model path for displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")"); - nonRenderableCreatureDisplayIds_.insert(displayId); - creaturePermanentFailureGuids_.insert(guid); return; } + { + // Intentionally invisible helper creatures should not consume retry budget. + std::string lowerPath = m2Path; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerPath.find("invisiblestalker") != std::string::npos || + lowerPath.find("invisible_stalker") != std::string::npos) { + creaturePermanentFailureGuids_.insert(guid); + return; + } + } auto* charRenderer = renderer->getCharacterRenderer(); @@ -2325,15 +2579,12 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x auto m2Data = assetManager->readFile(m2Path); if (m2Data.empty()) { LOG_WARNING("Failed to read creature M2: ", m2Path); - creaturePermanentFailureGuids_.insert(guid); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse creature M2: ", m2Path); - nonRenderableCreatureDisplayIds_.insert(displayId); - creaturePermanentFailureGuids_.insert(guid); return; } @@ -2360,7 +2611,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (!charRenderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load creature model: ", m2Path); - creaturePermanentFailureGuids_.insert(guid); return; } @@ -2502,28 +2752,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Convert canonical → render coordinates glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - // Smart filtering for bad spawn data: - // - If over ocean AND at continental height (Z > 50): bad data, skip - // - If over ocean AND near sea level (Z <= 50): water creature, allow - // - If over land: preserve server Z for elevated platforms/roofs and only - // correct clearly underground spawns. - if (renderer->getTerrainManager()) { - auto terrainH = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); - if (!terrainH) { - // No terrain at this X,Y position (ocean/void) - if (z > 50.0f) { - // High altitude over ocean = bad spawn data (e.g., bears at Z=94 over water) - return; - } - // Low altitude = probably legitimate water creature, allow spawn at original Z - } else { - // Keep authentic server Z for NPCs on raised geometry (e.g. flight masters). - // Only lift up if spawn is clearly below terrain. - if (renderPos.z < (*terrainH - 2.0f)) { - renderPos.z = *terrainH + 0.1f; - } - } - } + // Keep authoritative server Z for online creature spawns. + // Terrain-based lifting can incorrectly move elevated NPCs (e.g. flight masters on + // Stormwind ramparts) to bad heights relative to WMO geometry. // Convert canonical WoW orientation (0=north) -> render yaw (0=west) float renderYaw = orientation + glm::radians(90.0f); @@ -2537,8 +2768,16 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x return; } + // NOTE: Custom humanoid NPC geoset/equipment overrides are currently too + // aggressive and can make NPCs invisible (targetable but not rendered). + // Keep default model geosets for online creatures until this path is made + // data-accurate per display model. + static constexpr bool kEnableNpcHumanoidOverrides = false; + // Set geosets for humanoid NPCs based on CreatureDisplayInfoExtra - if (itDisplayData != displayDataMap_.end() && itDisplayData->second.extraDisplayId != 0) { + if (kEnableNpcHumanoidOverrides && + itDisplayData != displayDataMap_.end() && + itDisplayData->second.extraDisplayId != 0) { auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); if (itExtra != humanoidExtraMap_.end()) { const auto& extra = itExtra->second; @@ -2768,6 +3007,83 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } + // Optional NPC helmet attachments (kept disabled for stability: this path + // can increase spawn-time pressure and regress NPC visibility in crowded areas). + static constexpr bool kEnableNpcHelmetAttachments = false; + if (kEnableNpcHelmetAttachments && + itDisplayData != displayDataMap_.end() && + itDisplayData->second.extraDisplayId != 0) { + auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); + if (itExtra != humanoidExtraMap_.end()) { + const auto& extra = itExtra->second; + if (extra.equipDisplayId[0] != 0) { // Helm slot + auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + if (itemDisplayDbc) { + int32_t helmIdx = itemDisplayDbc->findRecordById(extra.equipDisplayId[0]); + if (helmIdx >= 0) { + std::string helmModelName = itemDisplayDbc->getString(static_cast(helmIdx), 1); + if (!helmModelName.empty()) { + size_t dotPos = helmModelName.rfind('.'); + if (dotPos != std::string::npos) { + helmModelName = helmModelName.substr(0, dotPos); + } + + static const std::unordered_map racePrefix = { + {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, + {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} + }; + std::string genderSuffix = (extra.sexId == 0) ? "M" : "F"; + std::string raceSuffix; + auto itRace = racePrefix.find(extra.raceId); + if (itRace != racePrefix.end()) { + raceSuffix = "_" + itRace->second + genderSuffix; + } + + std::string helmPath; + std::vector helmData; + if (!raceSuffix.empty()) { + helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; + helmData = assetManager->readFile(helmPath); + } + if (helmData.empty()) { + helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; + helmData = assetManager->readFile(helmPath); + } + + if (!helmData.empty()) { + auto helmModel = pipeline::M2Loader::load(helmData); + std::string skinPath = helmPath.substr(0, helmPath.size() - 3) + "00.skin"; + auto skinData = assetManager->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, helmModel); + } + + if (helmModel.isValid()) { + uint32_t helmModelId = nextCreatureModelId_++; + std::string helmTexName = itemDisplayDbc->getString(static_cast(helmIdx), 3); + std::string helmTexPath; + if (!helmTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; + if (assetManager->fileExists(suffixedTex)) { + helmTexPath = suffixedTex; + } + } + if (helmTexPath.empty()) { + helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; + } + } + // Attachment point 11 = Head + charRenderer->attachWeapon(instanceId, 11, helmModel, helmModelId, helmTexPath); + } + } + } + } + } + } + } + } + // Play idle animation and fade in charRenderer->playAnimation(instanceId, 0, true); charRenderer->startFadeIn(instanceId, 0.5f); @@ -2775,8 +3091,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Track instance creatureInstances_[guid] = instanceId; creatureModelIds_[guid] = modelId; - creaturePermanentFailureGuids_.erase(guid); - LOG_INFO("Spawned creature: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } @@ -3440,7 +3754,7 @@ void Application::updateQuestMarkers() { static int logCounter = 0; if (++logCounter % 300 == 0) { // Log every ~10 seconds at 30fps - LOG_INFO("Quest markers: ", questStatuses.size(), " NPCs with quest status"); + LOG_DEBUG("Quest markers: ", questStatuses.size(), " NPCs with quest status"); } // Clear all markers (we'll re-add active ones) @@ -3493,7 +3807,7 @@ void Application::updateQuestMarkers() { } if (firstRun && markersAdded > 0) { - LOG_INFO("Quest markers: Added ", markersAdded, " markers on first run"); + LOG_DEBUG("Quest markers: Added ", markersAdded, " markers on first run"); firstRun = false; } } diff --git a/src/core/memory_monitor.cpp b/src/core/memory_monitor.cpp index 1b38227a..4064d49b 100644 --- a/src/core/memory_monitor.cpp +++ b/src/core/memory_monitor.cpp @@ -2,11 +2,31 @@ #include "core/logger.hpp" #include #include +#include #include namespace wowee { namespace core { +namespace { +size_t readMemAvailableBytesFromProc() { + std::ifstream meminfo("/proc/meminfo"); + if (!meminfo.is_open()) return 0; + + std::string line; + while (std::getline(meminfo, line)) { + // Format: "MemAvailable: 123456789 kB" + if (line.rfind("MemAvailable:", 0) != 0) continue; + std::istringstream iss(line.substr(13)); + size_t kb = 0; + iss >> kb; + if (kb > 0) return kb * 1024ull; + break; + } + return 0; +} +} // namespace + MemoryMonitor& MemoryMonitor::getInstance() { static MemoryMonitor instance; return instance; @@ -25,10 +45,18 @@ void MemoryMonitor::initialize() { } size_t MemoryMonitor::getAvailableRAM() const { + // Best source on Linux for reclaimable memory headroom. + if (size_t memAvailable = readMemAvailableBytesFromProc(); memAvailable > 0) { + return memAvailable; + } + struct sysinfo info; if (sysinfo(&info) == 0) { - // Available = free + buffers + cached - return static_cast(info.freeram) * info.mem_unit; + // Fallback approximation if /proc/meminfo is unavailable. + size_t freeBytes = static_cast(info.freeram) * info.mem_unit; + size_t bufferBytes = static_cast(info.bufferram) * info.mem_unit; + size_t available = freeBytes + bufferBytes; + return (totalRAM_ > 0 && available > totalRAM_) ? totalRAM_ : available; } return totalRAM_ / 2; // Fallback: assume 50% available } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 35540fb8..03f5f5cc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -160,6 +160,7 @@ void GameHandler::update(float deltaTime) { // Send periodic heartbeat if in world if (state == WorldState::IN_WORLD) { timeSinceLastPing += deltaTime; + timeSinceLastMoveHeartbeat_ += deltaTime; if (timeSinceLastPing >= pingInterval) { if (socket) { @@ -168,6 +169,11 @@ void GameHandler::update(float deltaTime) { timeSinceLastPing = 0.0f; } + if (timeSinceLastMoveHeartbeat_ >= moveHeartbeatInterval_) { + sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); + timeSinceLastMoveHeartbeat_ = 0.0f; + } + // Update cast timer (Phase 3) if (casting && castTimeRemaining > 0.0f) { castTimeRemaining -= deltaTime; @@ -203,6 +209,9 @@ void GameHandler::update(float deltaTime) { if (taxiLandingCooldown_ > 0.0f) { taxiLandingCooldown_ -= deltaTime; } + if (taxiStartGrace_ > 0.0f) { + taxiStartGrace_ -= deltaTime; + } // Taxi logic timing auto taxiStart = std::chrono::high_resolution_clock::now(); @@ -215,7 +224,8 @@ void GameHandler::update(float deltaTime) { if (unit && (unit->getUnitFlags() & 0x00000100) == 0 && !taxiClientActive_ && - !taxiActivatePending_) { + !taxiActivatePending_ && + taxiStartGrace_ <= 0.0f) { onTaxiFlight_ = false; taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering if (taxiMountActive_ && mountCallback_) { @@ -247,7 +257,7 @@ void GameHandler::update(float deltaTime) { serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0; } - if (serverStillTaxi || taxiClientActive_ || taxiActivatePending_) { + if (taxiStartGrace_ > 0.0f || serverStillTaxi || taxiClientActive_ || taxiActivatePending_) { onTaxiFlight_ = true; } else { if (mountCallback_) mountCallback_(0); @@ -264,6 +274,25 @@ void GameHandler::update(float deltaTime) { } } + // Keep non-taxi mount state server-authoritative. + // Some server paths don't emit explicit mount field updates in lockstep + // with local visual state changes, so reconcile continuously. + if (!onTaxiFlight_ && !taxiMountActive_) { + auto playerEntity = entityManager.getEntity(playerGuid); + auto playerUnit = std::dynamic_pointer_cast(playerEntity); + if (playerUnit) { + uint32_t serverMountDisplayId = playerUnit->getMountDisplayId(); + if (serverMountDisplayId != currentMountDisplayId_) { + LOG_INFO("Mount reconcile: server=", serverMountDisplayId, + " local=", currentMountDisplayId_); + currentMountDisplayId_ = serverMountDisplayId; + if (mountCallback_) { + mountCallback_(serverMountDisplayId); + } + } + } + } + if (taxiRecoverPending_ && state == WorldState::IN_WORLD) { auto playerEntity = entityManager.getEntity(playerGuid); if (playerEntity) { @@ -408,7 +437,7 @@ void GameHandler::update(float deltaTime) { // Log profiling every 60 frames if (++profileCounter >= 60) { - LOG_INFO("UPDATE PROFILE (60 frames): socket=", socketTime / 60.0f, "ms taxi=", taxiTime / 60.0f, + LOG_DEBUG("UPDATE PROFILE (60 frames): socket=", socketTime / 60.0f, "ms taxi=", taxiTime / 60.0f, "ms distance=", distanceCheckTime / 60.0f, "ms entity=", entityUpdateTime / 60.0f, "ms TOTAL=", totalTime / 60.0f, "ms"); profileCounter = 0; @@ -1603,6 +1632,20 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementInfo.flags = 0; movementInfo.flags2 = 0; movementInfo.time = 0; + resurrectPending_ = false; + resurrectRequestPending_ = false; + onTaxiFlight_ = false; + taxiMountActive_ = false; + taxiActivatePending_ = false; + taxiClientActive_ = false; + taxiClientPath_.clear(); + taxiRecoverPending_ = false; + taxiStartGrace_ = 0.0f; + currentMountDisplayId_ = 0; + taxiMountDisplayId_ = 0; + if (mountCallback_) { + mountCallback_(0); + } // Send CMSG_SET_ACTIVE_MOVER (required by some servers) if (playerGuid != 0 && socket) { @@ -1705,9 +1748,17 @@ void GameHandler::sendMovement(Opcode opcode) { return; } - // Block manual movement while taxi is active/mounted, but still allow heartbeat packets. - if ((onTaxiFlight_ || taxiMountActive_) && opcode != Opcode::CMSG_MOVE_HEARTBEAT) return; - if (resurrectPending_) return; + // Block manual movement while taxi is active/mounted, but always allow + // stop/heartbeat opcodes so stuck states can be recovered. + bool taxiAllowed = + (opcode == Opcode::CMSG_MOVE_HEARTBEAT) || + (opcode == Opcode::CMSG_MOVE_STOP) || + (opcode == Opcode::CMSG_MOVE_STOP_STRAFE) || + (opcode == Opcode::CMSG_MOVE_STOP_TURN) || + (opcode == Opcode::CMSG_MOVE_STOP_SWIM) || + (opcode == Opcode::CMSG_MOVE_FALL_LAND); + if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return; + if (resurrectPending_ && !taxiAllowed) return; // Use real millisecond timestamp (server validates for anti-cheat) static auto startTime = std::chrono::steady_clock::now(); @@ -1817,6 +1868,45 @@ void GameHandler::sendMovement(Opcode opcode) { socket->send(packet); } +void GameHandler::forceClearTaxiAndMovementState() { + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + taxiClientActive_ = false; + taxiClientPath_.clear(); + taxiRecoverPending_ = false; + taxiStartGrace_ = 0.0f; + onTaxiFlight_ = false; + + if (taxiMountActive_ && mountCallback_) { + mountCallback_(0); + } + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + currentMountDisplayId_ = 0; + resurrectPending_ = false; + resurrectRequestPending_ = false; + playerDead_ = false; + releasedSpirit_ = false; + repopPending_ = false; + pendingSpiritHealerGuid_ = 0; + resurrectCasterGuid_ = 0; + + movementInfo.flags = 0; + movementInfo.flags2 = 0; + movementInfo.transportGuid = 0; + clearPlayerTransport(); + + if (socket && state == WorldState::IN_WORLD) { + sendMovement(Opcode::CMSG_MOVE_STOP); + sendMovement(Opcode::CMSG_MOVE_STOP_STRAFE); + sendMovement(Opcode::CMSG_MOVE_STOP_TURN); + sendMovement(Opcode::CMSG_MOVE_STOP_SWIM); + sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); + } + + LOG_INFO("Force-cleared taxi/movement state"); +} + void GameHandler::setPosition(float x, float y, float z) { movementInfo.x = x; movementInfo.y = y; @@ -2001,6 +2091,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { 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); applyTaxiMountForCurrentNode(); } } @@ -2559,6 +2650,15 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { LOG_INFO("Ignoring destroy for transport entity: 0x", std::hex, data.guid, std::dec); return; } + // Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal. + auto entity = entityManager.getEntity(data.guid); + if (entity) { + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(data.guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { + gameObjectDespawnCallback_(data.guid); + } + } entityManager.removeEntity(data.guid); LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec, " (", (data.isDeath ? "death" : "despawn"), ")"); @@ -4114,7 +4214,20 @@ void GameHandler::handleAttackStop(network::Packet& packet) { } void GameHandler::dismount() { - if (!isMounted() || !socket) return; + if (!socket) return; + if (!isMounted()) { + // Local/server desync guard: clear visual mount even when server says unmounted. + if (mountCallback_) { + mountCallback_(0); + } + currentMountDisplayId_ = 0; + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + onTaxiFlight_ = false; + taxiActivatePending_ = false; + taxiClientActive_ = false; + LOG_INFO("Dismount desync recovery: force-cleared local mount state"); + } network::Packet pkt(static_cast(Opcode::CMSG_CANCEL_MOUNT_AURA)); socket->send(pkt); LOG_INFO("Sent CMSG_CANCEL_MOUNT_AURA"); @@ -4144,20 +4257,27 @@ void GameHandler::handleForceRunSpeedChange(network::Packet& packet) { if (guid != playerGuid) return; - // Always ACK the speed change to prevent server stall + // Always ACK the speed change to prevent server stall. + // Packet format mirrors movement packets: packed guid + counter + movement info + new speed. if (socket) { network::Packet ack(static_cast(Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK)); - ack.writeUInt64(playerGuid); + MovementPacket::writePackedGuid(ack, playerGuid); ack.writeUInt32(counter); - // MovementInfo (minimal — no flags set means no optional fields) - ack.writeUInt32(0); // moveFlags - ack.writeUInt16(0); // moveFlags2 - ack.writeUInt32(movementTime); - ack.writeFloat(movementInfo.x); - ack.writeFloat(movementInfo.y); - ack.writeFloat(movementInfo.z); - ack.writeFloat(movementInfo.orientation); - ack.writeUInt32(0); // fallTime + + MovementInfo wire = movementInfo; + glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z)); + wire.x = serverPos.x; + wire.y = serverPos.y; + wire.z = serverPos.z; + if (wire.hasFlag(MovementFlags::ONTRANSPORT)) { + glm::vec3 serverTransport = + core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ)); + wire.transportX = serverTransport.x; + wire.transportY = serverTransport.y; + wire.transportZ = serverTransport.z; + } + MovementPacket::writeMovementPayload(ack, wire); + ack.writeFloat(newSpeed); socket->send(ack); } @@ -5899,6 +6019,21 @@ void GameHandler::handleNewWorld(network::Packet& packet) { movementInfo.z = canonical.z; movementInfo.orientation = orientation; movementInfo.flags = 0; + movementInfo.flags2 = 0; + resurrectPending_ = false; + resurrectRequestPending_ = false; + onTaxiFlight_ = false; + taxiMountActive_ = false; + taxiActivatePending_ = false; + taxiClientActive_ = false; + taxiClientPath_.clear(); + taxiRecoverPending_ = false; + taxiStartGrace_ = 0.0f; + currentMountDisplayId_ = 0; + taxiMountDisplayId_ = 0; + if (mountCallback_) { + mountCallback_(0); + } // Clear world state for the new map entityManager.clear(); @@ -6359,6 +6494,13 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { return; } + // Guard against stray/mis-mapped packets being treated as taxi replies. + // Only honor taxi replies when a taxi flow is actually active. + if (!taxiActivatePending_ && !taxiWindowOpen_ && !onTaxiFlight_) { + LOG_DEBUG("Ignoring stray taxi reply: result=", data.result); + return; + } + if (data.result == 0) { // Some cores can emit duplicate success replies (e.g. basic + express activate). // Ignore repeats once taxi is already active and no activation is pending. @@ -6366,6 +6508,7 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { return; } onTaxiFlight_ = true; + taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); taxiWindowOpen_ = false; taxiActivatePending_ = false; taxiActivateTimer_ = 0.0f; @@ -6391,6 +6534,12 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { void GameHandler::closeTaxi() { taxiWindowOpen_ = false; + // Closing the taxi UI must not cancel an active/pending flight. + // The window can auto-close due distance checks while takeoff begins. + if (taxiActivatePending_ || onTaxiFlight_ || taxiClientActive_) { + return; + } + // If we optimistically mounted during node selection, dismount now if (taxiMountActive_ && mountCallback_) { mountCallback_(0); // Dismount @@ -6444,6 +6593,11 @@ uint32_t GameHandler::getTaxiCostTo(uint32_t destNodeId) const { void GameHandler::activateTaxi(uint32_t destNodeId) { if (!socket || state != WorldState::IN_WORLD) return; + // One-shot taxi activation until server replies or timeout. + if (taxiActivatePending_ || onTaxiFlight_) { + return; + } + uint32_t startNode = currentTaxiData_.nearestNode; if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return; @@ -6514,13 +6668,14 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId); socket->send(basicPkt); - // Others accept express with a full node path + cost. - auto pkt = ActivateTaxiExpressPacket::build(taxiNpcGuid_, totalCost, path); - socket->send(pkt); + // AzerothCore in this setup rejects/misparses CMSG_ACTIVATETAXIEXPRESS (0x312), + // so keep taxi activation on the basic packet only. // Optimistically start taxi visuals; server will correct if it denies. + taxiWindowOpen_ = false; taxiActivatePending_ = true; taxiActivateTimer_ = 0.0f; + taxiStartGrace_ = 2.0f; if (!onTaxiFlight_) { onTaxiFlight_ = true; applyTaxiMountForCurrentNode(); diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 3a7114ac..c0d283e0 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -371,7 +371,7 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float if (debugFrameCount++ % 120 == 0) { // Log canonical position AND render position to check coordinate conversion glm::vec3 renderPos = core::coords::canonicalToRender(transport.position); - LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec, + LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec, " pathTime=", pathTimeMs, "ms / ", path.durationMs, "ms", " canonicalPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", " renderPos=(", renderPos.x, ", ", renderPos.y, ", ", renderPos.z, ")", diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 2f539672..3ecee765 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -523,23 +523,12 @@ bool PongParser::parse(network::Packet& packet, PongData& data) { return true; } -network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, uint64_t playerGuid) { - network::Packet packet(static_cast(opcode)); - - // Movement packet format (WoW 3.3.5a): - // packed GUID - // uint32 flags - // uint16 flags2 - // uint32 time - // float x, y, z - // float orientation - - // Write packed GUID +void MovementPacket::writePackedGuid(network::Packet& packet, uint64_t guid) { uint8_t mask = 0; uint8_t guidBytes[8]; int guidByteCount = 0; for (int i = 0; i < 8; i++) { - uint8_t byte = static_cast((playerGuid >> (i * 8)) & 0xFF); + uint8_t byte = static_cast((guid >> (i * 8)) & 0xFF); if (byte != 0) { mask |= (1 << i); guidBytes[guidByteCount++] = byte; @@ -549,6 +538,15 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u for (int i = 0; i < guidByteCount; i++) { packet.writeUInt8(guidBytes[i]); } +} + +void MovementPacket::writeMovementPayload(network::Packet& packet, const MovementInfo& info) { + // Movement packet format (WoW 3.3.5a) payload: + // uint32 flags + // uint16 flags2 + // uint32 time + // float x, y, z + // float orientation // Write movement flags packet.writeUInt32(info.flags); @@ -617,6 +615,15 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u packet.writeBytes(reinterpret_cast(&info.jumpCosAngle), sizeof(float)); packet.writeBytes(reinterpret_cast(&info.jumpXYSpeed), sizeof(float)); } +} + +network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, uint64_t playerGuid) { + network::Packet packet(static_cast(opcode)); + + // Movement packet format (WoW 3.3.5a): + // packed GUID + movement payload + writePackedGuid(packet, playerGuid); + writeMovementPayload(packet, info); // Detailed hex dump for debugging static int mvLog = 5; @@ -828,16 +835,27 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock block.z = packet.readFloat(); block.onTransport = (transportGuid != 0); block.transportGuid = transportGuid; - block.transportX = packet.readFloat(); - block.transportY = packet.readFloat(); - block.transportZ = packet.readFloat(); + float tx = packet.readFloat(); + float ty = packet.readFloat(); + float tz = packet.readFloat(); + if (block.onTransport) { + block.transportX = tx; + block.transportY = ty; + block.transportZ = tz; + } else { + block.transportX = 0.0f; + block.transportY = 0.0f; + block.transportZ = 0.0f; + } block.orientation = packet.readFloat(); /*float corpseOrientation =*/ packet.readFloat(); block.hasMovement = true; - LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation, - " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); + if (block.onTransport) { + LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation, + " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); + } } else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) { // Simple stationary position (4 floats) @@ -895,9 +913,9 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& } uint32_t fieldsCapacity = blockCount * 32; - LOG_INFO(" UPDATE MASK PARSE:"); - LOG_INFO(" maskBlockCount = ", (int)blockCount); - LOG_INFO(" fieldsCapacity (blocks * 32) = ", fieldsCapacity); + LOG_DEBUG(" UPDATE MASK PARSE:"); + LOG_DEBUG(" maskBlockCount = ", (int)blockCount); + LOG_DEBUG(" fieldsCapacity (blocks * 32) = ", fieldsCapacity); // Read update mask std::vector updateMask(blockCount); @@ -932,11 +950,11 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& size_t bytesUsed = endPos - startPos; size_t bytesRemaining = packet.getSize() - endPos; - LOG_INFO(" highestSetBitIndex = ", highestSetBit); - LOG_INFO(" valuesReadCount = ", valuesReadCount); - LOG_INFO(" bytesUsedForFields = ", bytesUsed); - LOG_INFO(" bytesRemainingInPacket = ", bytesRemaining); - LOG_INFO(" Parsed ", block.fields.size(), " fields"); + LOG_DEBUG(" highestSetBitIndex = ", highestSetBit); + LOG_DEBUG(" valuesReadCount = ", valuesReadCount); + LOG_DEBUG(" bytesUsedForFields = ", bytesUsed); + LOG_DEBUG(" bytesRemainingInPacket = ", bytesRemaining); + LOG_DEBUG(" Parsed ", block.fields.size(), " fields"); return true; } @@ -1009,9 +1027,9 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) // Read block count data.blockCount = packet.readUInt32(); - LOG_INFO("SMSG_UPDATE_OBJECT:"); - LOG_INFO(" objectCount = ", data.blockCount); - LOG_INFO(" packetSize = ", packet.getSize()); + LOG_DEBUG("SMSG_UPDATE_OBJECT:"); + LOG_DEBUG(" objectCount = ", data.blockCount); + LOG_DEBUG(" packetSize = ", packet.getSize()); // Check for out-of-range objects first if (packet.getReadPos() + 1 <= packet.getSize()) { diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index defb0517..365ce801 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -163,14 +163,23 @@ std::vector AssetManager::readFile(const std::string& path) const { fileCacheHits++; return it->second.data; } - fileCacheMisses++; } - // Cache miss - decompress from MPQ. - // Keep MPQ reads serialized, but do not block cache-hit readers on this mutex. + // Cache miss path: serialize MPQ reads. Before reading, re-check cache while holding + // readMutex so only one thread performs decompression per hot path at a time. std::vector data; { std::lock_guard readLock(readMutex); + { + std::lock_guard cacheLock(cacheMutex); + auto it = fileCache.find(normalized); + if (it != fileCache.end()) { + it->second.lastAccessTime = ++fileCacheAccessCounter; + fileCacheHits++; + return it->second.data; + } + fileCacheMisses++; + } data = mpqManager.readFile(normalized); } if (data.empty()) { diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index fab9fcc1..550a6219 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -735,9 +735,45 @@ void CameraController::update(float deltaTime) { } // ===== WoW-style orbit camera ===== - // Pivot point at upper chest/neck + // Pivot point at upper chest/neck. float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f; - glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset); + float pivotLift = 0.0f; + if (terrainManager && !externalFollow_) { + float moved = glm::length(glm::vec2(targetPos.x - lastPivotLiftQueryPos_.x, + targetPos.y - lastPivotLiftQueryPos_.y)); + float distDelta = std::abs(currentDistance - lastPivotLiftDistance_); + bool queryLift = (++pivotLiftQueryCounter_ >= PIVOT_LIFT_QUERY_INTERVAL) || + (moved >= PIVOT_LIFT_POS_THRESHOLD) || + (distDelta >= PIVOT_LIFT_DIST_THRESHOLD); + if (queryLift) { + pivotLiftQueryCounter_ = 0; + lastPivotLiftQueryPos_ = targetPos; + lastPivotLiftDistance_ = currentDistance; + + // Estimate where camera sits horizontally and ensure enough terrain clearance. + glm::vec3 probeCam = targetPos + (-forward3D) * currentDistance; + auto terrainAtCam = terrainManager->getHeightAt(probeCam.x, probeCam.y); + auto terrainAtPivot = terrainManager->getHeightAt(targetPos.x, targetPos.y); + + float desiredLift = 0.0f; + if (terrainAtCam) { + // Keep pivot high enough so near-hill camera rays don't cut through terrain. + constexpr float kMinRayClearance = 2.0f; + float basePivotZ = targetPos.z + PIVOT_HEIGHT + mountedOffset; + float rayClearance = basePivotZ - *terrainAtCam; + if (rayClearance < kMinRayClearance) { + desiredLift = std::clamp(kMinRayClearance - rayClearance, 0.0f, 1.4f); + } + } + // If character is already below local terrain sample, avoid lifting aggressively. + if (terrainAtPivot && targetPos.z < *terrainAtPivot - 0.2f) { + desiredLift = 0.0f; + } + cachedPivotLift_ = desiredLift; + } + pivotLift = cachedPivotLift_; + } + glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset + pivotLift); // Camera direction from yaw/pitch (already computed as forward3D) glm::vec3 camDir = -forward3D; // Camera looks at pivot, so it's behind diff --git a/src/rendering/celestial.cpp b/src/rendering/celestial.cpp index ad34506f..f5247464 100644 --- a/src/rendering/celestial.cpp +++ b/src/rendering/celestial.cpp @@ -177,7 +177,7 @@ void Celestial::renderSun(const Camera& camera, float timeOfDay, // Create model matrix glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, sunPos); - model = glm::scale(model, glm::vec3(500.0f, 500.0f, 1.0f)); // Large and visible + model = glm::scale(model, glm::vec3(95.0f, 95.0f, 1.0f)); // Match WotLK-like apparent size // Set uniforms glm::mat4 view = camera.getViewMatrix(); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index c1e65114..0d22d735 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -986,7 +986,7 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { static int logCounter = 0; if (++logCounter >= 300) { // Log every 10 seconds at 30fps - LOG_INFO("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (", + LOG_DEBUG("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (", instances.size() - updatedCount, " culled)"); logCounter = 0; } @@ -1584,7 +1584,17 @@ void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) { } void CharacterRenderer::removeInstance(uint32_t instanceId) { - instances.erase(instanceId); + auto it = instances.find(instanceId); + if (it == instances.end()) return; + + // Remove child attachments first (helmets/weapons), otherwise they leak as + // orphan render instances when the parent creature despawns. + auto attachments = it->second.weaponAttachments; + for (const auto& wa : attachments) { + removeInstance(wa.weaponInstanceId); + } + + instances.erase(it); } bool CharacterRenderer::getAnimationState(uint32_t instanceId, uint32_t& animationId, diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index d6c88427..86685217 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -2053,7 +2053,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: static int frameCounter = 0; if (++frameCounter >= 120) { frameCounter = 0; - LOG_INFO("M2 Render: ", totalMs, " ms (culling/sort: ", cullingSortMs, + LOG_DEBUG("M2 Render: ", totalMs, " ms (culling/sort: ", cullingSortMs, " ms, draw: ", drawLoopMs, " ms) | ", sortedVisible_.size(), " visible | ", totalBatchesDrawn, " batches | ", boneMatrixUploads, " bone uploads"); } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index acd43e38..a1685283 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2143,7 +2143,7 @@ void Renderer::update(float deltaTime) { // Log renderer profiling every 60 frames if (++rendProfileCounter >= 60) { - LOG_INFO("RENDERER UPDATE PROFILE (60 frames): camera=", camTime / 60.0f, + LOG_DEBUG("RENDERER UPDATE PROFILE (60 frames): camera=", camTime / 60.0f, "ms light=", lightTime / 60.0f, "ms charAnim=", charAnimTime / 60.0f, "ms terrain=", terrainTime / 60.0f, "ms sky=", skyTime / 60.0f, "ms charRend=", charRendTime / 60.0f, "ms audio=", audioTime / 60.0f, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 71a32fb2..f4b3db29 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -30,9 +30,58 @@ #include #include #include +#include #include namespace { + std::string trim(const std::string& s) { + size_t first = s.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + size_t last = s.find_last_not_of(" \t\r\n"); + return s.substr(first, last - first + 1); + } + + std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; + } + + bool isPortBotTarget(const std::string& target) { + std::string t = toLower(trim(target)); + return t == "portbot" || t == "gmbot" || t == "telebot"; + } + + std::string buildPortBotCommand(const std::string& rawInput) { + std::string input = trim(rawInput); + if (input.empty()) return ""; + + std::string lower = toLower(input); + if (lower == "help" || lower == "?") { + return "__help__"; + } + + if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) { + return input; + } + + if (lower.rfind("xyz ", 0) == 0) { + return ".go " + input; + } + + if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; + if (lower == "if" || lower == "ironforge") return ".tele ironforge"; + if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; + if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; + if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; + if (lower == "uc" || lower == "undercity") return ".tele undercity"; + if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; + if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; + + return ".tele " + input; + } + bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) { glm::vec3 oc = ray.origin - center; float b = glm::dot(oc, ray.direction); @@ -1200,13 +1249,42 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } - // /who command - if (cmdLower == "who") { - std::string playerName; + // /who commands + if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { + std::string query; if (spacePos != std::string::npos) { - playerName = command.substr(spacePos + 1); + query = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + size_t first = query.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + query.clear(); + } else { + size_t last = query.find_last_not_of(" \t\r\n"); + query = query.substr(first, last - first + 1); + } } - gameHandler.queryWho(playerName); + + if ((cmdLower == "whois") && query.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /whois "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + + if (cmdLower == "who" && (query == "help" || query == "?")) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Who commands: /who [name/filter], /whois , /online"; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + + gameHandler.queryWho(query); chatInputBuffer[0] = '\0'; return; } @@ -1953,6 +2031,26 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } } + // Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands. + if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { + std::string cmd = buildPortBotCommand(message); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + if (cmd.empty() || cmd == "__help__") { + msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + + gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); + msg.message = "PortBot executed: " + cmd; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + // Validate whisper has a target if (type == game::ChatType::WHISPER && target.empty()) { game::MessageChatData msg;