From eaba378b5b255f9fa111332f9169ea592d90fa14 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Feb 2026 03:38:12 -0800 Subject: [PATCH] Fix classic combat desync and separate attack intent from confirmed combat Combat state/UI: - Split attack intent from server-confirmed auto attack in GameHandler. - Add explicit combat state helpers (intent, confirmed, combat-with-target). - Update player/target frame and renderer in-combat indicators to use confirmed combat. - Add intent-only visual state in UI to avoid false combat feedback. Animation correctness: - Correct combat-ready animation IDs to canonical ready stances (22/23/24/25). - Remove hit/wound-like stance behavior caused by incorrect ready IDs. Classic/TBC/Turtle melee reliability: - Tighten melee sync while attack intent is active: faster swing resend, tighter facing threshold, and faster facing cadence for classic-like expansions. - Send immediate movement heartbeat after facing corrections and during stationary melee sync windows. - Handle melee error opcodes explicitly (NOTINRANGE/BADFACING/NOTSTANDING/CANT_ATTACK) and immediately resync facing/swing where appropriate. - On ATTACKSTOP with persistent intent, immediately reassert ATTACKSWING. - Increase movement heartbeat cadence during active classic-like melee intent. Server compatibility/anti-cheat stability: - Restrict legacy force-movement/speed/teleport ACK behavior for classic-like flows to avoid unsolicited order acknowledgements. - Preserve local movement state updates while preventing bad-order ACK noise. --- include/game/game_handler.hpp | 9 +++ src/game/game_handler.cpp | 129 +++++++++++++++++++++++++--------- src/rendering/renderer.cpp | 12 ++-- src/ui/game_screen.cpp | 27 ++++--- 4 files changed, 129 insertions(+), 48 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b9c1656d..b6f8dc7e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -413,6 +413,14 @@ public: void startAutoAttack(uint64_t targetGuid); void stopAutoAttack(); bool isAutoAttacking() const { return autoAttacking; } + bool hasAutoAttackIntent() const { return autoAttackRequested_; } + bool isInCombat() const { return autoAttacking || !hostileAttackers_.empty(); } + bool isInCombatWith(uint64_t guid) const { + return guid != 0 && + ((autoAttacking && autoAttackTarget == guid) || + (hostileAttackers_.count(guid) > 0)); + } + uint64_t getAutoAttackTargetGuid() const { return autoAttackTarget; } bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); @@ -1359,6 +1367,7 @@ private: // ---- Phase 2: Combat ---- bool autoAttacking = false; + bool autoAttackRequested_ = false; // local intent (CMSG_ATTACKSWING sent) uint64_t autoAttackTarget = 0; bool autoAttackOutOfRange_ = false; float autoAttackResendTimer_ = 0.0f; // Re-send CMSG_ATTACKSWING every ~1s while attacking diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 58907aaa..02b43f6a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -89,6 +89,10 @@ bool isActiveExpansion(const char* expansionId) { return profile->id == expansionId; } +bool isClassicLikeExpansion() { + return isActiveExpansion("classic") || isActiveExpansion("turtle"); +} + std::string formatCopperAmount(uint32_t amount) { uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; @@ -667,7 +671,11 @@ void GameHandler::update(float deltaTime) { timeSinceLastPing = 0.0f; } - float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_) ? 0.25f : moveHeartbeatInterval_; + const bool classicLikeCombatSync = + autoAttackRequested_ && (isClassicLikeExpansion() || isActiveExpansion("tbc")); + float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_) + ? 0.25f + : (classicLikeCombatSync ? 0.05f : moveHeartbeatInterval_); if (timeSinceLastMoveHeartbeat_ >= heartbeatInterval) { sendMovement(Opcode::MSG_MOVE_HEARTBEAT); timeSinceLastMoveHeartbeat_ = 0.0f; @@ -865,28 +873,37 @@ void GameHandler::update(float deltaTime) { auto distanceStart = std::chrono::high_resolution_clock::now(); // Leave combat if auto-attack target is too far away (leash range) - // Also re-send CMSG_ATTACKSWING every second to resume after server SMSG_ATTACKSTOP - if (autoAttacking && autoAttackTarget != 0) { + // and keep melee intent tightly synced while stationary. + if (autoAttackRequested_ && autoAttackTarget != 0) { auto targetEntity = entityManager.getEntity(autoAttackTarget); if (targetEntity) { float dx = movementInfo.x - targetEntity->getX(); float dy = movementInfo.y - targetEntity->getY(); float dist = std::sqrt(dx * dx + dy * dy); + const bool classicLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (dist > 40.0f) { stopAutoAttack(); LOG_INFO("Left combat: target too far (", dist, " yards)"); } else if (state == WorldState::IN_WORLD && socket) { autoAttackResendTimer_ += deltaTime; autoAttackFacingSyncTimer_ += deltaTime; - if (autoAttackResendTimer_ >= 1.0f) { + + // Re-request swing more aggressively until server confirms active loop. + float resendInterval = 1.0f; + if (!autoAttacking || autoAttackOutOfRange_) { + resendInterval = classicLike ? 0.25f : 0.50f; + } + if (autoAttackResendTimer_ >= resendInterval) { autoAttackResendTimer_ = 0.0f; auto pkt = AttackSwingPacket::build(autoAttackTarget); socket->send(pkt); } + // Keep server-facing aligned with our current melee target. // Some vanilla-family realms become strict about front-arc checks unless // the client sends explicit facing updates while stationary. - if (autoAttackFacingSyncTimer_ >= 0.20f) { + const float facingSyncInterval = classicLike ? 0.10f : 0.20f; + if (autoAttackFacingSyncTimer_ >= facingSyncInterval) { autoAttackFacingSyncTimer_ = 0.0f; float toTargetX = targetEntity->getX() - movementInfo.x; float toTargetY = targetEntity->getY() - movementInfo.y; @@ -895,10 +912,16 @@ void GameHandler::update(float deltaTime) { float diff = desired - movementInfo.orientation; while (diff > static_cast(M_PI)) diff -= 2.0f * static_cast(M_PI); while (diff < -static_cast(M_PI)) diff += 2.0f * static_cast(M_PI); - if (std::abs(diff) > 0.12f) { // ~7 degrees + const float facingThreshold = classicLike ? 0.035f : 0.12f; // ~2deg / ~7deg + if (std::abs(diff) > facingThreshold) { movementInfo.orientation = desired; sendMovement(Opcode::MSG_MOVE_SET_FACING); + // Follow facing update with a heartbeat to tighten server range/facing checks. + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } + } else if (classicLike) { + // Keep stationary melee position/facing fresh for strict vanilla-family checks. + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } } } @@ -1521,6 +1544,35 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ATTACKSTOP: handleAttackStop(packet); break; + case Opcode::SMSG_ATTACKSWING_NOTINRANGE: + autoAttackOutOfRange_ = true; + if (autoAttackRequested_ && autoAttackTarget != 0 && socket) { + auto pkt = AttackSwingPacket::build(autoAttackTarget); + socket->send(pkt); + } + break; + case Opcode::SMSG_ATTACKSWING_BADFACING: + if (autoAttackRequested_ && autoAttackTarget != 0) { + auto targetEntity = entityManager.getEntity(autoAttackTarget); + if (targetEntity) { + float toTargetX = targetEntity->getX() - movementInfo.x; + float toTargetY = targetEntity->getY() - movementInfo.y; + if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) { + movementInfo.orientation = std::atan2(-toTargetY, toTargetX); + sendMovement(Opcode::MSG_MOVE_SET_FACING); + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); + } + } + if (socket) { + auto pkt = AttackSwingPacket::build(autoAttackTarget); + socket->send(pkt); + } + } + break; + case Opcode::SMSG_ATTACKSWING_NOTSTANDING: + case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: + autoAttackOutOfRange_ = false; + break; case Opcode::SMSG_ATTACKERSTATEUPDATE: handleAttackerStateUpdate(packet); break; @@ -7813,7 +7865,10 @@ void GameHandler::startAutoAttack(uint64_t targetGuid) { if (isMounted()) { dismount(); } - autoAttacking = true; + autoAttackRequested_ = true; + // Keep combat animation/state server-authoritative. We only flip autoAttacking + // on SMSG_ATTACKSTART where attackerGuid == playerGuid. + autoAttacking = false; autoAttackTarget = targetGuid; autoAttackOutOfRange_ = false; autoAttackResendTimer_ = 0.0f; @@ -7826,7 +7881,8 @@ void GameHandler::startAutoAttack(uint64_t targetGuid) { } void GameHandler::stopAutoAttack() { - if (!autoAttacking) return; + if (!autoAttacking && !autoAttackRequested_) return; + autoAttackRequested_ = false; autoAttacking = false; autoAttackTarget = 0; autoAttackOutOfRange_ = false; @@ -7871,6 +7927,7 @@ void GameHandler::handleAttackStart(network::Packet& packet) { if (!AttackStartParser::parse(packet, data)) return; if (data.attackerGuid == playerGuid) { + autoAttackRequested_ = true; autoAttacking = true; autoAttackTarget = data.victimGuid; } else if (data.victimGuid == playerGuid && data.attackerGuid != 0) { @@ -7906,12 +7963,16 @@ void GameHandler::handleAttackStop(network::Packet& packet) { AttackStopData data; if (!AttackStopParser::parse(packet, data)) return; - // Don't clear autoAttacking on SMSG_ATTACKSTOP - the server sends this - // when the attack loop pauses (out of range, etc). The player's intent - // to attack persists until target dies or player explicitly cancels. - // We'll re-send CMSG_ATTACKSWING periodically in the update loop. + // Keep intent, but clear server-confirmed active state until ATTACKSTART resumes. if (data.attackerGuid == playerGuid) { + autoAttacking = false; LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)"); + if (autoAttackRequested_ && autoAttackTarget != 0 && socket) { + // Classic-family servers may emit transient ATTACKSTOP when range/facing jitters. + // Reassert melee intent immediately instead of waiting for periodic resend. + auto pkt = AttackSwingPacket::build(autoAttackTarget); + socket->send(pkt); + } } else if (data.victimGuid == playerGuid) { hostileAttackers_.erase(data.attackerGuid); } @@ -7982,9 +8043,15 @@ void GameHandler::handleForceRunSpeedChange(network::Packet& packet) { // Always ACK the speed change to prevent server stall. // Packet format mirrors movement packets: packed guid + counter + movement info + new speed. - if (socket) { + if (socket && !isClassicLikeExpansion()) { network::Packet ack(wireOpcode(Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK)); - MovementPacket::writePackedGuid(ack, playerGuid); + const bool legacyGuidAck = + isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); + if (legacyGuidAck) { + ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for force speed ACKs + } else { + MovementPacket::writePackedGuid(ack, playerGuid); + } ack.writeUInt32(counter); MovementInfo wire = movementInfo; @@ -8055,13 +8122,19 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) movementInfo.flags &= ~static_cast(MovementFlags::ROOT); } - if (!socket) return; + if (!socket || isClassicLikeExpansion()) return; uint16_t ackWire = wireOpcode(rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK : Opcode::CMSG_FORCE_MOVE_UNROOT_ACK); if (ackWire == 0xFFFF) return; network::Packet ack(ackWire); - MovementPacket::writePackedGuid(ack, playerGuid); + const bool legacyGuidAck = + isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); + if (legacyGuidAck) { + ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for root/unroot ACKs + } else { + MovementPacket::writePackedGuid(ack, playerGuid); + } ack.writeUInt32(counter); MovementInfo wire = movementInfo; @@ -10927,24 +11000,14 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { // Send the ack back to the server // Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time - if (socket) { + if (socket && !isClassicLikeExpansion()) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK)); - // Write packed guid - uint8_t mask = 0; - uint8_t bytes[8]; - int byteCount = 0; - uint64_t g = playerGuid; - for (int i = 0; i < 8; i++) { - uint8_t b = static_cast(g & 0xFF); - g >>= 8; - if (b != 0) { - mask |= (1 << i); - bytes[byteCount++] = b; - } - } - ack.writeUInt8(mask); - for (int i = 0; i < byteCount; i++) { - ack.writeUInt8(bytes[i]); + const bool legacyGuidAck = + isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); + if (legacyGuidAck) { + ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for teleport ACK + } else { + MovementPacket::writePackedGuid(ack, playerGuid); } ack.writeUInt32(counter); ack.writeUInt32(moveTime); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index f68975fd..9b9de2fc 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -960,9 +960,11 @@ void Renderer::updateCharacterAnimation() { 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 - constexpr uint32_t ANIM_READY_UNARMED = 7; // Combat ready stance (unarmed) - constexpr uint32_t ANIM_READY_1H = 8; // Combat ready stance (1H weapon) - constexpr uint32_t ANIM_READY_2H = 9; // Combat ready stance (2H weapon) + // Canonical player ready stances (AnimationData.dbc) + constexpr uint32_t ANIM_READY_UNARMED = 22; // ReadyUnarmed + constexpr uint32_t ANIM_READY_1H = 23; // Ready1H + constexpr uint32_t ANIM_READY_2H = 24; // Ready2H + constexpr uint32_t ANIM_READY_2H_L = 25; // Ready2HL (some 2H left-handed rigs) constexpr uint32_t ANIM_FLY_IDLE = 158; // Flying mount idle/hover constexpr uint32_t ANIM_FLY_FORWARD = 159; // Flying mount forward @@ -1613,7 +1615,9 @@ void Renderer::updateCharacterAnimation() { break; case CharAnimState::MOUNT: animId = ANIM_MOUNT; loop = true; break; case CharAnimState::COMBAT_IDLE: - animId = pickFirstAvailable({ANIM_READY_1H, ANIM_READY_2H, ANIM_READY_UNARMED}, ANIM_STAND); + animId = pickFirstAvailable( + {ANIM_READY_1H, ANIM_READY_2H, ANIM_READY_2H_L, ANIM_READY_UNARMED}, + ANIM_STAND); loop = true; break; case CharAnimState::CHARGE: diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 332b0c03..d734b04d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -397,7 +397,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Update renderer face-target position and selection circle auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { - renderer->setInCombat(gameHandler.isAutoAttacking()); + renderer->setInCombat(gameHandler.isInCombat()); static glm::vec3 targetGLPos; if (gameHandler.hasTarget()) { auto target = gameHandler.getTarget(); @@ -1535,11 +1535,15 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); + const bool inCombatConfirmed = gameHandler.isInCombat(); + const bool attackIntentOnly = gameHandler.hasAutoAttackIntent() && !inCombatConfirmed; ImVec4 playerBorder = isDead ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) - : (gameHandler.isAutoAttacking() + : (inCombatConfirmed ? ImVec4(1.0f, 0.2f, 0.2f, 1.0f) - : ImVec4(0.4f, 0.4f, 0.4f, 1.0f)); + : (attackIntentOnly + ? ImVec4(1.0f, 0.7f, 0.2f, 1.0f) + : ImVec4(0.4f, 0.4f, 0.4f, 1.0f))); ImGui::PushStyleColor(ImGuiCol_Border, playerBorder); if (ImGui::Begin("##PlayerFrame", nullptr, flags)) { @@ -1678,18 +1682,19 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); - bool isHostileTarget = gameHandler.isHostileAttacker(target->getGuid()); - if (!isHostileTarget && target->getType() == game::ObjectType::UNIT) { - auto u = std::static_pointer_cast(target); - isHostileTarget = u->isHostile(); - } + const uint64_t targetGuid = target->getGuid(); + const bool confirmedCombatWithTarget = gameHandler.isInCombatWith(targetGuid); + const bool intentTowardTarget = + gameHandler.hasAutoAttackIntent() && + gameHandler.getAutoAttackTargetGuid() == targetGuid && + !confirmedCombatWithTarget; ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f); - if (isHostileTarget) { + if (confirmedCombatWithTarget) { float t = ImGui::GetTime(); float pulse = (std::fmod(t, 0.6f) < 0.3f) ? 1.0f : 0.0f; borderColor = ImVec4(1.0f, 0.1f, 0.1f, pulse); - } else if (gameHandler.isAutoAttacking()) { - borderColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + } else if (intentTowardTarget) { + borderColor = ImVec4(1.0f, 0.7f, 0.2f, 1.0f); } ImGui::PushStyleColor(ImGuiCol_Border, borderColor);