From 6736ec328b76feeaf7bc88805b69f4d99a8477bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 8 Feb 2026 03:05:38 -0800 Subject: [PATCH] Fix taxi flights, mounts, and movement recovery --- include/core/application.hpp | 6 + include/game/game_handler.hpp | 19 ++ include/game/opcodes.hpp | 5 + include/game/world_packets.hpp | 2 +- include/rendering/camera_controller.hpp | 5 + include/rendering/renderer.hpp | 3 + include/rendering/terrain_manager.hpp | 1 + src/core/application.cpp | 132 +++++++- src/game/game_handler.cpp | 397 +++++++++++++++++++++++- src/game/world_packets.cpp | 14 +- src/rendering/camera_controller.cpp | 54 ++-- src/rendering/renderer.cpp | 5 +- src/ui/game_screen.cpp | 13 +- 13 files changed, 607 insertions(+), 49 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 9b6532ee..8e50aaba 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -70,6 +70,8 @@ public: const std::vector& getUnderwearPaths() const { return underwearPaths_; } uint32_t getSkinTextureSlotIndex() const { return skinTextureSlotIndex_; } uint32_t getCloakTextureSlotIndex() const { return cloakTextureSlotIndex_; } + uint32_t getGryphonDisplayId() const { return gryphonDisplayId_; } + uint32_t getWyvernDisplayId() const { return wyvernDisplayId_; } private: void update(float deltaTime); @@ -154,6 +156,10 @@ private: std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map displayIdModelCache_; // displayId → modelId (model caching) uint32_t nextCreatureModelId_ = 5000; // Model IDs for online creatures + uint32_t gryphonDisplayId_ = 0; + uint32_t wyvernDisplayId_ = 0; + bool lastTaxiFlight_ = false; + float taxiStreamCooldown_ = 0.0f; // Online gameobject model spawning struct GameObjectInstanceInfo { diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7a61bf8d..77bedb49 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -503,6 +503,8 @@ public: uint32_t mapId = 0; float x = 0, y = 0, z = 0; std::string name; + uint32_t mountDisplayIdAlliance = 0; + uint32_t mountDisplayIdHorde = 0; }; struct TaxiPathEdge { uint32_t pathId = 0; @@ -668,6 +670,7 @@ private: // ---- Teleport handler ---- void handleTeleportAck(network::Packet& packet); + void handleNewWorld(network::Packet& packet); // ---- Speed change handler ---- void handleForceRunSpeedChange(network::Packet& packet); @@ -858,6 +861,7 @@ private: std::string pendingInviterName; uint64_t activeCharacterGuid_ = 0; + Race playerRace_ = Race::HUMAN; // ---- Phase 5: Loot ---- bool lootWindowOpen = false; @@ -904,10 +908,25 @@ private: ShowTaxiNodesData currentTaxiData_; uint64_t taxiNpcGuid_ = 0; bool onTaxiFlight_ = false; + bool taxiMountActive_ = false; + uint32_t taxiMountDisplayId_ = 0; + bool taxiActivatePending_ = false; + float taxiActivateTimer_ = 0.0f; + bool taxiClientActive_ = false; + size_t taxiClientIndex_ = 0; + std::vector taxiClientPath_; + float taxiClientSpeed_ = 32.0f; + float taxiClientSegmentProgress_ = 0.0f; + bool taxiRecoverPending_ = false; + uint32_t taxiRecoverMapId_ = 0; + glm::vec3 taxiRecoverPos_{0.0f}; uint32_t knownTaxiMask_[12] = {}; // Track previously known nodes for discovery alerts bool taxiMaskInitialized_ = false; // First SMSG_SHOWTAXINODES seeds mask without alerts std::unordered_map taxiCostMap_; // destNodeId -> total cost in copper void buildTaxiCostMap(); + void applyTaxiMountForCurrentNode(); + void startClientTaxiPath(const std::vector& pathNodes); + void updateClientTaxi(float deltaTime); // Vendor bool vendorWindowOpen = false; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 3d38f43a..108a1fb2 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -250,6 +250,9 @@ enum class Opcode : uint16_t { // ---- Teleport / Transfer ---- MSG_MOVE_TELEPORT_ACK = 0x0C7, SMSG_TRANSFER_PENDING = 0x003F, + SMSG_NEW_WORLD = 0x003E, + MSG_MOVE_WORLDPORT_ACK = 0x00DC, + SMSG_TRANSFER_ABORTED = 0x0040, // ---- Speed Changes ---- SMSG_FORCE_RUN_SPEED_CHANGE = 0x00E2, @@ -261,6 +264,8 @@ enum class Opcode : uint16_t { // ---- Taxi / Flight Paths ---- SMSG_SHOWTAXINODES = 0x01A9, SMSG_ACTIVATETAXIREPLY = 0x01AE, + // Some cores send activate taxi reply on 0x029D (observed in logs) + SMSG_ACTIVATETAXIREPLY_ALT = 0x029D, SMSG_NEW_TAXI_PATH = 0x01AF, CMSG_ACTIVATETAXIEXPRESS = 0x0312, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 80cd074d..0d2cc5b5 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1733,7 +1733,7 @@ public: /** CMSG_ACTIVATETAXIEXPRESS packet builder */ class ActivateTaxiExpressPacket { public: - static network::Packet build(uint64_t npcGuid, const std::vector& pathNodes); + static network::Packet build(uint64_t npcGuid, uint32_t totalCost, const std::vector& pathNodes); }; /** CMSG_ACTIVATETAXI packet builder */ diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 01e09c60..2a44649d 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -75,6 +75,9 @@ public: void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } + void setExternalFollow(bool enabled) { externalFollow_ = enabled; } + void setExternalMoving(bool moving) { externalMoving_ = moving; } + void clearMovementInputs(); // For first-person player hiding void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { @@ -113,6 +116,7 @@ private: float userTargetDistance = 10.0f; // What the player wants (scroll wheel) float currentDistance = 10.0f; // Smoothed actual distance float collisionDistance = 10.0f; // Max allowed by collision + bool externalFollow_ = false; static constexpr float MIN_DISTANCE = 0.5f; // Minimum zoom (first-person threshold) static constexpr float MAX_DISTANCE = 50.0f; // Maximum zoom out static constexpr float ZOOM_SMOOTH_SPEED = 15.0f; // How fast zoom eases @@ -195,6 +199,7 @@ private: float runSpeedOverride_ = 0.0f; bool mounted_ = false; float mountHeightOffset_ = 0.0f; + bool externalMoving_ = false; // Online mode: trust server position, don't prefer outdoors over WMO floors bool onlineMode = false; diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index ef5653bd..ae3a54cd 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -111,6 +111,7 @@ public: glm::vec3& getCharacterPosition() { return characterPosition; } uint32_t getCharacterInstanceId() const { return characterInstanceId; } float getCharacterYaw() const { return characterYaw; } + void setCharacterYaw(float yawDeg) { characterYaw = yawDeg; } // Emote support void playEmote(const std::string& emoteName); @@ -127,6 +128,7 @@ public: // Mount rendering void setMounted(uint32_t mountInstId, float heightOffset); + void setTaxiFlight(bool onTaxi) { taxiFlight_ = onTaxi; } void clearMount(); bool isMounted() const { return mountInstanceId_ != 0; } @@ -272,6 +274,7 @@ private: // Mount state uint32_t mountInstanceId_ = 0; float mountHeightOffset_ = 0.0f; + bool taxiFlight_ = false; bool terrainEnabled = true; bool terrainLoaded = false; diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 7ef03322..6c6821b4 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -171,6 +171,7 @@ public: void setLoadRadius(int radius) { loadRadius = radius; } void setUnloadRadius(int radius) { unloadRadius = radius; } void setStreamingEnabled(bool enabled) { streamingEnabled = enabled; } + void setUpdateInterval(float seconds) { updateInterval = seconds; } void setWaterRenderer(WaterRenderer* renderer) { waterRenderer = renderer; } void setM2Renderer(M2Renderer* renderer) { m2Renderer = renderer; } void setWMORenderer(WMORenderer* renderer) { wmoRenderer = renderer; } diff --git a/src/core/application.cpp b/src/core/application.cpp index 8be55821..70e816a0 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -303,7 +303,7 @@ void Application::setState(AppState newState) { case AppState::CHARACTER_SELECTION: // Show character screen break; - case AppState::IN_GAME: + case AppState::IN_GAME: { // Wire up movement opcodes from camera controller if (renderer && renderer->getCameraController()) { auto* cc = renderer->getCameraController(); @@ -323,6 +323,7 @@ void Application::setState(AppState newState) { }); } break; + } case AppState::DISCONNECTED: // Back to auth break; @@ -379,7 +380,7 @@ void Application::update(float deltaTime) { } break; - case AppState::IN_GAME: + case AppState::IN_GAME: { if (gameHandler) { gameHandler->update(deltaTime); } @@ -399,16 +400,60 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); } - // Sync character render position → canonical WoW coords each frame - if (renderer && gameHandler) { - glm::vec3 renderPos = renderer->getCharacterPosition(); - glm::vec3 canonical = core::coords::renderToCanonical(renderPos); - gameHandler->setPosition(canonical.x, canonical.y, canonical.z); + bool onTaxi = gameHandler && gameHandler->isOnTaxiFlight(); + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->setExternalFollow(onTaxi); + renderer->getCameraController()->setExternalMoving(onTaxi); + if (lastTaxiFlight_ && !onTaxi) { + renderer->getCameraController()->clearMovementInputs(); + } + } + if (renderer) { + renderer->setTaxiFlight(onTaxi); + } + if (renderer && renderer->getTerrainManager()) { + renderer->getTerrainManager()->setStreamingEnabled(true); + // Freeze new tile streaming during taxi to avoid hangs; still process ready tiles. + if (onTaxi) { + renderer->getTerrainManager()->setUpdateInterval(9999.0f); + taxiStreamCooldown_ = 2.5f; + } else { + // Ramp streaming back in after taxi to avoid end-of-flight hitches. + if (lastTaxiFlight_) { + taxiStreamCooldown_ = 2.5f; + } + if (taxiStreamCooldown_ > 0.0f) { + taxiStreamCooldown_ -= deltaTime; + renderer->getTerrainManager()->setUpdateInterval(1.0f); + } else { + renderer->getTerrainManager()->setUpdateInterval(0.1f); + } + } + } + lastTaxiFlight_ = onTaxi; - // Sync orientation: camera yaw (degrees) → WoW orientation (radians) - float yawDeg = renderer->getCharacterYaw(); - float wowOrientation = glm::radians(yawDeg - 90.0f); - gameHandler->setOrientation(wowOrientation); + // Sync character render position ↔ canonical WoW coords each frame + if (renderer && gameHandler) { + if (onTaxi) { + auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid()); + if (playerEntity) { + glm::vec3 canonical(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer->getCharacterPosition() = renderPos; + // Lock yaw to server/taxi orientation so mouse cannot steer during taxi. + float yawDeg = glm::degrees(playerEntity->getOrientation()) + 90.0f; + renderer->setCharacterYaw(yawDeg); + } + } else { + glm::vec3 renderPos = renderer->getCharacterPosition(); + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); + gameHandler->setPosition(canonical.x, canonical.y, canonical.z); + + // Sync orientation: camera yaw (degrees) → WoW orientation (radians) + float yawDeg = renderer->getCharacterYaw(); + float wowOrientation = glm::radians(yawDeg - 90.0f); + gameHandler->setOrientation(wowOrientation); + } } // Send movement heartbeat every 500ms (keeps server position in sync) @@ -422,6 +467,7 @@ void Application::update(float deltaTime) { movementHeartbeatTimer = 0.0f; } break; + } case AppState::DISCONNECTED: // Handle disconnection @@ -1661,6 +1707,46 @@ void Application::buildCreatureDisplayLookups() { LOG_INFO("Loaded ", modelIdToPath_.size(), " model→path mappings"); } + // Resolve gryphon/wyvern display IDs by exact model path so taxi mounts have textures. + auto toLower = [](std::string s) { + for (char& c : s) c = static_cast(::tolower(c)); + return s; + }; + auto normalizePath = [&](const std::string& p) { + std::string s = p; + for (char& c : s) if (c == '/') c = '\\'; + return toLower(s); + }; + auto resolveDisplayIdForExactPath = [&](const std::string& exactPath) -> uint32_t { + const std::string target = normalizePath(exactPath); + uint32_t modelId = 0; + for (const auto& [mid, path] : modelIdToPath_) { + if (normalizePath(path) == target) { + modelId = mid; + break; + } + } + if (modelId == 0) return 0; + uint32_t bestDisplayId = 0; + int bestScore = -1; + for (const auto& [dispId, data] : displayDataMap_) { + if (data.modelId != modelId) continue; + int score = 0; + if (!data.skin1.empty()) score += 3; + if (!data.skin2.empty()) score += 2; + if (!data.skin3.empty()) score += 1; + if (score > bestScore) { + bestScore = score; + bestDisplayId = dispId; + } + } + return bestDisplayId; + }; + + gryphonDisplayId_ = resolveDisplayIdForExactPath("Creature\\Gryphon\\Gryphon.m2"); + wyvernDisplayId_ = resolveDisplayIdForExactPath("Creature\\Wyvern\\Wyvern.m2"); + LOG_INFO("Taxi mount displayIds: gryphon=", gryphonDisplayId_, " wyvern=", wyvernDisplayId_); + // CharHairGeosets.dbc: maps (race, sex, hairStyleId) → skinSectionId for hair mesh // Col 0: ID, Col 1: RaceID, Col 2: SexID, Col 3: VariationID, Col 4: GeosetID, Col 5: Showscalp if (auto chg = assetManager->loadDBC("CharHairGeosets.dbc"); chg && chg->isLoaded()) { @@ -1705,8 +1791,19 @@ void Application::buildCreatureDisplayLookups() { } std::string Application::getModelPathForDisplayId(uint32_t displayId) const { + if (displayId == 30412) return "Creature\\Gryphon\\Gryphon.m2"; + if (displayId == 30413) return "Creature\\Wyvern\\Wyvern.m2"; auto itData = displayDataMap_.find(displayId); - if (itData == displayDataMap_.end()) return ""; + if (itData == displayDataMap_.end()) { + // Some sources (e.g., taxi nodes) may provide a modelId directly. + auto itPath = modelIdToPath_.find(displayId); + if (itPath != modelIdToPath_.end()) { + return itPath->second; + } + if (displayId == 30412) return "Creature\\Gryphon\\Gryphon.m2"; + if (displayId == 30413) return "Creature\\Wyvern\\Wyvern.m2"; + return ""; + } auto itPath = modelIdToPath_.find(itData->second.modelId); if (itPath == modelIdToPath_.end()) return ""; @@ -2507,6 +2604,7 @@ void Application::processPendingMount() { if (lastSlash != std::string::npos) { modelDir = m2Path.substr(0, lastSlash + 1); } + int replaced = 0; for (size_t ti = 0; ti < md->textures.size(); ti++) { const auto& tex = md->textures[ti]; std::string texPath; @@ -2521,9 +2619,19 @@ void Application::processPendingMount() { GLuint skinTex = charRenderer->loadTexture(texPath); if (skinTex != 0) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); + replaced++; } } } + // Some mounts (gryphon/wyvern) use empty model textures; force skin1 onto slot 0. + if (replaced == 0 && !dispData.skin1.empty() && !md->textures.empty()) { + std::string texPath = modelDir + dispData.skin1 + ".blp"; + GLuint skinTex = charRenderer->loadTexture(texPath); + if (skinTex != 0) { + charRenderer->setModelTexture(modelId, 0, skinTex); + LOG_INFO("Forced mount skin1 texture on slot 0: ", texPath); + } + } } } } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e0316776..5cb93810 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -30,12 +30,13 @@ GameHandler::GameHandler() { // Default spells always available knownSpells.push_back(6603); // Attack + knownSpells.push_back(8690); // Hearthstone // Default action bar layout actionBar[0].type = ActionBarSlot::SPELL; actionBar[0].id = 6603; // Attack in slot 1 - actionBar[11].type = ActionBarSlot::ITEM; - actionBar[11].id = 6948; // Hearthstone item in slot 12 + actionBar[11].type = ActionBarSlot::SPELL; + actionBar[11].id = 8690; // Hearthstone in slot 12 } GameHandler::~GameHandler() { @@ -96,6 +97,11 @@ bool GameHandler::connect(const std::string& host, } void GameHandler::disconnect() { + if (onTaxiFlight_) { + taxiRecoverPending_ = true; + } else { + taxiRecoverPending_ = false; + } if (socket) { socket->disconnect(); socket.reset(); @@ -178,16 +184,84 @@ void GameHandler::update(float deltaTime) { // Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared if (onTaxiFlight_) { + updateClientTaxi(deltaTime); + if (!taxiMountActive_ && !taxiClientActive_ && taxiClientPath_.empty()) { + onTaxiFlight_ = false; + LOG_INFO("Cleared stale taxi state in update"); + } auto playerEntity = entityManager.getEntity(playerGuid); if (playerEntity && playerEntity->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(playerEntity); if ((unit->getUnitFlags() & 0x00000100) == 0) { onTaxiFlight_ = false; + if (taxiMountActive_ && mountCallback_) { + mountCallback_(0); + } + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + currentMountDisplayId_ = 0; + taxiClientActive_ = false; + taxiClientPath_.clear(); + taxiRecoverPending_ = false; + movementInfo.flags = 0; + movementInfo.flags2 = 0; + if (socket) { + sendMovement(Opcode::CMSG_MOVE_STOP); + sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); + } LOG_INFO("Taxi flight landed"); } } } + // Safety: if taxi flight ended but mount is still active, force dismount. + if (!onTaxiFlight_ && taxiMountActive_) { + if (mountCallback_) mountCallback_(0); + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + currentMountDisplayId_ = 0; + movementInfo.flags = 0; + movementInfo.flags2 = 0; + if (socket) { + sendMovement(Opcode::CMSG_MOVE_STOP); + sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); + } + LOG_INFO("Taxi dismount cleanup"); + } + + if (taxiRecoverPending_ && state == WorldState::IN_WORLD) { + auto playerEntity = entityManager.getEntity(playerGuid); + if (playerEntity) { + playerEntity->setPosition(taxiRecoverPos_.x, taxiRecoverPos_.y, + taxiRecoverPos_.z, movementInfo.orientation); + movementInfo.x = taxiRecoverPos_.x; + movementInfo.y = taxiRecoverPos_.y; + movementInfo.z = taxiRecoverPos_.z; + if (socket) { + sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); + } + taxiRecoverPending_ = false; + LOG_INFO("Taxi recovery applied"); + } + } + + if (taxiActivatePending_) { + taxiActivateTimer_ += deltaTime; + if (!onTaxiFlight_ && taxiActivateTimer_ > 5.0f) { + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + if (taxiMountActive_ && mountCallback_) { + mountCallback_(0); + } + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + taxiClientActive_ = false; + taxiClientPath_.clear(); + onTaxiFlight_ = false; + LOG_WARNING("Taxi activation timed out"); + } + } + // Leave combat if auto-attack target is too far away (leash range) if (autoAttacking && autoAttackTarget != 0) { auto targetEntity = entityManager.getEntity(autoAttackTarget); @@ -744,12 +818,23 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_NEW_WORLD: + handleNewWorld(packet); + break; + case Opcode::SMSG_TRANSFER_ABORTED: { + uint32_t mapId = packet.readUInt32(); + uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; + LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason); + addSystemChatMessage("Transfer aborted."); + break; + } // ---- Taxi / Flight Paths ---- case Opcode::SMSG_SHOWTAXINODES: handleShowTaxiNodes(packet); break; case Opcode::SMSG_ACTIVATETAXIREPLY: + case Opcode::SMSG_ACTIVATETAXIREPLY_ALT: handleActivateTaxiReply(packet); break; case Opcode::SMSG_NEW_TAXI_PATH: @@ -1086,6 +1171,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { LOG_INFO("Level ", (int)character.level, " ", getRaceName(character.race), " ", getClassName(character.characterClass)); + playerRace_ = character.race; break; } } @@ -1188,6 +1274,20 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { if (worldEntryCallback_) { worldEntryCallback_(data.mapId, data.x, data.y, data.z); } + + // If we disconnected mid-taxi, attempt to recover to destination after login. + if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) { + float dx = movementInfo.x - taxiRecoverPos_.x; + float dy = movementInfo.y - taxiRecoverPos_.y; + float dz = movementInfo.z - taxiRecoverPos_.z; + float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > 5.0f) { + // Keep pending until player entity exists; update() will apply. + LOG_INFO("Taxi recovery pending: dist=", dist); + } else { + taxiRecoverPending_ = false; + } + } } void GameHandler::handleAccountDataTimes(network::Packet& packet) { @@ -1265,7 +1365,15 @@ void GameHandler::sendMovement(Opcode opcode) { } // Block movement during taxi flight - if (onTaxiFlight_) return; + if (onTaxiFlight_) { + // If taxi visuals are already gone, clear taxi state to avoid stuck movement. + if (!taxiMountActive_ && !taxiClientActive_ && taxiClientPath_.empty()) { + onTaxiFlight_ = false; + LOG_INFO("Cleared stale taxi state in sendMovement"); + } else { + return; + } + } if (resurrectPending_) return; // Use real millisecond timestamp (server validates for anti-cheat) @@ -1497,6 +1605,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { default: break; } } + if (block.guid == playerGuid) { + constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; + if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_) { + onTaxiFlight_ = true; + applyTaxiMountForCurrentNode(); + } + } if (block.guid == playerGuid && (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { playerDead_ = true; @@ -1776,6 +1891,12 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); entity->setPosition(pos.x, pos.y, pos.z, block.orientation); LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); + if (block.guid == playerGuid) { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + movementInfo.orientation = block.orientation; + } // Fire transport move callback if this is a known transport if (transportGuids_.count(block.guid) && transportMoveCallback_) { @@ -3746,6 +3867,12 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { if (casting) return; // Already casting + // Hearthstone is item-bound; use the item rather than direct spell cast. + if (spellId == 8690) { + useItemById(6948); + return; + } + uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid; auto packet = CastSpellPacket::build(spellId, target, ++castCount); socket->send(packet); @@ -3787,10 +3914,13 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { knownSpells = data.spellIds; - // Ensure Attack (6603) is always present + // Ensure Attack (6603) and Hearthstone (8690) are always present if (std::find(knownSpells.begin(), knownSpells.end(), 6603u) == knownSpells.end()) { knownSpells.insert(knownSpells.begin(), 6603u); } + if (std::find(knownSpells.begin(), knownSpells.end(), 8690u) == knownSpells.end()) { + knownSpells.push_back(8690u); + } // Set initial cooldowns for (const auto& cd : data.cooldowns) { @@ -3799,11 +3929,11 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { } } - // Load saved action bar or use defaults (Attack slot 1, Hearthstone item slot 12) + // Load saved action bar or use defaults (Attack slot 1, Hearthstone slot 12) actionBar[0].type = ActionBarSlot::SPELL; actionBar[0].id = 6603; // Attack - actionBar[11].type = ActionBarSlot::ITEM; - actionBar[11].id = 6948; // Hearthstone item + actionBar[11].type = ActionBarSlot::SPELL; + actionBar[11].id = 8690; // Hearthstone loadCharacterConfig(); LOG_INFO("Learned ", knownSpells.size(), " spells"); @@ -4541,6 +4671,54 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { } } +void GameHandler::handleNewWorld(network::Packet& packet) { + // SMSG_NEW_WORLD: uint32 mapId, float x, y, z, orientation + if (packet.getSize() - packet.getReadPos() < 20) { + LOG_WARNING("SMSG_NEW_WORLD too short"); + return; + } + + uint32_t mapId = packet.readUInt32(); + float serverX = packet.readFloat(); + float serverY = packet.readFloat(); + float serverZ = packet.readFloat(); + float orientation = packet.readFloat(); + + LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId, + " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", + " orient=", orientation); + + currentMapId_ = mapId; + + // Update player position + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); + movementInfo.x = canonical.x; + movementInfo.y = canonical.y; + movementInfo.z = canonical.z; + movementInfo.orientation = orientation; + movementInfo.flags = 0; + + // Clear world state for the new map + entityManager.clear(); + hostileAttackers_.clear(); + stopAutoAttack(); + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + + // Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready + if (socket) { + network::Packet ack(static_cast(Opcode::MSG_MOVE_WORLDPORT_ACK)); + socket->send(ack); + LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK"); + } + + // Reload terrain at new position + if (worldEntryCallback_) { + worldEntryCallback_(mapId, serverX, serverY, serverZ); + } +} + // ============================================================ // Taxi / Flight Path Handlers // ============================================================ @@ -4555,6 +4733,7 @@ void GameHandler::loadTaxiDbc() { // Load TaxiNodes.dbc: 0=ID, 1=mapId, 2=x, 3=y, 4=z, 5=name(enUS locale) auto nodesDbc = am->loadDBC("TaxiNodes.dbc"); if (nodesDbc && nodesDbc->isLoaded()) { + uint32_t fieldCount = nodesDbc->getFieldCount(); for (uint32_t i = 0; i < nodesDbc->getRecordCount(); i++) { TaxiNode node; node.id = nodesDbc->getUInt32(i, 0); @@ -4563,9 +4742,25 @@ void GameHandler::loadTaxiDbc() { node.y = nodesDbc->getFloat(i, 3); node.z = nodesDbc->getFloat(i, 4); node.name = nodesDbc->getString(i, 5); + // TaxiNodes.dbc (3.3.5a): last two fields are mount display IDs (Alliance, Horde) + if (fieldCount >= 24) { + node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, 22); + node.mountDisplayIdHorde = nodesDbc->getUInt32(i, 23); + if (node.mountDisplayIdAlliance == 0 && node.mountDisplayIdHorde == 0 && fieldCount >= 22) { + node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, 20); + node.mountDisplayIdHorde = nodesDbc->getUInt32(i, 21); + } + } if (node.id > 0) { taxiNodes_[node.id] = std::move(node); } + if (node.id == 195) { + std::string fields; + for (uint32_t f = 0; f < fieldCount; f++) { + fields += std::to_string(f) + ":" + std::to_string(nodesDbc->getUInt32(i, f)) + " "; + } + LOG_INFO("TaxiNodes[195] fields: ", fields); + } } LOG_INFO("Loaded ", taxiNodes_.size(), " taxi nodes from TaxiNodes.dbc"); } else { @@ -4626,9 +4821,146 @@ void GameHandler::handleShowTaxiNodes(network::Packet& packet) { taxiWindowOpen_ = true; gossipWindowOpen = false; buildTaxiCostMap(); + auto it = taxiNodes_.find(data.nearestNode); + if (it != taxiNodes_.end()) { + LOG_INFO("Taxi node ", data.nearestNode, " mounts: A=", it->second.mountDisplayIdAlliance, + " H=", it->second.mountDisplayIdHorde); + } LOG_INFO("Taxi window opened, nearest node=", data.nearestNode); } +void GameHandler::applyTaxiMountForCurrentNode() { + if (taxiMountActive_ || !mountCallback_) return; + auto it = taxiNodes_.find(currentTaxiData_.nearestNode); + if (it == taxiNodes_.end()) return; + + bool isAlliance = true; + switch (playerRace_) { + case Race::ORC: + case Race::UNDEAD: + case Race::TAUREN: + case Race::TROLL: + case Race::GOBLIN: + case Race::BLOOD_ELF: + isAlliance = false; + break; + default: + isAlliance = true; + break; + } + uint32_t mountId = isAlliance ? it->second.mountDisplayIdAlliance + : it->second.mountDisplayIdHorde; + if (mountId == 0) { + mountId = isAlliance ? it->second.mountDisplayIdHorde + : it->second.mountDisplayIdAlliance; + } + if (mountId == 0) { + auto& app = core::Application::getInstance(); + uint32_t gryphonId = app.getGryphonDisplayId(); + uint32_t wyvernId = app.getWyvernDisplayId(); + if (isAlliance && gryphonId != 0) mountId = gryphonId; + if (!isAlliance && wyvernId != 0) mountId = wyvernId; + if (mountId == 0) { + mountId = (isAlliance ? wyvernId : gryphonId); + } + } + if (mountId == 0) { + // Fallback: any non-zero mount display from the node. + if (it->second.mountDisplayIdAlliance != 0) mountId = it->second.mountDisplayIdAlliance; + else if (it->second.mountDisplayIdHorde != 0) mountId = it->second.mountDisplayIdHorde; + } + if (mountId == 0 || mountId == 541) { + mountId = isAlliance ? 30412u : 30413u; + } + if (mountId != 0) { + taxiMountDisplayId_ = mountId; + taxiMountActive_ = true; + LOG_INFO("Taxi mount apply: displayId=", mountId); + mountCallback_(mountId); + } +} + +void GameHandler::startClientTaxiPath(const std::vector& pathNodes) { + taxiClientPath_.clear(); + taxiClientIndex_ = 0; + taxiClientActive_ = false; + taxiClientSegmentProgress_ = 0.0f; + + for (uint32_t nodeId : pathNodes) { + auto it = taxiNodes_.find(nodeId); + if (it == taxiNodes_.end()) continue; + glm::vec3 serverPos(it->second.x, it->second.y, it->second.z); + glm::vec3 canonical = core::coords::serverToCanonical(serverPos); + taxiClientPath_.push_back(canonical); + } + if (taxiClientPath_.size() < 2) return; + + taxiClientActive_ = true; +} + +void GameHandler::updateClientTaxi(float deltaTime) { + if (!taxiClientActive_ || taxiClientPath_.size() < 2) return; + if (!entityManager.hasEntity(playerGuid)) return; + auto playerEntity = entityManager.getEntity(playerGuid); + if (!playerEntity) return; + + if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) { + taxiClientActive_ = false; + return; + } + + glm::vec3 start = taxiClientPath_[taxiClientIndex_]; + glm::vec3 end = taxiClientPath_[taxiClientIndex_ + 1]; + glm::vec3 dir = end - start; + float segmentLen = glm::length(dir); + if (segmentLen < 0.01f) { + taxiClientIndex_++; + taxiClientSegmentProgress_ = 0.0f; + return; + } + + taxiClientSegmentProgress_ += taxiClientSpeed_ * deltaTime; + float t = taxiClientSegmentProgress_ / segmentLen; + if (t >= 1.0f) { + taxiClientIndex_++; + taxiClientSegmentProgress_ = 0.0f; + if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) { + taxiClientActive_ = false; + onTaxiFlight_ = false; + if (taxiMountActive_ && mountCallback_) { + mountCallback_(0); + } + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + taxiClientPath_.clear(); + taxiRecoverPending_ = false; + movementInfo.flags = 0; + movementInfo.flags2 = 0; + if (socket) { + sendMovement(Opcode::CMSG_MOVE_STOP); + sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); + } + LOG_INFO("Taxi flight landed (client path)"); + } + return; + } + + glm::vec3 dirNorm = dir / segmentLen; + glm::vec3 nextPos = start + dirNorm * (t * segmentLen); + + // Add a flight arc to avoid terrain collisions. + float arcHeight = std::clamp(segmentLen * 0.15f, 20.0f, 120.0f); + float arc = 4.0f * t * (1.0f - t); + nextPos.z = glm::mix(start.z, end.z, t) + arcHeight * arc; + + float orientation = std::atan2(dir.y, dir.x) - 1.57079632679f; + playerEntity->setPosition(nextPos.x, nextPos.y, nextPos.z, orientation); + movementInfo.x = nextPos.x; + movementInfo.y = nextPos.y; + movementInfo.z = nextPos.z; + movementInfo.orientation = orientation; +} + void GameHandler::handleActivateTaxiReply(network::Packet& packet) { ActivateTaxiReplyData data; if (!ActivateTaxiReplyParser::parse(packet, data)) { @@ -4639,10 +4971,23 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { if (data.result == 0) { onTaxiFlight_ = true; taxiWindowOpen_ = false; + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + applyTaxiMountForCurrentNode(); LOG_INFO("Taxi flight started!"); } else { LOG_WARNING("Taxi activation failed, result=", data.result); addSystemChatMessage("Cannot take that flight path."); + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + if (taxiMountActive_ && mountCallback_) { + mountCallback_(0); + } + taxiMountActive_ = false; + taxiMountDisplayId_ = 0; + onTaxiFlight_ = false; } } @@ -4690,6 +5035,16 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { uint32_t startNode = currentTaxiData_.nearestNode; if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return; + // If already mounted, dismount before starting a taxi flight. + if (isMounted()) { + LOG_INFO("Taxi activate: dismounting current mount"); + if (mountCallback_) mountCallback_(0); + currentMountDisplayId_ = 0; + dismount(); + } + + addSystemChatMessage("Taxi: requesting flight..."); + // BFS to find path from startNode to destNodeId std::unordered_map> adj; for (const auto& edge : taxiPathEdges_) { @@ -4740,12 +5095,34 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { LOG_INFO("Taxi path nodes: ", pathStr); } - auto pkt = ActivateTaxiExpressPacket::build(taxiNpcGuid_, path); - socket->send(pkt); + uint32_t totalCost = getTaxiCostTo(destNodeId); + LOG_INFO("Taxi activate: start=", startNode, " dest=", destNodeId, " cost=", totalCost); - // Fallback: some servers expect basic CMSG_ACTIVATETAXI. + // Some servers only accept basic CMSG_ACTIVATETAXI. 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); + + // Optimistically start taxi visuals; server will correct if it denies. + taxiActivatePending_ = true; + taxiActivateTimer_ = 0.0f; + if (!onTaxiFlight_) { + onTaxiFlight_ = true; + applyTaxiMountForCurrentNode(); + } + startClientTaxiPath(path); + + // Save recovery target in case of disconnect during taxi. + auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end()) { + taxiRecoverMapId_ = destIt->second.mapId; + taxiRecoverPos_ = core::coords::serverToCanonical( + glm::vec3(destIt->second.x, destIt->second.y, destIt->second.z)); + taxiRecoverPending_ = false; + } } // ============================================================ diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index db84065d..a1833dd2 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2667,25 +2667,29 @@ bool ShowTaxiNodesParser::parse(network::Packet& packet, ShowTaxiNodesData& data } bool ActivateTaxiReplyParser::parse(network::Packet& packet, ActivateTaxiReplyData& data) { - if (packet.getSize() - packet.getReadPos() < 4) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining >= 4) { + data.result = packet.readUInt32(); + } else if (remaining >= 1) { + data.result = packet.readUInt8(); + } else { LOG_ERROR("ActivateTaxiReplyParser: packet too short"); return false; } - data.result = packet.readUInt32(); LOG_INFO("ActivateTaxiReply: result=", data.result); return true; } -network::Packet ActivateTaxiExpressPacket::build(uint64_t npcGuid, const std::vector& pathNodes) { +network::Packet ActivateTaxiExpressPacket::build(uint64_t npcGuid, uint32_t totalCost, const std::vector& pathNodes) { network::Packet packet(static_cast(Opcode::CMSG_ACTIVATETAXIEXPRESS)); packet.writeUInt64(npcGuid); - packet.writeUInt32(0); // totalCost (server recalculates) + packet.writeUInt32(totalCost); packet.writeUInt32(static_cast(pathNodes.size())); for (uint32_t nodeId : pathNodes) { packet.writeUInt32(nodeId); } LOG_INFO("ActivateTaxiExpress: npc=0x", std::hex, npcGuid, std::dec, - " nodes=", pathNodes.size()); + " cost=", totalCost, " nodes=", pathNodes.size()); return packet; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 3cd9f840..9d075725 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -266,27 +266,30 @@ void CameraController::update(float deltaTime) { if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera glm::vec3 targetPos = *followTarget; - if (wmoRenderer) { - wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); - } - if (m2Renderer) { - m2Renderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); + if (!externalFollow_) { + if (wmoRenderer) { + wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); + } + if (m2Renderer) { + m2Renderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); + } } - // Check for water at current position — simple submersion test. - // If the player's feet are meaningfully below the water surface, swim. - std::optional waterH; - if (waterRenderer) { - waterH = waterRenderer->getWaterHeightAt(targetPos.x, targetPos.y); - } - bool inWater = waterH && (targetPos.z < (*waterH - 0.3f)); - // Keep swimming through water-data gaps (chunk boundaries). - if (!inWater && swimming && !waterH) { - inWater = true; - } + if (!externalFollow_) { + // Check for water at current position — simple submersion test. + // If the player's feet are meaningfully below the water surface, swim. + std::optional waterH; + if (waterRenderer) { + waterH = waterRenderer->getWaterHeightAt(targetPos.x, targetPos.y); + } + bool inWater = waterH && (targetPos.z < (*waterH - 0.3f)); + // Keep swimming through water-data gaps (chunk boundaries). + if (!inWater && swimming && !waterH) { + inWater = true; + } - if (inWater) { + if (inWater) { swimming = true; // Swim movement follows look pitch (forward/back), while strafe stays // lateral for stable control. @@ -406,7 +409,7 @@ void CameraController::update(float deltaTime) { } grounded = false; - } else { + } else { // Exiting water — give a small upward boost to help climb onto shore. swimming = false; @@ -433,6 +436,12 @@ void CameraController::update(float deltaTime) { // Apply gravity verticalVelocity += gravity * deltaTime; targetPos.z += verticalVelocity * deltaTime; + } + } else { + // External follow (e.g., taxi): trust server position without grounding. + swimming = false; + grounded = true; + verticalVelocity = 0.0f; } // Sweep collisions in small steps to reduce tunneling through thin walls/floors. @@ -1286,9 +1295,18 @@ bool CameraController::isMoving() const { if (!enabled || !camera) { return false; } + if (externalMoving_) return true; return moveForwardActive || moveBackwardActive || strafeLeftActive || strafeRightActive || autoRunning; } +void CameraController::clearMovementInputs() { + moveForwardActive = false; + moveBackwardActive = false; + strafeLeftActive = false; + strafeRightActive = false; + autoRunning = false; +} + bool CameraController::isSprinting() const { return enabled && camera && runPace; } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 422fcdc8..6d475e37 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -694,7 +694,8 @@ void Renderer::updateCharacterAnimation() { if (moving && haveMountState && curMountDur > 1.0f) { float norm = std::fmod(curMountTime, curMountDur) / curMountDur; // One bounce per stride cycle - mountBob = std::sin(norm * 2.0f * 3.14159f) * 0.12f; + float bobSpeed = taxiFlight_ ? 2.0f : 1.0f; + mountBob = std::sin(norm * 2.0f * 3.14159f * bobSpeed) * 0.12f; } } @@ -1190,7 +1191,7 @@ void Renderer::update(float deltaTime) { cameraController && cameraController->isThirdPerson() && cameraController->isGrounded() && !cameraController->isSwimming(); - if (canPlayFootsteps && isMounted() && mountInstanceId_ > 0) { + if (canPlayFootsteps && isMounted() && mountInstanceId_ > 0 && !taxiFlight_) { // Mount footsteps: use mount's animation for timing uint32_t animId = 0; float animTimeMs = 0.0f, animDurationMs = 0.0f; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d75abdc8..c2d5e652 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3624,8 +3624,15 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(0); bool isSelected = (selectedNodeId == nodeId); - if (ImGui::Selectable(node.name.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns)) { + if (ImGui::Selectable(node.name.c_str(), isSelected, + ImGuiSelectableFlags_SpanAllColumns | + ImGuiSelectableFlags_AllowDoubleClick)) { selectedNodeId = nodeId; + LOG_INFO("Taxi UI: Selected dest=", nodeId); + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + LOG_INFO("Taxi UI: Double-click activate dest=", nodeId); + gameHandler.activateTaxi(nodeId); + } } ImGui::TableSetColumnIndex(1); @@ -3656,6 +3663,10 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::Separator(); + if (selectedNodeId != 0 && ImGui::Button("Fly Selected", ImVec2(-1, 0))) { + LOG_INFO("Taxi UI: Fly Selected dest=", selectedNodeId); + gameHandler.activateTaxi(selectedNodeId); + } if (ImGui::Button("Close", ImVec2(-1, 0))) { gameHandler.closeTaxi(); }