Add mount system and crash mouse-release handler

Render mount M2 model under player with seated animation, apply creature
skin textures, server-driven speed via SMSG_FORCE_RUN_SPEED_CHANGE, and
/dismount command. X11 XUngrabPointer on crash/hang to always release mouse.
This commit is contained in:
Kelsi 2026-02-07 17:59:40 -08:00
parent 4a932dd8cd
commit 643611ee79
13 changed files with 363 additions and 3 deletions

View file

@ -272,6 +272,9 @@ if (FFMPEG_LIBRARY_DIRS)
endif()
# Platform-specific libraries
if(UNIX AND NOT APPLE)
target_link_libraries(wowee PRIVATE X11)
endif()
if(WIN32)
target_link_libraries(wowee PRIVATE ws2_32)
# SDL2main provides WinMain entry point on Windows

View file

@ -150,6 +150,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
// Mount model tracking
uint32_t mountInstanceId_ = 0;
uint32_t mountModelId_ = 0;
bool creatureLookupsBuilt_ = false;
// Deferred creature spawn queue (throttles spawning to avoid hangs)

View file

@ -189,6 +189,10 @@ public:
uint32_t getDisplayId() const { return displayId; }
void setDisplayId(uint32_t id) { displayId = id; }
// Mount display ID (UNIT_FIELD_MOUNTDISPLAYID, index 69)
uint32_t getMountDisplayId() const { return mountDisplayId; }
void setMountDisplayId(uint32_t id) { mountDisplayId = id; }
// Unit flags (UNIT_FIELD_FLAGS, index 59)
uint32_t getUnitFlags() const { return unitFlags; }
void setUnitFlags(uint32_t f) { unitFlags = f; }
@ -216,6 +220,7 @@ protected:
uint32_t level = 1;
uint32_t entry = 0;
uint32_t displayId = 0;
uint32_t mountDisplayId = 0;
uint32_t unitFlags = 0;
uint32_t npcFlags = 0;
uint32_t factionTemplate = 0;

View file

@ -455,6 +455,13 @@ public:
}
const std::unordered_map<uint64_t, QuestGiverStatus>& getNpcQuestStatuses() const { return npcQuestStatus_; }
// Mount state
using MountCallback = std::function<void(uint32_t mountDisplayId)>; // 0 = dismount
void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); }
bool isMounted() const { return currentMountDisplayId_ != 0; }
float getServerRunSpeed() const { return serverRunSpeed_; }
void dismount();
// Taxi / Flight Paths
bool isTaxiWindowOpen() const { return taxiWindowOpen_; }
void closeTaxi();
@ -630,6 +637,9 @@ private:
// ---- Teleport handler ----
void handleTeleportAck(network::Packet& packet);
// ---- Speed change handler ----
void handleForceRunSpeedChange(network::Packet& packet);
// ---- Taxi handlers ----
void handleShowTaxiNodes(network::Packet& packet);
void handleActivateTaxiReply(network::Packet& packet);
@ -844,6 +854,8 @@ private:
ShowTaxiNodesData currentTaxiData_;
uint64_t taxiNpcGuid_ = 0;
bool onTaxiFlight_ = false;
uint32_t knownTaxiMask_[12] = {}; // Track previously known nodes for discovery alerts
bool taxiMaskInitialized_ = false; // First SMSG_SHOWTAXINODES seeds mask without alerts
// Vendor
bool vendorWindowOpen = false;
@ -877,6 +889,9 @@ private:
NpcRespawnCallback npcRespawnCallback_;
MeleeSwingCallback meleeSwingCallback_;
NpcSwingCallback npcSwingCallback_;
MountCallback mountCallback_;
uint32_t currentMountDisplayId_ = 0;
float serverRunSpeed_ = 7.0f;
bool playerDead_ = false;
};

View file

@ -241,9 +241,16 @@ enum class Opcode : uint16_t {
MSG_MOVE_TELEPORT_ACK = 0x0C7,
SMSG_TRANSFER_PENDING = 0x003F,
// ---- Speed Changes ----
SMSG_FORCE_RUN_SPEED_CHANGE = 0x00E2,
// ---- Mount ----
CMSG_CANCEL_MOUNT_AURA = 0x0375,
// ---- Taxi / Flight Paths ----
SMSG_SHOWTAXINODES = 0x01A9,
SMSG_ACTIVATETAXIREPLY = 0x01AE,
SMSG_NEW_TAXI_PATH = 0x01AF,
CMSG_ACTIVATETAXIEXPRESS = 0x0312,
};

