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.
This commit is contained in:
Kelsi 2026-02-20 03:38:12 -08:00
parent cb044e3985
commit eaba378b5b
4 changed files with 129 additions and 48 deletions

View file

@ -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<CombatTextEntry>& 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

View file

@ -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<float>(M_PI)) diff -= 2.0f * static_cast<float>(M_PI);
while (diff < -static_cast<float>(M_PI)) diff += 2.0f * static_cast<float>(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<uint32_t>(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<uint8_t>(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);

View file

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

View file

@ -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<game::Unit>(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);