Fix taxi flights, mounts, and movement recovery

This commit is contained in:
Kelsi 2026-02-08 03:05:38 -08:00
parent d910073d7a
commit 6736ec328b
13 changed files with 607 additions and 49 deletions

View file

@ -70,6 +70,8 @@ public:
const std::vector<std::string>& 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<uint64_t, uint32_t> creatureModelIds_; // guid → loaded modelId
std::unordered_map<uint32_t, uint32_t> 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 {

View file

@ -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<glm::vec3> 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<uint32_t, uint32_t> taxiCostMap_; // destNodeId -> total cost in copper
void buildTaxiCostMap();
void applyTaxiMountForCurrentNode();
void startClientTaxiPath(const std::vector<uint32_t>& pathNodes);
void updateClientTaxi(float deltaTime);
// Vendor
bool vendorWindowOpen = false;

View file

@ -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,

View file

@ -1733,7 +1733,7 @@ public:
/** CMSG_ACTIVATETAXIEXPRESS packet builder */
class ActivateTaxiExpressPacket {
public:
static network::Packet build(uint64_t npcGuid, const std::vector<uint32_t>& pathNodes);
static network::Packet build(uint64_t npcGuid, uint32_t totalCost, const std::vector<uint32_t>& pathNodes);
};
/** CMSG_ACTIVATETAXI packet builder */

View file

@ -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;

View file

@ -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;

View file

@ -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; }

View file

@ -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<char>(::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<uint32_t>(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);
}
}
}
}
}

View file

@ -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<Unit>(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<uint16_t>(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<uint32_t>& 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<uint32_t, std::vector<uint32_t>> 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;
}
}
// ============================================================

View file

@ -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<uint32_t>& pathNodes) {
network::Packet ActivateTaxiExpressPacket::build(uint64_t npcGuid, uint32_t totalCost, const std::vector<uint32_t>& pathNodes) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_ACTIVATETAXIEXPRESS));
packet.writeUInt64(npcGuid);
packet.writeUInt32(0); // totalCost (server recalculates)
packet.writeUInt32(totalCost);
packet.writeUInt32(static_cast<uint32_t>(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;
}

View file

@ -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<float> 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<float> 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;
}

View file

@ -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;

View file

@ -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();
}