View file

@ -72,6 +72,8 @@ public:
using MovementCallback = std::function<void(uint32_t opcode)>;
void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); }
void setUseWoWSpeed(bool use) { useWoWSpeed = use; }
void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; }
void setMounted(bool m) { mounted_ = m; }
// For first-person player hiding
void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) {
@ -188,6 +190,10 @@ private:
static constexpr float WOW_GRAVITY = -19.29f;
static constexpr float WOW_JUMP_VELOCITY = 7.96f;
// Server-driven run speed override (0 = use default WOW_RUN_SPEED)
float runSpeedOverride_ = 0.0f;
bool mounted_ = false;
// Online mode: trust server position, don't prefer outdoors over WMO floors
bool onlineMode = false;

View file

@ -125,6 +125,11 @@ public:
void triggerMeleeSwing();
void setEquippedWeaponType(uint32_t inventoryType) { equippedWeaponInvType_ = inventoryType; meleeAnimId = 0; }
// Mount rendering
void setMounted(uint32_t mountInstId, float heightOffset);
void clearMount();
bool isMounted() const { return mountInstanceId_ != 0; }
// Selection circle for targeted entity
void setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color);
void clearSelectionCircle();
@ -214,7 +219,7 @@ private:
float characterYaw = 0.0f;
// Character animation state
enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING };
enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT };
CharAnimState charAnimState = CharAnimState::IDLE;
void updateCharacterAnimation();
bool isFootstepAnimationState() const;
@ -259,6 +264,10 @@ private:
uint32_t meleeAnimId = 0;
uint32_t equippedWeaponInvType_ = 0;
// Mount state
uint32_t mountInstanceId_ = 0;
float mountHeightOffset_ = 0.0f;
bool terrainEnabled = true;
bool terrainLoaded = false;

View file

