From 41cd059f84900543aad2a14847ea238fa5105c2e Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 5 Apr 2026 20:30:15 +0300 Subject: [PATCH 1/9] fix --- src/core/audio_callback_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/audio_callback_handler.cpp b/src/core/audio_callback_handler.cpp index 088fc270..befc57e1 100644 --- a/src/core/audio_callback_handler.cpp +++ b/src/core/audio_callback_handler.cpp @@ -2,6 +2,7 @@ #include "core/coordinates.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "game/game_handler.hpp" #include "audio/audio_coordinator.hpp" From fe29ccad3f89ed3739a8979dc90961931e937ff1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 16:01:14 -0700 Subject: [PATCH 2/9] fix(transport): guard against untracked transport placing player at map origin When on-transport flag is set but the transport isn't tracked by TransportManager, getPlayerWorldPosition() returns localOffset (a small relative value) as a world position. This overwrites movementInfo with near-zero coordinates, teleporting the player to map origin on Eastern Kingdoms (Alterac/Hillsbrad area). Add transport existence checks in sendMovement() and getComposedWorldPosition() before composing position. --- src/game/game_handler.cpp | 6 +++++- src/game/movement_handler.cpp | 13 +++++++++---- src/game/transport_manager.cpp | 4 +++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0c47c46f..91105558 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6587,7 +6587,11 @@ void GameHandler::mailMarkAsRead(uint32_t mailId) { glm::vec3 GameHandler::getComposedWorldPosition() { if (playerTransportGuid_ != 0 && transportManager_) { - return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + auto* tr = transportManager_->getTransport(playerTransportGuid_); + if (tr) { + return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + } + // Transport not tracked — fall through to normal position } // Not on transport, return normal movement position return glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z); diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index c06a45a2..28caef66 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -516,10 +516,15 @@ void MovementHandler::sendMovement(Opcode opcode) { // Add transport data if player is on a server-recognized transport if (includeTransportInWire) { if (owner_.transportManager_) { - glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; + auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); + if (tr) { + glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } + // If transport not found, keep current movementInfo position — + // the localOffset fallback would place us near map origin (0,0,0). } movementInfo.flags |= static_cast(MovementFlags::ONTRANSPORT); movementInfo.transportGuid = owner_.playerTransportGuid_; diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 113dfad5..47f12735 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -141,7 +141,9 @@ ActiveTransport* TransportManager::getTransport(uint64_t guid) { glm::vec3 TransportManager::getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset) { auto* transport = getTransport(transportGuid); if (!transport) { - return localOffset; // Fallback + LOG_WARNING("getPlayerWorldPosition: transport 0x", std::hex, transportGuid, std::dec, + " not found — returning localOffset as-is (callers should guard)"); + return localOffset; } if (transport->isM2) { From aff545edef5a05cab64a20878652ba45855a919e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 17:25:25 -0700 Subject: [PATCH 3/9] fix(rendering): emote animations, WMO portal culling, transport teleport Emote animations: fix DBC chain for /laugh, /flirt, /sleep, /fart, /stink. Previously all emotes with AnimID=0 used emoteRef as animId (wrong DBC record IDs). Now resolves through Emotes.dbc properly, with per-emote overrides for emotes whose DBC chain yields 0. Adds Emotes.dbc load failure warning and diagnostic logging. WMO culling: skip portal culling when camera is outside all groups (fixes vanishing Stormwind ground tiles). Also handle indoor/outdoor AABB overlap by showing all groups when position is in both indoor and outdoor AABBs. Transport: clear ONTRANSPORT flag and transport state when transport not found, preventing stale transport data from teleporting player to map origin. Add area trigger safety net near (0,0,0) on Eastern Kingdoms. --- src/game/chat_handler.cpp | 2 +- src/game/movement_handler.cpp | 37 +++++++++++++++++-- src/rendering/animation/emote_registry.cpp | 41 ++++++++++++++++++---- src/rendering/animation_controller.cpp | 3 ++ src/rendering/wmo_renderer.cpp | 28 +++++++++++++++ 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/game/chat_handler.cpp b/src/game/chat_handler.cpp index 5dfc2fd5..5d06e46f 100644 --- a/src/game/chat_handler.cpp +++ b/src/game/chat_handler.cpp @@ -190,7 +190,7 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { LOG_WARNING("Failed to parse SMSG_MESSAGECHAT, size=", packet.getSize()); return; } - LOG_INFO("INCOMING CHAT: type=", static_cast(data.type), + LOG_WARNING("INCOMING CHAT: type=", static_cast(data.type), " (", getChatTypeString(data.type), ") sender=0x", std::hex, data.senderGuid, std::dec, " '", data.senderName, "' msg='", data.message.substr(0, 60), "'"); diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index 28caef66..3fe7d5d8 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -515,17 +515,28 @@ void MovementHandler::sendMovement(Opcode opcode) { // Add transport data if player is on a server-recognized transport if (includeTransportInWire) { + bool transportResolved = false; if (owner_.transportManager_) { auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); if (tr) { + transportResolved = true; glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); movementInfo.x = composed.x; movementInfo.y = composed.y; movementInfo.z = composed.z; } - // If transport not found, keep current movementInfo position — - // the localOffset fallback would place us near map origin (0,0,0). } + if (!transportResolved) { + // Transport not tracked — don't send ONTRANSPORT to the server. + // Sending stale transport GUID + local offset causes the server to + // compute a bad world position and teleport us to map origin. + LOG_WARNING("sendMovement: transport 0x", std::hex, owner_.playerTransportGuid_, + std::dec, " not found — clearing transport state"); + includeTransportInWire = false; + owner_.clearPlayerTransport(); + } + } + if (includeTransportInWire) { movementInfo.flags |= static_cast(MovementFlags::ONTRANSPORT); movementInfo.transportGuid = owner_.playerTransportGuid_; movementInfo.transportX = owner_.playerTransportOffset_.x; @@ -601,6 +612,17 @@ void MovementHandler::sendMovement(Opcode opcode) { wireOpcode(opcode), std::dec, (includeTransportInWire ? " ONTRANSPORT" : "")); + // Detect near-origin position on Eastern Kingdoms (map 0) — this would place + // the player near Alterac Mountains and is almost certainly a bug. + if (owner_.currentMapId_ == 0 && + std::abs(movementInfo.x) < 500.0f && std::abs(movementInfo.y) < 500.0f) { + LOG_WARNING("sendMovement: position near map origin! canonical=(", + movementInfo.x, ", ", movementInfo.y, ", ", movementInfo.z, + ") onTransport=", owner_.isOnTransport(), + " transportGuid=0x", std::hex, owner_.playerTransportGuid_, std::dec, + " flags=0x", std::hex, movementInfo.flags, std::dec); + } + // Convert canonical → server coordinates for the wire MovementInfo wireInfo = movementInfo; glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wireInfo.x, wireInfo.y, wireInfo.z)); @@ -2540,6 +2562,17 @@ void MovementHandler::checkAreaTriggers() { const float py = movementInfo.y; const float pz = movementInfo.z; + // Sanity: if position is near map origin on Eastern Kingdoms (map 0), + // something has corrupted movementInfo — skip area trigger check to + // avoid firing Alterac/Hillsbrad triggers and causing a rogue teleport. + if (owner_.currentMapId_ == 0 && std::abs(px) < 500.0f && std::abs(py) < 500.0f) { + LOG_WARNING("checkAreaTriggers: position near map origin (", px, ", ", py, ", ", pz, + ") on map 0 — skipping to avoid rogue teleport. onTransport=", + owner_.isOnTransport(), " transportGuid=0x", std::hex, + owner_.playerTransportGuid_, std::dec); + return; + } + // On first check after map transfer, just mark which triggers we're inside // without firing them — prevents exit portal from immediately sending us back bool suppressFirst = owner_.areaTriggerSuppressFirst_; diff --git a/src/rendering/animation/emote_registry.cpp b/src/rendering/animation/emote_registry.cpp index 88236349..307968bc 100644 --- a/src/rendering/animation/emote_registry.cpp +++ b/src/rendering/animation/emote_registry.cpp @@ -32,7 +32,7 @@ static std::vector parseEmoteCommands(const std::string& raw) { static bool isLoopingEmote(const std::string& command) { static const std::unordered_set kLooping = { - "dance", "train", "dead", "eat", "work", + "dance", "train", "dead", "eat", "work", "sleep", }; return kLooping.find(command) != kLooping.end(); } @@ -117,6 +117,9 @@ void EmoteRegistry::loadFromDbc() { uint32_t animId = emotesDbc->getUInt32(r, emL ? (*emL)["AnimID"] : 2); if (animId != 0) emoteIdToAnim[emoteId] = animId; } + LOG_WARNING("Emotes: loaded ", emoteIdToAnim.size(), " anim mappings from Emotes.dbc"); + } else { + LOG_WARNING("Emotes: Emotes.dbc failed to load — all emotes will use fallback animations"); } emoteTable_.clear(); @@ -128,11 +131,13 @@ void EmoteRegistry::loadFromDbc() { uint32_t emoteRef = emotesTextDbc->getUInt32(r, etL ? (*etL)["EmoteRef"] : 2); uint32_t animId = 0; - auto animIt = emoteIdToAnim.find(emoteRef); - if (animIt != emoteIdToAnim.end()) { - animId = animIt->second; - } else { - animId = emoteRef; + if (emoteRef != 0) { + auto animIt = emoteIdToAnim.find(emoteRef); + if (animIt != emoteIdToAnim.end()) { + animId = animIt->second; + } + // If Emotes.dbc has AnimID=0 for this ref, leave animId=0 (text-only). + // Previously fell back to using emoteRef as animId which is wrong. } uint32_t senderTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["SenderTargetTextID"] : 5); @@ -161,11 +166,33 @@ void EmoteRegistry::loadFromDbc() { } } + // Override emotes whose DBC chain yields animId=0. + // /sleep uses the stand-state system in WoW rather than Emotes.dbc AnimID. + // /laugh and /flirt should resolve from Emotes.dbc (70 and 83), but these + // serve as backup if Emotes.dbc failed to load. + // /fart and /stink have EmoteRef=0 in EmotesText.dbc — no Emotes.dbc link. + static const std::unordered_map kAnimOverrides = { + {"sleep", anim::EMOTE_SLEEP}, // 71 — stand-state emote + {"laugh", anim::EMOTE_LAUGH}, // 70 — backup + {"flirt", anim::EMOTE_SHY}, // 83 — DBC calls it SHY; it's the flirt animation + {"fart", anim::EMOTE_FLEX}, // 82 — straining/tensing gesture + {"stink", anim::EMOTE_RUDE}, // 73 — dismissive/disgusted gesture + }; + for (auto& [cmd, info] : emoteTable_) { + if (info.animId == 0) { + auto ov = kAnimOverrides.find(cmd); + if (ov != kAnimOverrides.end()) { + LOG_WARNING("Emotes: override /", cmd, " → animId=", ov->second); + info.animId = ov->second; + } + } + } + if (emoteTable_.empty()) { LOG_WARNING("Emotes: DBC loaded but no commands parsed, using fallback list"); loadFallbackEmotes(); } else { - LOG_INFO("Emotes: loaded ", emoteTable_.size(), " commands from DBC"); + LOG_WARNING("Emotes: loaded ", emoteTable_.size(), " commands from DBC"); } buildDbcIdIndex(); diff --git a/src/rendering/animation_controller.cpp b/src/rendering/animation_controller.cpp index c1cdb610..2ee06414 100644 --- a/src/rendering/animation_controller.cpp +++ b/src/rendering/animation_controller.cpp @@ -93,6 +93,9 @@ void AnimationController::playEmote(const std::string& emoteName) { auto* characterRenderer = renderer_->getCharacterRenderer(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (characterRenderer && characterInstanceId > 0) { + bool hasAnim = characterRenderer->hasAnimation(characterInstanceId, animId); + LOG_WARNING("playEmote '", emoteName, "': animId=", animId, " loop=", loop, + " modelHasAnim=", hasAnim); characterRenderer->playAnimation(characterInstanceId, animId, loop); lastPlayerAnimRequest_ = animId; lastPlayerAnimLoopRequest_ = loop; diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 32996c31..fe3eb9f6 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1418,6 +1418,17 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Portal-based visibility — reuse member scratch buffers (avoid per-frame alloc) portalVisibleGroups_.clear(); bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); + if (usePortalCulling) { + // If the actual camera is outside all groups, skip portal culling. + // The character position (portalViewerPos) may fall inside a group's + // loose AABB while visually outside the WMO, causing the BFS to start + // from an interior group whose portals aren't in the frustum — hiding + // the entire WMO. + glm::vec3 localRealCam = glm::vec3(instance.invModelMatrix * glm::vec4(camPos, 1.0f)); + if (findContainingGroup(model, localRealCam) < 0) { + usePortalCulling = false; + } + } if (usePortalCulling) { portalVisibleGroupSet_.clear(); glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(portalViewerPos, 1.0f); @@ -2135,6 +2146,23 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, } return; } + // Best-fit group is indoor-only, but the position might also be inside an + // outdoor group's AABB (e.g., standing on a street near a building whose + // indoor AABB extends outward). If any outdoor group also contains the + // position, treat this as an outdoor location and show all groups. + for (size_t gi = 0; gi < model.groups.size(); gi++) { + if (static_cast(gi) == cameraGroup) continue; + const auto& g = model.groups[gi]; + if (!(g.groupFlags & WMO_GROUP_FLAG_OUTDOOR)) continue; + if (cameraLocalPos.x >= g.boundingBoxMin.x && cameraLocalPos.x <= g.boundingBoxMax.x && + cameraLocalPos.y >= g.boundingBoxMin.y && cameraLocalPos.y <= g.boundingBoxMax.y && + cameraLocalPos.z >= g.boundingBoxMin.z && cameraLocalPos.z <= g.boundingBoxMax.z) { + for (size_t gj = 0; gj < model.groups.size(); gj++) { + outVisibleGroups.insert(static_cast(gj)); + } + return; + } + } } // If the camera group has no portal refs, it's a dead-end group (utility/transition group). From 722c065089ae35ccb329971e2d6c67b1fb3ae19b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 17:33:42 -0700 Subject: [PATCH 4/9] fix(emotes): use EMOTE_TALK for /fart and /stink (no dedicated anim in DBC) --- src/rendering/animation/emote_registry.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rendering/animation/emote_registry.cpp b/src/rendering/animation/emote_registry.cpp index 307968bc..0d438184 100644 --- a/src/rendering/animation/emote_registry.cpp +++ b/src/rendering/animation/emote_registry.cpp @@ -175,8 +175,8 @@ void EmoteRegistry::loadFromDbc() { {"sleep", anim::EMOTE_SLEEP}, // 71 — stand-state emote {"laugh", anim::EMOTE_LAUGH}, // 70 — backup {"flirt", anim::EMOTE_SHY}, // 83 — DBC calls it SHY; it's the flirt animation - {"fart", anim::EMOTE_FLEX}, // 82 — straining/tensing gesture - {"stink", anim::EMOTE_RUDE}, // 73 — dismissive/disgusted gesture + {"fart", anim::EMOTE_TALK}, // 60 — generic gesture (WoW has no dedicated anim) + {"stink", anim::EMOTE_TALK}, // 60 — generic gesture (WoW has no dedicated anim) }; for (auto& [cmd, info] : emoteTable_) { if (info.animId == 0) { From 696baffdf7532722eb3c3e122b7ac4223b4db4fa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 17:39:56 -0700 Subject: [PATCH 5/9] fix(movement): upgrade teleport and heartbeat diagnostics to WARNING MSG_MOVE_TELEPORT_ACK now logs server-sent coordinates AND current position at WARNING level (was LOG_INFO, invisible in log file). Heartbeat position audit now logs every ~60 heartbeats (~30s) at WARNING level to trace position drift before rogue teleports. --- src/game/movement_handler.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index 3fe7d5d8..7635a945 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -630,10 +630,12 @@ void MovementHandler::sendMovement(Opcode opcode) { wireInfo.y = serverPos.y; wireInfo.z = serverPos.z; - // Periodic position audit — DEBUG to avoid flooding production logs. - if (opcode == Opcode::MSG_MOVE_HEARTBEAT && ++heartbeatLogCount_ % 30 == 0) { - LOG_DEBUG("HEARTBEAT pos canonical=(", movementInfo.x, ",", movementInfo.y, ",", movementInfo.z, - ") wire=(", wireInfo.x, ",", wireInfo.y, ",", wireInfo.z, ")"); + // Periodic position audit — log every ~60 heartbeats (~30s) to trace position drift. + if (opcode == Opcode::MSG_MOVE_HEARTBEAT && ++heartbeatLogCount_ % 60 == 0) { + LOG_WARNING("HEARTBEAT #", heartbeatLogCount_, " canonical=(", + movementInfo.x, ",", movementInfo.y, ",", movementInfo.z, + ") server=(", wireInfo.x, ",", wireInfo.y, ",", wireInfo.z, + ") flags=0x", std::hex, movementInfo.flags, std::dec); } wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation); @@ -1671,9 +1673,10 @@ void MovementHandler::handleTeleportAck(network::Packet& packet) { float serverZ = packet.readFloat(); float orientation = packet.readFloat(); - LOG_INFO("MSG_MOVE_TELEPORT_ACK: guid=0x", std::hex, guid, std::dec, - " counter=", counter, - " pos=(", serverX, ", ", serverY, ", ", serverZ, ")"); + LOG_WARNING("MSG_MOVE_TELEPORT_ACK: guid=0x", std::hex, guid, std::dec, + " counter=", counter, + " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", + " currentPos=(", movementInfo.x, ", ", movementInfo.y, ", ", movementInfo.z, ")"); glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); movementInfo.x = canonical.x; From 53639f9592fe88bf7aafee1b6a32237cd5273d50 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 17:52:18 -0700 Subject: [PATCH 6/9] fix(animation): re-probe capabilities on melee swing, add combat diagnostics If the capability probe ran before the model was fully loaded, all melee animation IDs would be 0 and auto-attack swings would silently fall back to STAND (no visible animation). Now re-probes when a melee swing fires but hasMelee is false. Added WARNING-level logging to triggerMeleeSwing and CombatFSM to diagnose the night elf stationary combat animation issue. --- src/rendering/animation/combat_fsm.cpp | 6 +++++- src/rendering/animation_controller.cpp | 26 +++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/rendering/animation/combat_fsm.cpp b/src/rendering/animation/combat_fsm.cpp index 997fb938..67e301fe 100644 --- a/src/rendering/animation/combat_fsm.cpp +++ b/src/rendering/animation/combat_fsm.cpp @@ -1,6 +1,7 @@ #include "rendering/animation/combat_fsm.hpp" #include "rendering/animation/animation_ids.hpp" #include "game/inventory.hpp" +#include "core/logger.hpp" namespace wowee { namespace rendering { @@ -378,7 +379,10 @@ AnimOutput CombatFSM::resolve(const Input& in, const AnimCapabilitySet& caps, animId = caps.resolvedMelee1H; } } - if (animId == 0) animId = anim::STAND; // Melee must play something + if (animId == 0) { + LOG_WARNING("CombatFSM: MELEE_SWING resolved animId=0, falling back to STAND"); + animId = anim::STAND; + } loop = false; break; diff --git a/src/rendering/animation_controller.cpp b/src/rendering/animation_controller.cpp index 2ee06414..8cdf6d0a 100644 --- a/src/rendering/animation_controller.cpp +++ b/src/rendering/animation_controller.cpp @@ -245,6 +245,18 @@ void AnimationController::triggerMeleeSwing() { if (durationSec < 0.25f) durationSec = 0.25f; if (durationSec > 1.0f) durationSec = 1.0f; meleeSwingTimer_ = durationSec; + + // Diagnostic: log the melee swing trigger with key state + const auto& caps = characterAnimator_.getCapabilities(); + auto* cc = renderer_->getCameraController(); + LOG_WARNING("triggerMeleeSwing: meleeAnimId=", meleeAnimId_, + " dur=", durationSec, + " caps.melee1H=", caps.resolvedMelee1H, + " caps.melee2H=", caps.resolvedMelee2H, + " caps.meleeUnarmed=", caps.resolvedMeleeUnarmed, + " grounded=", (cc ? cc->isGrounded() : false), + " probed=", capabilitiesProbed_); + if (renderer_->getAudioCoordinator()->getActivitySoundManager()) { renderer_->getAudioCoordinator()->getActivitySoundManager()->playMeleeSwing(); } @@ -1043,9 +1055,17 @@ void AnimationController::updateCharacterAnimation() { auto* cameraController = renderer_->getCameraController(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); - // Lazy probe: populate capability set once per model - if (!capabilitiesProbed_ && characterRenderer && characterInstanceId != 0) { - probeCapabilities(); + // Lazy probe: populate capability set once per model. + // Re-probe if melee capabilities are missing (model may not have been fully + // loaded on the first probe attempt). + if (characterRenderer && characterInstanceId != 0) { + if (!capabilitiesProbed_) { + probeCapabilities(); + } else if (meleeSwingTimer_ > 0.0f && !characterAnimator_.getCapabilities().hasMelee) { + LOG_WARNING("Re-probing capabilities: melee swing active but hasMelee=false"); + capabilitiesProbed_ = false; + probeCapabilities(); + } } // When mounted, delegate to MountFSM and handle positioning From e26ed39da8998d26bf0799af9537b22b8e477db9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 18:54:01 -0700 Subject: [PATCH 7/9] fix(combat): add diagnostic logging to handleAttackerStateUpdate Log parse failures with remaining packet size and successful parses with attacker/target/player GUIDs, damage, and callback status to diagnose why meleeSwingCallback is never invoked during auto-attack. --- src/game/combat_handler.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index 48f0b2fd..e64c9841 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -433,10 +433,19 @@ void CombatHandler::handleAttackStop(network::Packet& packet) { void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) { AttackerStateUpdateData data; - if (!owner_.packetParsers_->parseAttackerStateUpdate(packet, data)) return; + if (!owner_.packetParsers_->parseAttackerStateUpdate(packet, data)) { + LOG_WARNING("ATTACKER_STATE_UPDATE: parse failed, remaining=", packet.getRemainingSize()); + return; + } bool isPlayerAttacker = (data.attackerGuid == owner_.playerGuid); bool isPlayerTarget = (data.targetGuid == owner_.playerGuid); + + LOG_WARNING("ATTACKER_STATE_UPDATE: attacker=0x", std::hex, data.attackerGuid, + " target=0x", data.targetGuid, " player=0x", owner_.playerGuid, std::dec, + " isPlayerAttacker=", isPlayerAttacker, " isPlayerTarget=", isPlayerTarget, + " dmg=", data.totalDamage, " hasCallback=", (owner_.meleeSwingCallback_ ? 1 : 0)); + if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat if (isPlayerAttacker) { From 0e74e0f951741332d3ab3190832beb5d42c7cbae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 19:02:07 -0700 Subject: [PATCH 8/9] fix(combat): read WotLK overkill field in SMSG_ATTACKERSTATEUPDATE AzerothCore sends a uint32 overkill field between totalDamage and subDamageCount. The parser was missing this, causing subDamageCount to read the first byte of overkill (0 for non-kills) and fail immediately. This broke all melee swing animations except the killing blow. --- src/game/world_packets.cpp | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8df1dec0..abd3af7c 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3428,8 +3428,8 @@ bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { } bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpdateData& data) { - // Upfront validation: hitInfo(4) + packed GUIDs(1-8 each) + totalDamage(4) + subDamageCount(1) = 13 bytes minimum - if (!packet.hasRemaining(13)) return false; + // Upfront validation: hitInfo(4) + packed GUIDs(1-8 each) + totalDamage(4) + overkill(4) + subDamageCount(1) = 17 bytes minimum + if (!packet.hasRemaining(17)) return false; size_t startPos = packet.getReadPos(); data.hitInfo = packet.readUInt32(); @@ -3444,13 +3444,15 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda } data.targetGuid = packet.readPackedGuid(); - // Validate totalDamage + subDamageCount can be read (5 bytes) - if (!packet.hasRemaining(5)) { + // Validate totalDamage + overkill + subDamageCount can be read (9 bytes) + // WotLK (AzerothCore) sends: damage(4) + overkill(4) + subDamageCount(1) + if (!packet.hasRemaining(9)) { packet.setReadPos(startPos); return false; } data.totalDamage = static_cast(packet.readUInt32()); + data.overkill = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); // Cap subDamageCount: each entry is 20 bytes. If the claimed count @@ -3487,17 +3489,14 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda // Validate victimState + overkill fields (8 bytes) if (!packet.hasRemaining(8)) { data.victimState = 0; - data.overkill = 0; return !data.subDamages.empty(); } data.victimState = packet.readUInt32(); - // WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill. - // Older parsers omitted these, reading overkill from the wrong offset. + // WotLK: attackerState(4) + meleeSpellId(4) follow victimState auto rem = [&]() { return packet.getRemainingSize(); }; - if (rem() >= 4) packet.readUInt32(); // unk1 (always 0) - if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack) - data.overkill = (rem() >= 4) ? static_cast(packet.readUInt32()) : -1; + if (rem() >= 4) packet.readUInt32(); // attackerState (always 0) + if (rem() >= 4) packet.readUInt32(); // meleeSpellId (0 for auto-attack) // hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40) if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32(); From 0a22b0d41aa7aa80d11fc3cafa8d05b064c02fd2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 5 Apr 2026 19:10:42 -0700 Subject: [PATCH 9/9] refactor: remove debug diagnostics from combat and animation code --- src/game/combat_handler.cpp | 11 +---------- src/rendering/animation/combat_fsm.cpp | 2 +- src/rendering/animation_controller.cpp | 15 --------------- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index e64c9841..48f0b2fd 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -433,19 +433,10 @@ void CombatHandler::handleAttackStop(network::Packet& packet) { void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) { AttackerStateUpdateData data; - if (!owner_.packetParsers_->parseAttackerStateUpdate(packet, data)) { - LOG_WARNING("ATTACKER_STATE_UPDATE: parse failed, remaining=", packet.getRemainingSize()); - return; - } + if (!owner_.packetParsers_->parseAttackerStateUpdate(packet, data)) return; bool isPlayerAttacker = (data.attackerGuid == owner_.playerGuid); bool isPlayerTarget = (data.targetGuid == owner_.playerGuid); - - LOG_WARNING("ATTACKER_STATE_UPDATE: attacker=0x", std::hex, data.attackerGuid, - " target=0x", data.targetGuid, " player=0x", owner_.playerGuid, std::dec, - " isPlayerAttacker=", isPlayerAttacker, " isPlayerTarget=", isPlayerTarget, - " dmg=", data.totalDamage, " hasCallback=", (owner_.meleeSwingCallback_ ? 1 : 0)); - if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat if (isPlayerAttacker) { diff --git a/src/rendering/animation/combat_fsm.cpp b/src/rendering/animation/combat_fsm.cpp index 67e301fe..764987eb 100644 --- a/src/rendering/animation/combat_fsm.cpp +++ b/src/rendering/animation/combat_fsm.cpp @@ -380,7 +380,7 @@ AnimOutput CombatFSM::resolve(const Input& in, const AnimCapabilitySet& caps, } } if (animId == 0) { - LOG_WARNING("CombatFSM: MELEE_SWING resolved animId=0, falling back to STAND"); + LOG_DEBUG("CombatFSM: MELEE_SWING resolved animId=0, falling back to STAND"); animId = anim::STAND; } loop = false; diff --git a/src/rendering/animation_controller.cpp b/src/rendering/animation_controller.cpp index 8cdf6d0a..d8a90d49 100644 --- a/src/rendering/animation_controller.cpp +++ b/src/rendering/animation_controller.cpp @@ -93,9 +93,6 @@ void AnimationController::playEmote(const std::string& emoteName) { auto* characterRenderer = renderer_->getCharacterRenderer(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (characterRenderer && characterInstanceId > 0) { - bool hasAnim = characterRenderer->hasAnimation(characterInstanceId, animId); - LOG_WARNING("playEmote '", emoteName, "': animId=", animId, " loop=", loop, - " modelHasAnim=", hasAnim); characterRenderer->playAnimation(characterInstanceId, animId, loop); lastPlayerAnimRequest_ = animId; lastPlayerAnimLoopRequest_ = loop; @@ -246,17 +243,6 @@ void AnimationController::triggerMeleeSwing() { if (durationSec > 1.0f) durationSec = 1.0f; meleeSwingTimer_ = durationSec; - // Diagnostic: log the melee swing trigger with key state - const auto& caps = characterAnimator_.getCapabilities(); - auto* cc = renderer_->getCameraController(); - LOG_WARNING("triggerMeleeSwing: meleeAnimId=", meleeAnimId_, - " dur=", durationSec, - " caps.melee1H=", caps.resolvedMelee1H, - " caps.melee2H=", caps.resolvedMelee2H, - " caps.meleeUnarmed=", caps.resolvedMeleeUnarmed, - " grounded=", (cc ? cc->isGrounded() : false), - " probed=", capabilitiesProbed_); - if (renderer_->getAudioCoordinator()->getActivitySoundManager()) { renderer_->getAudioCoordinator()->getActivitySoundManager()->playMeleeSwing(); } @@ -1062,7 +1048,6 @@ void AnimationController::updateCharacterAnimation() { if (!capabilitiesProbed_) { probeCapabilities(); } else if (meleeSwingTimer_ > 0.0f && !characterAnimator_.getCapabilities().hasMelee) { - LOG_WARNING("Re-probing capabilities: melee swing active but hasMelee=false"); capabilitiesProbed_ = false; probeCapabilities(); }