From 4ba19d53d72c2c70a95d6c03e38436665bb29f67 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Fri, 10 Apr 2026 19:50:06 +0300 Subject: [PATCH 1/6] fix(ui): preserve auto-connect state when navigating back from character screen Add resetForBack() to RealmScreen that clears selection state without resetting autoSelectAttempted, preventing single-realm auto-connect from re-firing when the user navigates back from the character screen. Signed-off-by: Pavel Okhlopkov --- include/ui/realm_screen.hpp | 12 ++++++++++++ src/core/ui_screen_callback_handler.cpp | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/include/ui/realm_screen.hpp b/include/ui/realm_screen.hpp index ed6504c1..5963328d 100644 --- a/include/ui/realm_screen.hpp +++ b/include/ui/realm_screen.hpp @@ -44,6 +44,18 @@ public: statusMessage.clear(); } + /** + * Reset for back-navigation from character screen. + * Preserves autoSelectAttempted so single-realm auto-connect doesn't re-fire. + */ + void resetForBack() { + selectedRealmIndex = -1; + realmSelected = false; + selectedRealmName.clear(); + selectedRealmAddress.clear(); + statusMessage.clear(); + } + /** * Check if a realm has been selected */ diff --git a/src/core/ui_screen_callback_handler.cpp b/src/core/ui_screen_callback_handler.cpp index 1d6acf87..f2016991 100644 --- a/src/core/ui_screen_callback_handler.cpp +++ b/src/core/ui_screen_callback_handler.cpp @@ -155,7 +155,7 @@ void UIScreenCallbackHandler::setupCallbacks() { uiManager_.getCharacterScreen().setOnBack([this]() { // Disconnect from world server and reset UI state for fresh realm selection gameHandler_.disconnect(); - uiManager_.getRealmScreen().reset(); + uiManager_.getRealmScreen().resetForBack(); uiManager_.getCharacterScreen().reset(); setState_(AppState::REALM_SELECTION); }); From 826a22eed340d77629b7b47871e6d94c3d8901e7 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Fri, 10 Apr 2026 19:50:24 +0300 Subject: [PATCH 2/6] fix(animation): prevent creature walk/run animation persisting after arriving Use destination position (getLatest) instead of dead-reckoned position (getX/Y/Z) during the overrun window to avoid visible forward-drift and backward-snap. Only fall back to position-change movement detection for entities without active movement tracking, preventing residual velocity drift from keeping walk/run animation playing after arrival. Signed-off-by: Pavel Okhlopkov --- src/core/application.cpp | 42 +++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index f62aabc9..04ed7549 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1740,7 +1740,16 @@ void Application::update(float deltaTime) { if (canonDistSq > syncRadiusSq) continue; } - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Use the destination position once the entity has reached its + // target. During the dead-reckoning overrun window getX/Y/Z + // drifts past the destination at the last known velocity; + // using getLatest (== moveEnd while isMoving_) avoids the + // visible forward-drift followed by a backward snap. + const bool inOverrun = entity->isEntityMoving() && !entity->isActivelyMoving(); + glm::vec3 canonical( + inOverrun ? entity->getLatestX() : entity->getX(), + inOverrun ? entity->getLatestY() : entity->getY(), + inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // Visual collision guard: keep hostile melee units from rendering inside the @@ -1822,17 +1831,18 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f); - // isEntityMoving() reflects server-authoritative move state set by - // startMoveTo() in handleMonsterMove, regardless of distance-cull. - // This correctly detects movement for distant creatures (> 150u) - // where updateMovement() is not called and getX/Y/Z() stays stale. - // Use isActivelyMoving() (not isEntityMoving()) so the - // Run/Walk animation stops when the creature reaches its - // destination, rather than persisting through the dead- - // reckoning overrun window. + // Use isActivelyMoving() so Run/Walk animation stops when the + // creature reaches its destination. Don't use position-change + // (planarDistSq) as a movement indicator when the entity is in + // the dead-reckoning overrun window — the residual velocity + // drift would keep the walk/run animation playing long after + // the creature has actually arrived. Only fall back to position- + // change detection for entities with no active movement tracking + // (e.g. teleports or position-only updates from the server). const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq = 0.03f * 0.03f; - const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq || dz > 0.08f); + const bool posChanging = planarDistSq > kMoveThreshSq || dz > 0.08f; + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || (posChanging && !entity->isEntityMoving())); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); } else if (planarDistSq > kMoveThreshSq || dz > 0.08f) { @@ -1936,8 +1946,13 @@ void Application::update(float deltaTime) { if (glm::dot(d, d) > pSyncRadiusSq) continue; } - // Position sync - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Position sync — clamp to destination during dead-reckoning + // overrun to avoid drift + backward snap (same as creature loop). + const bool inOverrun = entity->isEntityMoving() && !entity->isActivelyMoving(); + glm::vec3 canonical( + inOverrun ? entity->getLatestX() : entity->getX(), + inOverrun ? entity->getLatestY() : entity->getY(), + inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); auto posIt = _pCreatureRenderPosCache.find(guid); @@ -1956,7 +1971,8 @@ void Application::update(float deltaTime) { const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f); const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq2 = 0.03f * 0.03f; - const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq2 || dz > 0.08f); + const bool posChanging2 = planarDistSq > kMoveThreshSq2 || dz > 0.08f; + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || (posChanging2 && !entity->isEntityMoving())); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); From 9c1ffae14008ce388b9d9b7db809c145673d9396 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Fri, 10 Apr 2026 19:50:40 +0300 Subject: [PATCH 3/6] fix(ui): add keyboard navigation to character selection screen Add Up/Down arrow keys to cycle through character list and Enter to select. Claim arrow key ownership via SetKeyOwner to prevent ImGui nav from moving focus to other widgets. Lock Enter key until release to prevent the keypress from activating chat on the game screen. Signed-off-by: Pavel Okhlopkov --- src/ui/character_screen.cpp | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 8024ee5e..67c758b7 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -6,6 +6,7 @@ #include "core/application.hpp" #include "core/logger.hpp" #include +#include #include #include #include @@ -241,6 +242,43 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::EndTable(); } + + // Keyboard navigation: Up/Down to change selection, Enter to enter world + // Claim ownership of arrow keys so ImGui nav doesn't move focus between buttons + ImGuiID charListOwner = ImGui::GetID("CharListNav"); + ImGui::SetKeyOwner(ImGuiKey_UpArrow, charListOwner); + ImGui::SetKeyOwner(ImGuiKey_DownArrow, charListOwner); + + if (!characters.empty() && deleteConfirmStage == 0) { + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { + if (selectedCharacterIndex > 0) { + selectedCharacterIndex--; + selectedCharacterGuid = characters[selectedCharacterIndex].guid; + saveLastCharacter(selectedCharacterGuid); + } + } + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { + if (selectedCharacterIndex < static_cast(characters.size()) - 1) { + selectedCharacterIndex++; + selectedCharacterGuid = characters[selectedCharacterIndex].guid; + saveLastCharacter(selectedCharacterGuid); + } + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) { + if (selectedCharacterIndex >= 0 && + selectedCharacterIndex < static_cast(characters.size())) { + const auto& ch = characters[selectedCharacterIndex]; + characterSelected = true; + saveLastCharacter(ch.guid); + // Claim Enter so the game screen doesn't activate chat on the same press + ImGui::SetKeyOwner(ImGuiKey_Enter, charListOwner, ImGuiInputFlags_LockUntilRelease); + ImGui::SetKeyOwner(ImGuiKey_KeypadEnter, charListOwner, ImGuiInputFlags_LockUntilRelease); + gameHandler.selectCharacter(ch.guid); + if (onCharacterSelected) onCharacterSelected(ch.guid); + } + } + } + ImGui::EndChild(); // ── Right: Details panel ── From 759d6046bbdb1f1a64b383441a1e3def27865d60 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Fri, 10 Apr 2026 19:50:56 +0300 Subject: [PATCH 4/6] fix(quest): quest log population, NPC marker updates on accept/abandon - Delegate GameHandler::getQuestGiverStatus() to QuestHandler instead of reading from GameHandler's own empty npcQuestStatus_ map - Immediately add quest to local log in acceptQuest() instead of waiting for field updates, fixing quests not appearing after accept - Handle duplicate accept path (server already has quest) by also adding to local log - Remove early return on empty questLog_ in applyQuestStateFromFields() - Re-query nearby quest giver NPC statuses on abandon so markers refresh Signed-off-by: Pavel Okhlopkov --- include/game/game_handler.hpp | 5 +--- src/game/game_handler.cpp | 4 +++ src/game/quest_handler.cpp | 47 ++++++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index cac41b7c..7cff33f0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1699,10 +1699,7 @@ public: bool isServerMovementAllowed() const; // Quest giver status (! and ? markers) - QuestGiverStatus getQuestGiverStatus(uint64_t guid) const { - auto it = npcQuestStatus_.find(guid); - return (it != npcQuestStatus_.end()) ? it->second : QuestGiverStatus::NONE; - } + QuestGiverStatus getQuestGiverStatus(uint64_t guid) const; const std::unordered_map& getNpcQuestStatuses() const; // Charge callback — fires when player casts a charge spell toward target diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3d38b20b..9eeb836b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2261,6 +2261,10 @@ const std::unordered_map& GameHandler::getNpcQuestSt static const std::unordered_map empty; return empty; } +QuestGiverStatus GameHandler::getQuestGiverStatus(uint64_t guid) const { + if (questHandler_) return questHandler_->getQuestGiverStatus(guid); + return QuestGiverStatus::NONE; +} const std::vector& GameHandler::getQuestLog() const { if (questHandler_) return questHandler_->getQuestLog(); static const std::vector empty; diff --git a/src/game/quest_handler.cpp b/src/game/quest_handler.cpp index 6b5b12c4..3f4ee6ab 100644 --- a/src/game/quest_handler.cpp +++ b/src/game/quest_handler.cpp @@ -339,6 +339,8 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { if (packet.hasRemaining(9)) { uint64_t npcGuid = packet.readUInt64(); uint8_t status = owner_.getPacketParsers()->readQuestGiverStatus(packet); + LOG_INFO("SMSG_QUESTGIVER_STATUS: npcGuid=0x", std::hex, npcGuid, std::dec, + " status=", static_cast(status)); npcQuestStatus_[npcGuid] = static_cast(status); } }; @@ -1075,8 +1077,17 @@ void QuestHandler::acceptQuest() { const bool inLocalLog = hasQuestInLog(questId); const int serverSlot = findQuestLogSlotIndexFromServer(questId); if (serverSlot >= 0) { - LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId, - " slot=", serverSlot); + LOG_INFO("Quest already in server quest log: questId=", questId, + " slot=", serverSlot, " inLocalLog=", inLocalLog); + // Ensure it's in our local log even if server already has it + addQuestToLocalLogIfMissing(questId, currentQuestDetails_.title, currentQuestDetails_.objectives); + requestQuestQuery(questId, false); + // Re-query NPC status from server + if (npcGuid && owner_.getSocket()) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(npcGuid); + owner_.getSocket()->send(qsPkt); + } questDetailsOpen_ = false; questDetailsOpenTime_ = std::chrono::steady_clock::time_point{}; currentQuestDetails_ = QuestDetailsData{}; @@ -1094,6 +1105,9 @@ void QuestHandler::acceptQuest() { pendingQuestAcceptTimeouts_[questId] = 5.0f; pendingQuestAcceptNpcGuids_[questId] = npcGuid; + // Immediately add to local quest log using available details + addQuestToLocalLogIfMissing(questId, currentQuestDetails_.title, currentQuestDetails_.objectives); + // Play quest-accept sound if (auto* ac = owner_.services().audioCoordinator) { if (auto* sfx = ac->getUiSoundManager()) @@ -1223,6 +1237,19 @@ void QuestHandler::abandonQuest(uint32_t questId) { } } + // Re-query nearby quest giver NPCs so markers refresh (e.g. "?" → "!") + if (owner_.getSocket()) { + for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { + if (entity->getType() != ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (unit->getNpcFlags() & 0x02) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(guid); + owner_.getSocket()->send(qsPkt); + } + } + } + // Remove any quest POI minimap markers for this quest. gossipPois_.erase( std::remove_if(gossipPois_.begin(), gossipPois_.end(), @@ -1376,7 +1403,7 @@ bool QuestHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { void QuestHandler::applyQuestStateFromFields(const std::map& fields) { const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - if (ufQuestStart == 0xFFFF || questLog_.empty()) return; + if (ufQuestStart == 0xFFFF) return; const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; if (qStride < 2) return; @@ -1391,6 +1418,20 @@ void QuestHandler::applyQuestStateFromFields(const std::map& uint32_t questId = idIt->second; if (questId == 0) continue; + // Add quest to local log only if we have a pending accept for it + if (!hasQuestInLog(questId) && pendingQuestAcceptTimeouts_.count(questId) != 0) { + addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), ""); + requestQuestQuery(questId, false); + // Re-query quest giver status for the NPC that gave us this quest + auto pendingIt = pendingQuestAcceptNpcGuids_.find(questId); + if (pendingIt != pendingQuestAcceptNpcGuids_.end() && pendingIt->second != 0 && owner_.getSocket()) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(pendingIt->second); + owner_.getSocket()->send(qsPkt); + } + clearPendingQuestAccept(questId); + } + auto stateIt = fields.find(stateField); if (stateIt == fields.end()) continue; bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete); From e07983b7f6d8dbbe932ea7547ae54c1570a86f8e Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Fri, 10 Apr 2026 19:51:13 +0300 Subject: [PATCH 5/6] fix(rendering): crash on window resize due to stale swapchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark swapchain dirty in Application's SDL resize handler (was only done in Window::pollEvents which is never called) - Skip swapchain recreation when window is minimized (0×0 extent violates Vulkan spec and crashes vmaCreateImage) - Guard aspect ratio division by zero when height is 0 Signed-off-by: Pavel Okhlopkov --- src/core/application.cpp | 6 +++++- src/rendering/renderer.cpp | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 04ed7549..2a006a65 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -720,8 +720,12 @@ void Application::run() { int newWidth = event.window.data1; int newHeight = event.window.data2; window->setSize(newWidth, newHeight); + // Mark swapchain dirty so it gets recreated at the correct size + if (window->getVkContext()) { + window->getVkContext()->markSwapchainDirty(); + } // Vulkan viewport set in command buffer, not globally - if (renderer && renderer->getCamera()) { + if (renderer && renderer->getCamera() && newHeight > 0) { renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); } // Notify addons so UI layouts can adapt to the new size diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 8749a94e..3864ae1e 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -859,6 +859,8 @@ void Renderer::beginFrame() { // Handle swapchain recreation if needed if (vkCtx->isSwapchainDirty()) { + // Skip recreation while window is minimized (0×0 extent is a Vulkan spec violation) + if (window->getWidth() == 0 || window->getHeight() == 0) return; (void)vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); // Rebuild water resources that reference swapchain extent/views if (waterRenderer) { From 5b47d034c597209584f97e94574753ec3842047d Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Fri, 10 Apr 2026 20:35:18 +0300 Subject: [PATCH 6/6] fix(movement): multi-segment path interpolation, waypoint parsing & terrain Z clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proper waypoint support to entity movement: - Parse intermediate waypoints from MonsterMove packets in both WotLK and Vanilla paths. Uncompressed paths store absolute float3 waypoints; compressed paths decode TrinityCore's packed uint32 deltas (11-bit signed x/y, 10-bit signed z, ×0.25 scale, waypoint = midpoint − delta) with correct 2's-complement sign extension. - Entity::startMoveAlongPath() interpolates along cumulative-distance- proportional segments instead of a single straight line. - MovementHandler builds the full path (start → waypoints → destination) in canonical coords and dispatches to startMoveAlongPath() when waypoints are present. - Snap entity x/y/z to moveEnd in the dead-reckoning overrun phase before starting a new movement, preventing visible teleports when the renderer was showing the entity at its destination. - Clamp creature and player entity Z to the terrain surface via TerrainManager::getHeightAt() during active movement. Idle entities keep their server-authoritative Z to avoid breaking flight masters, elevator riders, etc. Signed-off-by: Pavel Okhlopkov --- include/game/entity.hpp | 93 +++++++++++++++++++++++++++++-- include/game/world_packets.hpp | 3 + src/core/application.cpp | 19 +++++++ src/game/movement_handler.cpp | 19 ++++++- src/game/world_packets_entity.cpp | 67 ++++++++++++++++++---- 5 files changed, 182 insertions(+), 19 deletions(-) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 4845e880..2166bfa8 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -1,7 +1,10 @@ #pragma once #include +#include #include +#include +#include #include #include #include @@ -76,14 +79,70 @@ public: z = pz; orientation = o; isMoving_ = false; // Instant position set cancels interpolation + usePathMode_ = false; + } + + // Multi-segment path movement + void startMoveAlongPath(const std::vector>& path, float destO, float totalDuration) { + if (path.empty()) return; + if (path.size() == 1 || totalDuration <= 0.0f) { + startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); + return; + } + // Compute cumulative distances for proportional segment timing + pathPoints_ = path; + pathSegDists_.resize(path.size()); + pathSegDists_[0] = 0.0f; + float totalDist = 0.0f; + for (size_t i = 1; i < path.size(); i++) { + float dx = path[i][0] - path[i - 1][0]; + float dy = path[i][1] - path[i - 1][1]; + float dz = path[i][2] - path[i - 1][2]; + totalDist += std::sqrt(dx * dx + dy * dy + dz * dz); + pathSegDists_[i] = totalDist; + } + if (totalDist < 0.001f) { + startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); + return; + } + // Snap position if in overrun phase + if (isMoving_ && moveElapsed_ >= moveDuration_) { + x = moveEndX_; y = moveEndY_; z = moveEndZ_; + } + moveEndX_ = path.back()[0]; moveEndY_ = path.back()[1]; moveEndZ_ = path.back()[2]; + moveDuration_ = totalDuration; + moveElapsed_ = 0.0f; + orientation = destO; + isMoving_ = true; + usePathMode_ = true; + // Velocity for dead-reckoning after path completes + float fromX = isMoving_ ? moveEndX_ : x; + float fromY = isMoving_ ? moveEndY_ : y; + float impliedVX = (path.back()[0] - fromX) / totalDuration; + float impliedVY = (path.back()[1] - fromY) / totalDuration; + float impliedVZ = (path.back()[2] - path[0][2]) / totalDuration; + const float alpha = 0.65f; + velX_ = alpha * impliedVX + (1.0f - alpha) * velX_; + velY_ = alpha * impliedVY + (1.0f - alpha) * velY_; + velZ_ = alpha * impliedVZ + (1.0f - alpha) * velZ_; } // Movement interpolation (syncs entity position with renderer during movement) void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) { + usePathMode_ = false; if (durationSec <= 0.0f) { setPosition(destX, destY, destZ, destO); return; } + // If we're in the dead-reckoning overrun phase, snap x/y/z back to the + // destination before using them as the new start. The renderer was showing + // the entity at moveEnd (via getLatest) during overrun, so the new + // interpolation must start there to avoid a visible teleport. + if (isMoving_ && moveElapsed_ >= moveDuration_) { + x = moveEndX_; + y = moveEndY_; + z = moveEndZ_; + } // Derive velocity from the displacement this packet implies. // Use the previous destination (not current lerped pos) as the "from" so // variable network timing doesn't inflate/shrink the implied speed. @@ -113,11 +172,31 @@ public: if (!isMoving_) return; moveElapsed_ += deltaTime; if (moveElapsed_ < moveDuration_) { - // Linear interpolation within the packet window - float t = moveElapsed_ / moveDuration_; - x = moveStartX_ + (moveEndX_ - moveStartX_) * t; - y = moveStartY_ + (moveEndY_ - moveStartY_) * t; - z = moveStartZ_ + (moveEndZ_ - moveStartZ_) * t; + if (usePathMode_ && pathPoints_.size() > 1) { + // Multi-segment path interpolation + float totalDist = pathSegDists_.back(); + float t = moveElapsed_ / moveDuration_; + float targetDist = t * totalDist; + // Find the segment containing targetDist + size_t seg = 1; + while (seg < pathSegDists_.size() - 1 && pathSegDists_[seg] < targetDist) + seg++; + float segStart = pathSegDists_[seg - 1]; + float segEnd = pathSegDists_[seg]; + float segLen = segEnd - segStart; + float segT = (segLen > 0.001f) ? (targetDist - segStart) / segLen : 0.0f; + const auto& p0 = pathPoints_[seg - 1]; + const auto& p1 = pathPoints_[seg]; + x = p0[0] + (p1[0] - p0[0]) * segT; + y = p0[1] + (p1[1] - p0[1]) * segT; + z = p0[2] + (p1[2] - p0[2]) * segT; + } else { + // Single-segment linear interpolation + float t = moveElapsed_ / moveDuration_; + x = moveStartX_ + (moveEndX_ - moveStartX_) * t; + y = moveStartY_ + (moveEndY_ - moveStartY_) * t; + z = moveStartZ_ + (moveEndZ_ - moveStartZ_) * t; + } } else { // Past the interpolation window: dead-reckon at the smoothed velocity // rather than freezing in place. Cap to one extra interval so we don't @@ -192,11 +271,15 @@ protected: // Movement interpolation state bool isMoving_ = false; + bool usePathMode_ = false; float moveStartX_ = 0, moveStartY_ = 0, moveStartZ_ = 0; float moveEndX_ = 0, moveEndY_ = 0, moveEndZ_ = 0; float moveDuration_ = 0; float moveElapsed_ = 0; float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning + // Multi-segment path data + std::vector> pathPoints_; + std::vector pathSegDists_; // Cumulative distances for each waypoint }; /** diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 19cbf3ba..8734a815 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1678,6 +1678,9 @@ struct MonsterMoveData { // Destination (final point of the spline, server coords) float destX = 0, destY = 0, destZ = 0; bool hasDest = false; + // Intermediate waypoints along the path (server coords, excludes start & final dest) + struct Point { float x, y, z; }; + std::vector waypoints; }; class MonsterMoveParser { diff --git a/src/core/application.cpp b/src/core/application.cpp index 2a006a65..966bbba5 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1756,6 +1756,17 @@ void Application::update(float deltaTime) { inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + // Clamp creature Z to terrain surface during movement interpolation. + // The server sends single-segment moves and expects the client to place + // creatures on the ground. Only clamp while actively moving — idle + // creatures keep their server-authoritative Z (flight masters, etc.). + if (entity->isActivelyMoving() && renderer->getTerrainManager()) { + auto terrainZ = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); + if (terrainZ.has_value()) { + renderPos.z = terrainZ.value(); + } + } + // Visual collision guard: keep hostile melee units from rendering inside the // player's model while attacking. This is client-side only (no server position change). // Only check for creatures within 8 units (melee range) — saves expensive @@ -1959,6 +1970,14 @@ void Application::update(float deltaTime) { inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + // Clamp other players' Z to terrain surface during movement + if (entity->isActivelyMoving() && renderer->getTerrainManager()) { + auto terrainZ = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); + if (terrainZ.has_value()) { + renderPos.z = terrainZ.value(); + } + } + auto posIt = _pCreatureRenderPosCache.find(guid); if (posIt == _pCreatureRenderPosCache.end()) { charRenderer->setInstancePosition(instanceId, renderPos); diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index d190389e..6c814235 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -1454,8 +1454,23 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { } } - entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z, - orientation, data.duration / 1000.0f); + // Build full path: start → waypoints → destination (all in canonical coords) + if (!data.waypoints.empty()) { + glm::vec3 startCanonical = core::coords::serverToCanonical( + glm::vec3(data.x, data.y, data.z)); + std::vector> path; + path.push_back({startCanonical.x, startCanonical.y, startCanonical.z}); + for (const auto& wp : data.waypoints) { + glm::vec3 wpCanonical = core::coords::serverToCanonical( + glm::vec3(wp.x, wp.y, wp.z)); + path.push_back({wpCanonical.x, wpCanonical.y, wpCanonical.z}); + } + path.push_back({destCanonical.x, destCanonical.y, destCanonical.z}); + entity->startMoveAlongPath(path, orientation, data.duration / 1000.0f); + } else { + entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z, + orientation, data.duration / 1000.0f); + } if (owner_.creatureMoveCallbackRef()) { owner_.creatureMoveCallbackRef()(data.guid, diff --git a/src/game/world_packets_entity.cpp b/src/game/world_packets_entity.cpp index ce281232..f529da70 100644 --- a/src/game/world_packets_entity.cpp +++ b/src/game/world_packets_entity.cpp @@ -637,13 +637,15 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; if (uncompressed) { - // Read last point as destination - // Skip to last point: each point is 12 bytes - if (pointCount > 1) { - for (uint32_t i = 0; i < pointCount - 1; i++) { - if (!packet.hasRemaining(12)) return true; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - } + // All waypoints stored as absolute float3 (Catmullrom/Flying paths) + // Read all intermediate points, then the final destination + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(12)) return true; + MonsterMoveData::Point wp; + wp.x = packet.readFloat(); + wp.y = packet.readFloat(); + wp.z = packet.readFloat(); + data.waypoints.push_back(wp); } if (!packet.hasRemaining(12)) return true; data.destX = packet.readFloat(); @@ -657,6 +659,33 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { data.destY = packet.readFloat(); data.destZ = packet.readFloat(); data.hasDest = true; + + // Remaining waypoints are packed as uint32 deltas from the midpoint + // between the creature's start position and the destination. + // Encoding matches TrinityCore MoveSpline::PackXYZ: + // x = 11-bit signed (bits 0-10), y = 11-bit signed (bits 11-21), + // z = 10-bit signed (bits 22-31), each scaled by 0.25 units. + if (pointCount > 1) { + float midX = (data.x + data.destX) * 0.5f; + float midY = (data.y + data.destY) * 0.5f; + float midZ = (data.z + data.destZ) * 0.5f; + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(4)) break; + uint32_t packed = packet.readUInt32(); + // Sign-extend 11-bit x and y, 10-bit z (2's complement) + int32_t sx = static_cast(packed & 0x7FF); + if (sx & 0x400) sx |= static_cast(0xFFFFF800); + int32_t sy = static_cast((packed >> 11) & 0x7FF); + if (sy & 0x400) sy |= static_cast(0xFFFFF800); + int32_t sz = static_cast((packed >> 22) & 0x3FF); + if (sz & 0x200) sz |= static_cast(0xFFFFFC00); + MonsterMoveData::Point wp; + wp.x = midX - static_cast(sx) * 0.25f; + wp.y = midY - static_cast(sy) * 0.25f; + wp.z = midZ - static_cast(sz) * 0.25f; + data.waypoints.push_back(wp); + } + } } LOG_DEBUG("MonsterMove: guid=0x", std::hex, data.guid, std::dec, @@ -754,12 +783,26 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d data.destZ = packet.readFloat(); data.hasDest = true; - // Remaining waypoints are packed as uint32 deltas. + // Remaining waypoints are packed as uint32 deltas from midpoint. if (pointCount > 1) { - size_t skipBytes = static_cast(pointCount - 1) * 4; - size_t newPos = packet.getReadPos() + skipBytes; - if (newPos > packet.getSize()) return false; - packet.setReadPos(newPos); + float midX = (data.x + data.destX) * 0.5f; + float midY = (data.y + data.destY) * 0.5f; + float midZ = (data.z + data.destZ) * 0.5f; + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(4)) break; + uint32_t packed = packet.readUInt32(); + int32_t sx = static_cast(packed & 0x7FF); + if (sx & 0x400) sx |= static_cast(0xFFFFF800); + int32_t sy = static_cast((packed >> 11) & 0x7FF); + if (sy & 0x400) sy |= static_cast(0xFFFFF800); + int32_t sz = static_cast((packed >> 22) & 0x3FF); + if (sz & 0x200) sz |= static_cast(0xFFFFFC00); + MonsterMoveData::Point wp; + wp.x = midX - static_cast(sx) * 0.25f; + wp.y = midY - static_cast(sy) * 0.25f; + wp.z = midZ - static_cast(sz) * 0.25f; + data.waypoints.push_back(wp); + } } LOG_DEBUG("MonsterMove(turtle): guid=0x", std::hex, data.guid, std::dec,