@ -389,6 +389,11 @@ void Application::update(float deltaTime) {
npcManager->update(deltaTime, renderer->getCharacterRenderer());
}
// Sync server run speed to camera controller
if (renderer && gameHandler && renderer->getCameraController()) {
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
}
// Sync character render position → canonical WoW coords each frame
if (renderer && gameHandler) {
glm::vec3 renderPos = renderer->getCharacterPosition();
@ -554,6 +559,150 @@ void Application::setupUICallbacks() {
despawnOnlineCreature(guid);
});
// Mount callback (online mode) - load/destroy mount model
gameHandler->setMountCallback([this](uint32_t mountDisplayId) {
if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return;
auto* charRenderer = renderer->getCharacterRenderer();
if (mountDisplayId == 0) {
// Dismount: remove mount instance and model
if (mountInstanceId_ != 0) {
charRenderer->removeInstance(mountInstanceId_);
mountInstanceId_ = 0;
}
mountModelId_ = 0;
renderer->clearMount();
LOG_INFO("Dismounted");
return;
}
// Mount: load mount model
std::string m2Path = getModelPathForDisplayId(mountDisplayId);
if (m2Path.empty()) {
LOG_WARNING("No model path for mount displayId ", mountDisplayId);
return;
}
// Check model cache
uint32_t modelId = 0;
bool modelCached = false;
auto cacheIt = displayIdModelCache_.find(mountDisplayId);
if (cacheIt != displayIdModelCache_.end()) {
modelId = cacheIt->second;
modelCached = true;
} else {
modelId = nextCreatureModelId_++;
auto m2Data = assetManager->readFile(m2Path);
if (m2Data.empty()) {
LOG_WARNING("Failed to read mount M2: ", m2Path);
return;
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (model.vertices.empty()) {
LOG_WARNING("Failed to parse mount M2: ", m2Path);
return;
}
// Load skin file
std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin";
auto skinData = assetManager->readFile(skinPath);
if (!skinData.empty()) {
pipeline::M2Loader::loadSkin(skinData, model);
}
// Load external .anim files
std::string basePath = m2Path.substr(0, m2Path.size() - 3);
for (uint32_t si = 0; si < model.sequences.size(); si++) {
if (!(model.sequences[si].flags & 0x20)) {
char animFileName[256];
snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim",
basePath.c_str(), model.sequences[si].id, model.sequences[si].variationIndex);
auto animData = assetManager->readFile(animFileName);
if (!animData.empty()) {
pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model);
}
}
}
if (!charRenderer->loadModel(model, modelId)) {
LOG_WARNING("Failed to load mount model: ", m2Path);
return;
}
displayIdModelCache_[mountDisplayId] = modelId;
}
// Apply creature skin textures from CreatureDisplayInfo.dbc
if (!modelCached) {
auto itDisplayData = displayDataMap_.find(mountDisplayId);
if (itDisplayData != displayDataMap_.end()) {
const auto& dispData = itDisplayData->second;
const auto* modelData = charRenderer->getModelData(modelId);
if (modelData) {
std::string modelDir;
size_t lastSlash = m2Path.find_last_of("\\/");
if (lastSlash != std::string::npos) {
modelDir = m2Path.substr(0, lastSlash + 1);
}
for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
const auto& tex = modelData->textures[ti];
std::string texPath;
if (tex.type == 11 && !dispData.skin1.empty()) {
texPath = modelDir + dispData.skin1 + ".blp";
} else if (tex.type == 12 && !dispData.skin2.empty()) {
texPath = modelDir + dispData.skin2 + ".blp";
} else if (tex.type == 13 && !dispData.skin3.empty()) {
texPath = modelDir + dispData.skin3 + ".blp";
}
if (!texPath.empty()) {
GLuint skinTex = charRenderer->loadTexture(texPath);
if (skinTex != 0) {
charRenderer->setModelTexture(modelId, static_cast<uint32_t>(ti), skinTex);
}
}
}
}
}
}
mountModelId_ = modelId;
// Create mount instance at player position
glm::vec3 mountPos = renderer->getCharacterPosition();
float yawRad = glm::radians(renderer->getCharacterYaw());
uint32_t instanceId = charRenderer->createInstance(modelId, mountPos,
glm::vec3(0.0f, 0.0f, yawRad), 1.0f);
if (instanceId == 0) {
LOG_WARNING("Failed to create mount instance");
return;
}
mountInstanceId_ = instanceId;
// Compute height offset — place player above mount's back
const auto* modelData = charRenderer->getModelData(modelId);
float heightOffset = 1.2f; // Default fallback
if (modelData) {
// No coord swizzle in character renderer, so Z is up in model space too.
// Use the top of the bounding box as the saddle height.
float topZ = modelData->boundMax.z;
if (topZ > 0.1f) {
heightOffset = topZ * 0.85f;
}
LOG_INFO("Mount bounds: min=(", modelData->boundMin.x, ",", modelData->boundMin.y, ",", modelData->boundMin.z,
") max=(", modelData->boundMax.x, ",", modelData->boundMax.y, ",", modelData->boundMax.z,
") radius=", modelData->boundRadius, " → heightOffset=", heightOffset);
}
renderer->setMounted(instanceId, heightOffset);
charRenderer->playAnimation(instanceId, 0, true); // Idle animation
LOG_INFO("Mounted: displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset);
});
// Creature move callback (online mode) - update creature positions
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
auto it = creatureInstances_.find(guid);

View file

@ -363,6 +363,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleMonsterMove(packet);
break;
// ---- Speed Changes ----
case Opcode::SMSG_FORCE_RUN_SPEED_CHANGE:
handleForceRunSpeedChange(packet);
break;
// ---- Phase 2: Combat ----
case Opcode::SMSG_ATTACKSTART:
handleAttackStart(packet);
@ -648,6 +653,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_ACTIVATETAXIREPLY:
handleActivateTaxiReply(packet);
break;
case Opcode::SMSG_NEW_TAXI_PATH:
// Empty packet - server signals a new flight path was learned
// The actual node details come in the next SMSG_SHOWTAXINODES
addSystemChatMessage("New flight path discovered!");
break;
default:
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec);
@ -1287,6 +1297,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
case 59: unit->setUnitFlags(val); break; // UNIT_FIELD_FLAGS
case 54: unit->setLevel(val); break;
case 67: unit->setDisplayId(val); break; // UNIT_FIELD_DISPLAYID
case 69: // UNIT_FIELD_MOUNTDISPLAYID
if (block.guid == playerGuid) {
uint32_t old = currentMountDisplayId_;
currentMountDisplayId_ = val;
if (val != old && mountCallback_) mountCallback_(val);
}
unit->setMountDisplayId(val);
break;
case 82: unit->setNpcFlags(val); break; // UNIT_NPC_FLAGS
default: break;
}
@ -1398,6 +1416,15 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
unit->setFactionTemplate(val);
unit->setHostile(isHostileFaction(val));
break;
case 67: unit->setDisplayId(val); break; // UNIT_FIELD_DISPLAYID
case 69: // UNIT_FIELD_MOUNTDISPLAYID
if (block.guid == playerGuid) {
uint32_t old = currentMountDisplayId_;
currentMountDisplayId_ = val;
if (val != old && mountCallback_) mountCallback_(val);
}
unit->setMountDisplayId(val);
break;
case 82: unit->setNpcFlags(val); break; // UNIT_NPC_FLAGS
default: break;
}
@ -2964,6 +2991,27 @@ void GameHandler::handleAttackStop(network::Packet& packet) {
}
}
void GameHandler::dismount() {
if (!isMounted() || !socket) return;
network::Packet pkt(static_cast<uint16_t>(Opcode::CMSG_CANCEL_MOUNT_AURA));
socket->send(pkt);
LOG_INFO("Sent CMSG_CANCEL_MOUNT_AURA");
}
void GameHandler::handleForceRunSpeedChange(network::Packet& packet) {
// Packed GUID
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
// uint32 counter (ack counter, we ignore)
packet.readUInt32();
// float newSpeed
float newSpeed = packet.readFloat();
if (guid == playerGuid) {
serverRunSpeed_ = newSpeed;
LOG_INFO("Server run speed changed to ", newSpeed);
}
}
void GameHandler::handleMonsterMove(network::Packet& packet) {
MonsterMoveData data;
if (!MonsterMoveParser::parse(packet, data)) {
@ -3945,6 +3993,29 @@ void GameHandler::handleShowTaxiNodes(network::Packet& packet) {
loadTaxiDbc();
// Detect newly discovered flight paths by comparing with stored mask
if (taxiMaskInitialized_) {
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
uint32_t newBits = data.nodeMask[i] & ~knownTaxiMask_[i];
if (newBits == 0) continue;
for (uint32_t bit = 0; bit < 32; ++bit) {
if (newBits & (1u << bit)) {
uint32_t nodeId = i * 32 + bit;
auto it = taxiNodes_.find(nodeId);
if (it != taxiNodes_.end()) {
addSystemChatMessage("Discovered flight path: " + it->second.name);
}
}
}
}
}
// Update stored mask
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
knownTaxiMask_[i] = data.nodeMask[i];
}
taxiMaskInitialized_ = true;
currentTaxiData_ = data;
taxiNpcGuid_ = data.npcGuid;
taxiWindowOpen_ = true;

View file

@ -1,8 +1,33 @@
#include "core/application.hpp"
#include "core/logger.hpp"
#include <exception>
#include <csignal>
#include <SDL2/SDL.h>
#include <X11/Xlib.h>
static void releaseMouseGrab() {
// Bypass SDL — talk to X11 directly (signal-safe enough for our purposes)
Display* dpy = XOpenDisplay(nullptr);
if (dpy) {
XUngrabPointer(dpy, CurrentTime);
XUngrabKeyboard(dpy, CurrentTime);
XFlush(dpy);
XCloseDisplay(dpy);
}
}
static void crashHandler(int sig) {
releaseMouseGrab();
std::signal(sig, SIG_DFL);
std::raise(sig);
}
int main([[maybe_unused]] int argc, [[maybe_unused]] char* argv[]) {
std::signal(SIGSEGV, crashHandler);
std::signal(SIGABRT, crashHandler);
std::signal(SIGFPE, crashHandler);
std::signal(SIGTERM, crashHandler);
std::signal(SIGINT, crashHandler);
try {
wowee::core::Logger::getInstance().setLogLevel(wowee::core::LogLevel::DEBUG);
LOG_INFO("=== Wowee Native Client ===");
@ -22,10 +47,12 @@ int main([[maybe_unused]] int argc, [[maybe_unused]] char* argv[]) {
return 0;
}
catch (const std::exception& e) {
releaseMouseGrab();
LOG_FATAL("Unhandled exception: ", e.what());
return 1;
}
catch (...) {
releaseMouseGrab();
LOG_FATAL("Unknown exception occurred");
return 1;
}

View file

@ -191,7 +191,7 @@ void CameraController::update(float deltaTime) {
} else if (ctrlDown) {
speed = WOW_WALK_SPEED;
} else {
speed = WOW_RUN_SPEED;
speed = (runSpeedOverride_ > 0.0f) ? runSpeedOverride_ : WOW_RUN_SPEED;
}
} else {
// Exploration mode (original behavior)
@ -225,10 +225,12 @@ void CameraController::update(float deltaTime) {
glm::vec3 right(-std::sin(moveYawRad), std::cos(moveYawRad), 0.0f);
// Toggle sit/crouch with X key (edge-triggered) — only when UI doesn't want keyboard
// Blocked while mounted
bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X);
if (xDown && !xKeyWasDown) {
if (xDown && !xKeyWasDown && !mounted_) {
sitting = !sitting;
}
if (mounted_) sitting = false;
xKeyWasDown = xDown;
// Update eye height based on crouch state (smooth transition)

View file

@ -364,6 +364,20 @@ void Renderer::setCharacterFollow(uint32_t instanceId) {
}
}
void Renderer::setMounted(uint32_t mountInstId, float heightOffset) {
mountInstanceId_ = mountInstId;
mountHeightOffset_ = heightOffset;
charAnimState = CharAnimState::MOUNT;
if (cameraController) cameraController->setMounted(true);
}
void Renderer::clearMount() {
mountInstanceId_ = 0;
mountHeightOffset_ = 0.0f;
charAnimState = CharAnimState::IDLE;
if (cameraController) cameraController->setMounted(false);
}
uint32_t Renderer::resolveMeleeAnimId() {
if (!characterRenderer || characterInstanceId == 0) {
meleeAnimId = 0;
@ -473,6 +487,7 @@ void Renderer::updateCharacterAnimation() {
constexpr uint32_t ANIM_SITTING = 97; // Hold on same animation (no separate idle)
constexpr uint32_t ANIM_SWIM_IDLE = 41; // Treading water (SwimIdle)
constexpr uint32_t ANIM_SWIM = 42; // Swimming forward (Swim)
constexpr uint32_t ANIM_MOUNT = 91; // Seated on mount
CharAnimState newState = charAnimState;
@ -489,6 +504,42 @@ void Renderer::updateCharacterAnimation() {
bool swim = cameraController->isSwimming();
bool forceMelee = meleeSwingTimer > 0.0f && grounded && !swim;
// When mounted, force MOUNT state and skip normal transitions
if (isMounted()) {
newState = CharAnimState::MOUNT;
charAnimState = newState;
// Play seated animation on player
uint32_t currentAnimId = 0;
float currentAnimTimeMs = 0.0f, currentAnimDurationMs = 0.0f;
bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs);
if (!haveState || currentAnimId != ANIM_MOUNT) {
characterRenderer->playAnimation(characterInstanceId, ANIM_MOUNT, true);
}
// Sync mount instance position and rotation
if (mountInstanceId_ > 0) {
characterRenderer->setInstancePosition(mountInstanceId_, characterPosition);
float yawRad = glm::radians(characterYaw);
characterRenderer->setInstanceRotation(mountInstanceId_, glm::vec3(0.0f, 0.0f, yawRad));
// Drive mount model animation: idle when still, run when moving
uint32_t mountAnimId = moving ? ANIM_RUN : ANIM_STAND;
uint32_t curMountAnim = 0;
float curMountTime = 0, curMountDur = 0;
bool haveMountState = characterRenderer->getAnimationState(mountInstanceId_, curMountAnim, curMountTime, curMountDur);
if (!haveMountState || curMountAnim != mountAnimId) {
characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true);
}
}
// Offset player Z above mount
glm::vec3 playerPos = characterPosition;
playerPos.z += mountHeightOffset_;
characterRenderer->setInstancePosition(characterInstanceId, playerPos);
return;
}
if (!forceMelee) switch (charAnimState) {
case CharAnimState::IDLE:
if (swim) {
@ -629,6 +680,9 @@ void Renderer::updateCharacterAnimation() {
newState = CharAnimState::IDLE;
}
break;
case CharAnimState::MOUNT:
break; // Handled by early return above
}
if (forceMelee) {
@ -692,6 +746,7 @@ void Renderer::updateCharacterAnimation() {
}
loop = false;
break;
case CharAnimState::MOUNT: animId = ANIM_MOUNT; loop = true; break;
}
uint32_t currentAnimId = 0;

View file

@ -1138,6 +1138,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return;
}
// /dismount command
if (cmdLower == "dismount") {
gameHandler.dismount();
chatInputBuffer[0] = '\0';
return;
}
// /sit command
if (cmdLower == "sit") {
gameHandler.setStandState(1); // 1 = sit