From 5575fc6f28ec28ceefefcf82446913c71668ce81 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 09:29:02 -0700 Subject: [PATCH 01/38] fix(combatlog): preserve unknown source for environmental entries --- src/game/game_handler.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 16374768..138e3589 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14381,8 +14381,11 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint log.spellId = spellId; log.isPlayerSource = isPlayerSource; log.timestamp = std::time(nullptr); + // If the caller provided an explicit destination GUID but left source GUID as 0, + // preserve "unknown/no source" (e.g. environmental damage) instead of + // backfilling from current target. uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid - : (isPlayerSource ? playerGuid : targetGuid); + : ((dstGuid != 0) ? 0 : (isPlayerSource ? playerGuid : targetGuid)); uint64_t effectiveDst = (dstGuid != 0) ? dstGuid : (isPlayerSource ? targetGuid : playerGuid); log.sourceName = lookupName(effectiveSrc); From 5911b8eb01f386b0a207f0d26f398fede6f6fbed Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 09:36:42 -0700 Subject: [PATCH 02/38] fix(combatlog): show resisted amount from resist log packets --- src/game/game_handler.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 138e3589..1774c003 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7105,11 +7105,20 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); + int32_t resistedAmount = 0; + // Resist payload includes: + // float resistFactor + uint32 targetResistance + uint32 resistedValue. + // Some servers may truncate optional tail fields, so parse defensively. + if (rl_rem() >= 12) { + /*float resistFactor =*/ packet.readFloat(); + /*uint32_t targetRes =*/ packet.readUInt32(); + resistedAmount = static_cast(packet.readUInt32()); + } // Show RESIST when the player is involved on either side. if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, false, 0, attackerGuid, victimGuid); } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; From 753f4ef1be1ddc5dd24419dd9e248b6aa69c02df Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 09:44:52 -0700 Subject: [PATCH 03/38] fix(combatlog): map immune2 spell miss results correctly --- src/game/game_handler.cpp | 45 +++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1774c003..ba13a05f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -116,6 +116,23 @@ bool hasFullPackedGuid(const network::Packet& packet) { return packet.getSize() - packet.getReadPos() >= guidBytes; } +CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) { + switch (missInfo) { + case 0: return CombatTextEntry::MISS; + case 1: return CombatTextEntry::DODGE; + case 2: return CombatTextEntry::PARRY; + case 3: return CombatTextEntry::BLOCK; + case 4: return CombatTextEntry::EVADE; + case 5: return CombatTextEntry::IMMUNE; + case 6: return CombatTextEntry::DEFLECT; + case 7: return CombatTextEntry::ABSORB; + case 8: return CombatTextEntry::RESIST; + case 10: return CombatTextEntry::IMMUNE; // Seen on some cores as a secondary immune result. + case 11: return CombatTextEntry::REFLECT; + default: return CombatTextEntry::MISS; + } +} + std::string formatCopperAmount(uint32_t amount) { uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; @@ -2761,19 +2778,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } } - static const CombatTextEntry::Type missTypes[] = { - CombatTextEntry::MISS, // 0=MISS - CombatTextEntry::DODGE, // 1=DODGE - CombatTextEntry::PARRY, // 2=PARRY - CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::EVADE, // 4=EVADE - CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::DEFLECT, // 6=DEFLECT - CombatTextEntry::ABSORB, // 7=ABSORB - CombatTextEntry::RESIST, // 8=RESIST - }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] - : (missInfo == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); + CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo); if (casterGuid == playerGuid) { // We cast a spell and it missed the target addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid); @@ -17236,17 +17241,6 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Preserve spellId and actual participants for spell-go miss results. // This keeps the persistent combat log aligned with the later GUID fixes. if (!data.missTargets.empty()) { - static const CombatTextEntry::Type missTypes[] = { - CombatTextEntry::MISS, // 0=MISS - CombatTextEntry::DODGE, // 1=DODGE - CombatTextEntry::PARRY, // 2=PARRY - CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::EVADE, // 4=EVADE - CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::DEFLECT, // 6=DEFLECT - CombatTextEntry::ABSORB, // 7=ABSORB - CombatTextEntry::RESIST, // 8=RESIST - }; const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; const bool playerIsCaster = (spellCasterGuid == playerGuid); @@ -17254,8 +17248,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) { if (!playerIsCaster && m.targetGuid != playerGuid) { continue; } - CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] - : (m.missType == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); + CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(m.missType); addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } From a962422b12b16aa9fcbc3e132b57adb8a188e6c5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 09:52:33 -0700 Subject: [PATCH 04/38] fix(combatlog): map alternate immune2 spell miss value --- src/game/game_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ba13a05f..9cd0a385 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -127,7 +127,9 @@ CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) { case 6: return CombatTextEntry::DEFLECT; case 7: return CombatTextEntry::ABSORB; case 8: return CombatTextEntry::RESIST; - case 10: return CombatTextEntry::IMMUNE; // Seen on some cores as a secondary immune result. + case 9: // Some cores encode SPELL_MISS_IMMUNE2 as 9. + case 10: // Others encode SPELL_MISS_IMMUNE2 as 10. + return CombatTextEntry::IMMUNE; case 11: return CombatTextEntry::REFLECT; default: return CombatTextEntry::MISS; } From c90c8fb8cff27cc3bf1722941f207ad5f78d620a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:00:45 -0700 Subject: [PATCH 05/38] fix(combatlog): parse full spell miss target lists --- src/game/game_handler.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9cd0a385..fec1caaa 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2761,7 +2761,6 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); - count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u) || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { From 5ecc46623a3ac29a01f2b692acaa4304e0708bc9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:09:04 -0700 Subject: [PATCH 06/38] fix(combatlog): consume full spell go target lists when capped --- src/game/packet_parsers_classic.cpp | 47 +++++++++++++++-------------- src/game/packet_parsers_tbc.cpp | 47 +++++++++++++++-------------- src/game/world_packets.cpp | 46 ++++++++++++++-------------- 3 files changed, 74 insertions(+), 66 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 046839d0..4528ef72 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -410,33 +410,34 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da // Hit targets if (rem() < 1) return true; - data.hitCount = packet.readUInt8(); - // Cap hit count to prevent OOM from huge target lists - if (data.hitCount > 128) { - LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)data.hitCount, ")"); - data.hitCount = 128; + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); } - data.hitTargets.reserve(data.hitCount); - for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) { - data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); + const uint8_t storedHitLimit = std::min(rawHitCount, 128); + data.hitTargets.reserve(storedHitLimit); + for (uint16_t i = 0; i < rawHitCount && rem() >= 1; ++i) { + const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } } + data.hitCount = static_cast(data.hitTargets.size()); // Check if we read all expected hits - if (data.hitTargets.size() < data.hitCount) { + if (data.hitTargets.size() < rawHitCount) { LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(), - "/", (int)data.hitCount); - data.hitCount = data.hitTargets.size(); + "/", (int)rawHitCount); } // Miss targets if (rem() < 1) return true; - data.missCount = packet.readUInt8(); - // Cap miss count to prevent OOM - if (data.missCount > 128) { - LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)data.missCount, ")"); - data.missCount = 128; + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 128) { + LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); } - data.missTargets.reserve(data.missCount); - for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) { + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount && rem() >= 2; ++i) { SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); if (rem() < 1) break; @@ -446,13 +447,15 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da (void)packet.readUInt32(); (void)packet.readUInt8(); } - data.missTargets.push_back(m); + if (i < storedMissLimit) { + data.missTargets.push_back(m); + } } + data.missCount = static_cast(data.missTargets.size()); // Check if we read all expected misses - if (data.missTargets.size() < data.missCount) { + if (data.missTargets.size() < rawMissCount) { LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)data.missTargets.size(), - "/", (int)data.missCount); - data.missCount = data.missTargets.size(); + "/", (int)rawMissCount); } LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index d218926a..ba2393c3 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1277,32 +1277,33 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) return true; } - data.hitCount = packet.readUInt8(); - // Cap hit count to prevent OOM from huge target lists - if (data.hitCount > 128) { - LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)data.hitCount, ")"); - data.hitCount = 128; + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); } - data.hitTargets.reserve(data.hitCount); - for (uint8_t i = 0; i < data.hitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { - data.hitTargets.push_back(packet.readUInt64()); // full GUID in TBC + const uint8_t storedHitLimit = std::min(rawHitCount, 128); + data.hitTargets.reserve(storedHitLimit); + for (uint16_t i = 0; i < rawHitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { + const uint64_t targetGuid = packet.readUInt64(); // full GUID in TBC + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } } + data.hitCount = static_cast(data.hitTargets.size()); // Check if we read all expected hits - if (data.hitTargets.size() < data.hitCount) { + if (data.hitTargets.size() < rawHitCount) { LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(), - "/", (int)data.hitCount); - data.hitCount = data.hitTargets.size(); + "/", (int)rawHitCount); } if (packet.getReadPos() < packet.getSize()) { - data.missCount = packet.readUInt8(); - // Cap miss count to prevent OOM - if (data.missCount > 128) { - LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)data.missCount, ")"); - data.missCount = 128; + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 128) { + LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); } - data.missTargets.reserve(data.missCount); - for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) { + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) { SpellGoMissEntry m; m.targetGuid = packet.readUInt64(); // full GUID in TBC m.missType = packet.readUInt8(); @@ -1313,13 +1314,15 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) (void)packet.readUInt32(); (void)packet.readUInt8(); } - data.missTargets.push_back(m); + if (i < storedMissLimit) { + data.missTargets.push_back(m); + } } + data.missCount = static_cast(data.missTargets.size()); // Check if we read all expected misses - if (data.missTargets.size() < data.missCount) { + if (data.missTargets.size() < rawMissCount) { LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", (int)data.missTargets.size(), - "/", (int)data.missCount); - data.missCount = data.missTargets.size(); + "/", (int)rawMissCount); } } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 659003d7..686dba75 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3714,43 +3714,43 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { // Timestamp in 3.3.5a packet.readUInt32(); - data.hitCount = packet.readUInt8(); - // Cap hit count to prevent DoS via massive arrays - if (data.hitCount > 128) { - LOG_WARNING("Spell go: hitCount capped (requested=", (int)data.hitCount, ")"); - data.hitCount = 128; + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); } + const uint8_t storedHitLimit = std::min(rawHitCount, 128); - data.hitTargets.reserve(data.hitCount); - for (uint8_t i = 0; i < data.hitCount; ++i) { + data.hitTargets.reserve(storedHitLimit); + for (uint16_t i = 0; i < rawHitCount; ++i) { // WotLK hit targets are packed GUIDs, like the caster and miss targets. if (packet.getSize() - packet.getReadPos() < 1) { - LOG_WARNING("Spell go: truncated hit targets at index ", (int)i, "/", (int)data.hitCount); - data.hitCount = i; + LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount); break; } - data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); + const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } } + data.hitCount = static_cast(data.hitTargets.size()); // Validate missCount field exists if (packet.getSize() - packet.getReadPos() < 1) { return true; // Valid, just no misses } - data.missCount = packet.readUInt8(); - // Cap miss count to prevent DoS - if (data.missCount > 128) { - LOG_WARNING("Spell go: missCount capped (requested=", (int)data.missCount, ")"); - data.missCount = 128; + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 128) { + LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ")"); } + const uint8_t storedMissLimit = std::min(rawMissCount, 128); - data.missTargets.reserve(data.missCount); - for (uint8_t i = 0; i < data.missCount; ++i) { + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount; ++i) { // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. if (packet.getSize() - packet.getReadPos() < 2) { - LOG_WARNING("Spell go: truncated miss targets at index ", (int)i, "/", (int)data.missCount); - data.missCount = i; + LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount); break; } SpellGoMissEntry m; @@ -3758,15 +3758,17 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { m.missType = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; if (m.missType == 11) { if (packet.getSize() - packet.getReadPos() < 5) { - LOG_WARNING("Spell go: truncated reflect payload at miss index ", (int)i, "/", (int)data.missCount); - data.missCount = i; + LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); break; } (void)packet.readUInt32(); (void)packet.readUInt8(); } - data.missTargets.push_back(m); + if (i < storedMissLimit) { + data.missTargets.push_back(m); + } } + data.missCount = static_cast(data.missTargets.size()); LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); From 69ff91e9a25ad83dbd2ab585f224128792450b90 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:17:19 -0700 Subject: [PATCH 07/38] fix(combatlog): validate packed GUID bounds in spell cast parsers --- src/game/world_packets.cpp | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 686dba75..02c20a6c 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -19,6 +19,22 @@ namespace { inline uint16_t bswap16(uint16_t v) { return static_cast(((v & 0xFF00u) >> 8) | ((v & 0x00FFu) << 8)); } + + bool hasFullPackedGuid(const wowee::network::Packet& packet) { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; + } } namespace wowee { @@ -3684,7 +3700,7 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { // Read target flags and target (simplified) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t targetFlags = packet.readUInt32(); - if ((targetFlags & 0x02) && packet.getSize() - packet.getReadPos() >= 1) { // TARGET_FLAG_UNIT, validate packed GUID read + if ((targetFlags & 0x02) && hasFullPackedGuid(packet)) { // TARGET_FLAG_UNIT data.targetGuid = UpdateObjectParser::readPackedGuid(packet); } } @@ -3723,7 +3739,7 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.reserve(storedHitLimit); for (uint16_t i = 0; i < rawHitCount; ++i) { // WotLK hit targets are packed GUIDs, like the caster and miss targets. - if (packet.getSize() - packet.getReadPos() < 1) { + if (!hasFullPackedGuid(packet)) { LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount); break; } @@ -3749,13 +3765,17 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { for (uint16_t i = 0; i < rawMissCount; ++i) { // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. - if (packet.getSize() - packet.getReadPos() < 2) { + if (!hasFullPackedGuid(packet)) { LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount); break; } SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK - m.missType = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + if (packet.getSize() - packet.getReadPos() < 1) { + LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount); + break; + } + m.missType = packet.readUInt8(); if (m.missType == 11) { if (packet.getSize() - packet.getReadPos() < 5) { LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); From c9858655f69b1d73ceef27ddbde1fc8d75434fff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:25:07 -0700 Subject: [PATCH 08/38] fix(combatlog): validate packed guid bounds in classic spell cast parsers --- src/game/packet_parsers_classic.cpp | 30 ++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 4528ef72..3d3e1a92 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -358,8 +358,9 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 2) return false; + if (!hasFullPackedGuid(packet)) return false; data.casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (rem() < 1) return false; + if (!hasFullPackedGuid(packet)) return false; data.casterUnit = UpdateObjectParser::readPackedGuid(packet); // uint8 castCount + uint32 spellId + uint16 castFlags + uint32 castTime = 11 bytes @@ -373,7 +374,7 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa if (rem() < 2) return true; uint16_t targetFlags = packet.readUInt16(); // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID - if (((targetFlags & 0x02) || (targetFlags & 0x800)) && rem() >= 1) { + if (((targetFlags & 0x02) || (targetFlags & 0x800)) && hasFullPackedGuid(packet)) { data.targetGuid = UpdateObjectParser::readPackedGuid(packet); } @@ -398,8 +399,9 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 2) return false; + if (!hasFullPackedGuid(packet)) return false; data.casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (rem() < 1) return false; + if (!hasFullPackedGuid(packet)) return false; data.casterUnit = UpdateObjectParser::readPackedGuid(packet); // uint8 castCount + uint32 spellId + uint16 castFlags = 7 bytes @@ -416,16 +418,21 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da } const uint8_t storedHitLimit = std::min(rawHitCount, 128); data.hitTargets.reserve(storedHitLimit); - for (uint16_t i = 0; i < rawHitCount && rem() >= 1; ++i) { + uint16_t parsedHitCount = 0; + for (uint16_t i = 0; i < rawHitCount; ++i) { + if (!hasFullPackedGuid(packet)) { + break; + } const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + ++parsedHitCount; if (i < storedHitLimit) { data.hitTargets.push_back(targetGuid); } } data.hitCount = static_cast(data.hitTargets.size()); // Check if we read all expected hits - if (data.hitTargets.size() < rawHitCount) { - LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(), + if (parsedHitCount < rawHitCount) { + LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)parsedHitCount, "/", (int)rawHitCount); } @@ -437,7 +444,11 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); - for (uint16_t i = 0; i < rawMissCount && rem() >= 2; ++i) { + uint16_t parsedMissCount = 0; + for (uint16_t i = 0; i < rawMissCount; ++i) { + if (!hasFullPackedGuid(packet)) { + break; + } SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); if (rem() < 1) break; @@ -447,14 +458,15 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da (void)packet.readUInt32(); (void)packet.readUInt8(); } + ++parsedMissCount; if (i < storedMissLimit) { data.missTargets.push_back(m); } } data.missCount = static_cast(data.missTargets.size()); // Check if we read all expected misses - if (data.missTargets.size() < rawMissCount) { - LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)data.missTargets.size(), + if (parsedMissCount < rawMissCount) { + LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)parsedMissCount, "/", (int)rawMissCount); } From 4561eb86964ddc86262b8128a34bfe28469883db Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:33:48 -0700 Subject: [PATCH 09/38] fix(combatlog): validate packed GUID bounds in spell start parser --- src/game/world_packets.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 02c20a6c..cc04e449 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3683,7 +3683,14 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { if (packet.getSize() - packet.getReadPos() < 22) return false; size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); // Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 9 bytes) From 5a9be91fac8306f0e2671635caac926084882da5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:40:54 -0700 Subject: [PATCH 10/38] fix(combatlog): validate packed guid bounds in spell go parser --- src/game/world_packets.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index cc04e449..c52be440 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3722,7 +3722,14 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { if (packet.getSize() - packet.getReadPos() < 17) return false; size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); // Validate remaining fixed fields up to hitCount/missCount From ffa6dda4d9987be9ed90605b67e37b7f1f3b632f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:48:20 -0700 Subject: [PATCH 11/38] fix(combatlog): validate packed GUID bounds in attacker state parsers --- src/game/packet_parsers_classic.cpp | 10 +++++++++- src/game/world_packets.cpp | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 3d3e1a92..e99329b3 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -489,9 +489,17 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 5) return false; // hitInfo(4) + at least GUID mask byte(1) + const size_t startPos = packet.getReadPos(); data.hitInfo = packet.readUInt32(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla if (rem() < 5) return false; // int32 totalDamage + uint8 subDamageCount diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c52be440..82126379 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3343,7 +3343,15 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda size_t startPos = packet.getReadPos(); data.hitInfo = packet.readUInt32(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // Validate totalDamage + subDamageCount can be read (5 bytes) From 918762501f5de7db655183e641acbe611fa71632 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 10:56:04 -0700 Subject: [PATCH 12/38] fix(combatlog): fail spell go parse on truncated target lists --- src/game/world_packets.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 82126379..c176650e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3758,11 +3758,14 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } const uint8_t storedHitLimit = std::min(rawHitCount, 128); + bool truncatedTargets = false; + data.hitTargets.reserve(storedHitLimit); for (uint16_t i = 0; i < rawHitCount; ++i) { // WotLK hit targets are packed GUIDs, like the caster and miss targets. if (!hasFullPackedGuid(packet)) { LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount); + truncatedTargets = true; break; } const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); @@ -3770,6 +3773,10 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.push_back(targetGuid); } } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } data.hitCount = static_cast(data.hitTargets.size()); // Validate missCount field exists @@ -3789,18 +3796,21 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. if (!hasFullPackedGuid(packet)) { LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount); + truncatedTargets = true; break; } SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK if (packet.getSize() - packet.getReadPos() < 1) { LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount); + truncatedTargets = true; break; } m.missType = packet.readUInt8(); if (m.missType == 11) { if (packet.getSize() - packet.getReadPos() < 5) { LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); + truncatedTargets = true; break; } (void)packet.readUInt32(); @@ -3810,6 +3820,10 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.push_back(m); } } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } data.missCount = static_cast(data.missTargets.size()); LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, From e0ac81450d1630679eef71dec8e0ee23f7d10bff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 11:03:02 -0700 Subject: [PATCH 13/38] fix(combatlog): enforce full spell start fixed-field bounds --- src/game/world_packets.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c176650e..25dc79d2 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3701,8 +3701,8 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); - // Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 9 bytes) - if (packet.getSize() - packet.getReadPos() < 9) { + // Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 13 bytes) + if (packet.getSize() - packet.getReadPos() < 13) { packet.setReadPos(startPos); return false; } From 6b290009aa0034f101bd25e8f84cbcabc9f9afe3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 11:10:08 -0700 Subject: [PATCH 14/38] fix(combatlog): fail classic and tbc spell go parse on truncation --- src/game/packet_parsers_classic.cpp | 44 ++++++++++++++++++----------- src/game/packet_parsers_tbc.cpp | 39 +++++++++++++++++-------- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index e99329b3..995f109b 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -397,6 +397,7 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa // ============================================================================ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + const size_t startPos = packet.getReadPos(); if (rem() < 2) return false; if (!hasFullPackedGuid(packet)) return false; @@ -418,23 +419,24 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da } const uint8_t storedHitLimit = std::min(rawHitCount, 128); data.hitTargets.reserve(storedHitLimit); - uint16_t parsedHitCount = 0; + bool truncatedTargets = false; for (uint16_t i = 0; i < rawHitCount; ++i) { if (!hasFullPackedGuid(packet)) { + LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", i, + "/", (int)rawHitCount); + truncatedTargets = true; break; } const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); - ++parsedHitCount; if (i < storedHitLimit) { data.hitTargets.push_back(targetGuid); } } - data.hitCount = static_cast(data.hitTargets.size()); - // Check if we read all expected hits - if (parsedHitCount < rawHitCount) { - LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)parsedHitCount, - "/", (int)rawHitCount); + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; } + data.hitCount = static_cast(data.hitTargets.size()); // Miss targets if (rem() < 1) return true; @@ -444,31 +446,41 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); - uint16_t parsedMissCount = 0; for (uint16_t i = 0; i < rawMissCount; ++i) { if (!hasFullPackedGuid(packet)) { + LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; break; } SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); - if (rem() < 1) break; + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: missing missType at miss index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; + break; + } m.missType = packet.readUInt8(); if (m.missType == 11) { - if (rem() < 5) break; + if (rem() < 5) { + LOG_WARNING("[Classic] Spell go: truncated reflect payload at miss index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; + break; + } (void)packet.readUInt32(); (void)packet.readUInt8(); } - ++parsedMissCount; if (i < storedMissLimit) { data.missTargets.push_back(m); } } - data.missCount = static_cast(data.missTargets.size()); - // Check if we read all expected misses - if (parsedMissCount < rawMissCount) { - LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)parsedMissCount, - "/", (int)rawMissCount); + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; } + data.missCount = static_cast(data.missTargets.size()); LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ba2393c3..34f5b283 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1261,6 +1261,7 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& // WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags. // ============================================================================ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + const size_t startPos = packet.getReadPos(); // Fixed header before hit/miss lists: // casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) if (packet.getSize() - packet.getReadPos() < 25) return false; @@ -1283,18 +1284,24 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) } const uint8_t storedHitLimit = std::min(rawHitCount, 128); data.hitTargets.reserve(storedHitLimit); - for (uint16_t i = 0; i < rawHitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { + bool truncatedTargets = false; + for (uint16_t i = 0; i < rawHitCount; ++i) { + if (packet.getReadPos() + 8 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", i, + "/", (int)rawHitCount); + truncatedTargets = true; + break; + } const uint64_t targetGuid = packet.readUInt64(); // full GUID in TBC if (i < storedHitLimit) { data.hitTargets.push_back(targetGuid); } } - data.hitCount = static_cast(data.hitTargets.size()); - // Check if we read all expected hits - if (data.hitTargets.size() < rawHitCount) { - LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(), - "/", (int)rawHitCount); + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; } + data.hitCount = static_cast(data.hitTargets.size()); if (packet.getReadPos() < packet.getSize()) { const uint8_t rawMissCount = packet.readUInt8(); @@ -1303,12 +1310,21 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); - for (uint16_t i = 0; i < rawMissCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) { + for (uint16_t i = 0; i < rawMissCount; ++i) { + if (packet.getReadPos() + 9 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; + break; + } SpellGoMissEntry m; m.targetGuid = packet.readUInt64(); // full GUID in TBC m.missType = packet.readUInt8(); if (m.missType == 11) { if (packet.getReadPos() + 5 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; break; } (void)packet.readUInt32(); @@ -1318,12 +1334,11 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) data.missTargets.push_back(m); } } - data.missCount = static_cast(data.missTargets.size()); - // Check if we read all expected misses - if (data.missTargets.size() < rawMissCount) { - LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", (int)data.missTargets.size(), - "/", (int)rawMissCount); + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; } + data.missCount = static_cast(data.missTargets.size()); } LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, From f0ba85fa80d87c09c52b90486f5a4ed8bfc0800e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 13:21:38 -0700 Subject: [PATCH 15/38] fix(combatlog): reset spell go parser output before decode --- src/game/packet_parsers_classic.cpp | 3 +++ src/game/packet_parsers_tbc.cpp | 3 +++ src/game/world_packets.cpp | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 995f109b..54fb7f95 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -396,6 +396,9 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa // + uint8(missCount) + [PackedGuid(missTarget) + uint8(missType)] × missCount // ============================================================================ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + // Always reset output to avoid stale targets when callers reuse buffers. + data = SpellGoData{}; + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; const size_t startPos = packet.getReadPos(); if (rem() < 2) return false; diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 34f5b283..16f1ffed 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1261,6 +1261,9 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& // WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags. // ============================================================================ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + // Always reset output to avoid stale targets when callers reuse buffers. + data = SpellGoData{}; + const size_t startPos = packet.getReadPos(); // Fixed header before hit/miss lists: // casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 25dc79d2..f057f90b 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3725,6 +3725,9 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { } bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { + // Always reset output to avoid stale targets when callers reuse buffers. + data = SpellGoData{}; + // Packed GUIDs are variable-length, so only require the smallest possible // shape up front: 2 GUID masks + fixed fields through missCount. if (packet.getSize() - packet.getReadPos() < 17) return false; From b24da8463c4d68a02225db5896961902e1d5ea94 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 13:29:55 -0700 Subject: [PATCH 16/38] fix(combatlog): avoid partial spell miss log entries on truncation --- src/game/game_handler.cpp | 44 ++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fec1caaa..c7c69c26 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2760,25 +2760,55 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); - uint32_t count = packet.readUInt32(); - for (uint32_t i = 0; i < count; ++i) { + const uint32_t rawCount = packet.readUInt32(); + if (rawCount > 128) { + LOG_WARNING("SMSG_SPELLLOGMISS: miss count capped (requested=", rawCount, ")"); + } + const uint32_t storedLimit = std::min(rawCount, 128u); + + struct SpellMissLogEntry { + uint64_t victimGuid = 0; + uint8_t missInfo = 0; + }; + std::vector parsedMisses; + parsedMisses.reserve(storedLimit); + + bool truncated = false; + for (uint32_t i = 0; i < rawCount; ++i) { if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u) || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { - packet.setReadPos(packet.getSize()); break; + truncated = true; + break; } - uint64_t victimGuid = readSpellMissGuid(); - if (packet.getSize() - packet.getReadPos() < 1) break; - uint8_t missInfo = packet.readUInt8(); + const uint64_t victimGuid = readSpellMissGuid(); + if (packet.getSize() - packet.getReadPos() < 1) { + truncated = true; + break; + } + const uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult if (missInfo == 11) { if (packet.getSize() - packet.getReadPos() >= 5) { /*uint32_t reflectSpellId =*/ packet.readUInt32(); /*uint8_t reflectResult =*/ packet.readUInt8(); } else { - packet.setReadPos(packet.getSize()); + truncated = true; break; } } + if (i < storedLimit) { + parsedMisses.push_back({victimGuid, missInfo}); + } + } + + if (truncated) { + packet.setReadPos(packet.getSize()); + break; + } + + for (const auto& miss : parsedMisses) { + const uint64_t victimGuid = miss.victimGuid; + const uint8_t missInfo = miss.missInfo; CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo); if (casterGuid == playerGuid) { // We cast a spell and it missed the target From bcfdcce062840b562aaf57f1ad26685af1589545 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 13:37:28 -0700 Subject: [PATCH 17/38] fix(combatlog): reject truncated spell go packets missing counts --- src/game/packet_parsers_classic.cpp | 16 +++++-- src/game/packet_parsers_tbc.cpp | 67 ++++++++++++++++------------- src/game/world_packets.cpp | 6 ++- 3 files changed, 52 insertions(+), 37 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 54fb7f95..1e801b6b 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -414,8 +414,12 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da data.spellId = packet.readUInt32(); data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) - // Hit targets - if (rem() < 1) return true; + // hitCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: missing hitCount after fixed fields"); + packet.setReadPos(startPos); + return false; + } const uint8_t rawHitCount = packet.readUInt8(); if (rawHitCount > 128) { LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); @@ -441,8 +445,12 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da } data.hitCount = static_cast(data.hitTargets.size()); - // Miss targets - if (rem() < 1) return true; + // missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: missing missCount after hit target list"); + packet.setReadPos(startPos); + return false; + } const uint8_t rawMissCount = packet.readUInt8(); if (rawMissCount > 128) { LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 16f1ffed..ca36930a 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1277,8 +1277,9 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) // NOTE: NO timestamp field here in TBC (WotLK added packet.readUInt32()) if (packet.getReadPos() >= packet.getSize()) { - LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " (no hit data)"); - return true; + LOG_WARNING("[TBC] Spell go: missing hitCount after fixed fields"); + packet.setReadPos(startPos); + return false; } const uint8_t rawHitCount = packet.readUInt8(); @@ -1306,43 +1307,47 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) } data.hitCount = static_cast(data.hitTargets.size()); - if (packet.getReadPos() < packet.getSize()) { - const uint8_t rawMissCount = packet.readUInt8(); - if (rawMissCount > 128) { - LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("[TBC] Spell go: missing missCount after hit target list"); + packet.setReadPos(startPos); + return false; + } + + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 128) { + LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); + } + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount; ++i) { + if (packet.getReadPos() + 9 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; + break; } - const uint8_t storedMissLimit = std::min(rawMissCount, 128); - data.missTargets.reserve(storedMissLimit); - for (uint16_t i = 0; i < rawMissCount; ++i) { - if (packet.getReadPos() + 9 > packet.getSize()) { - LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", i, + SpellGoMissEntry m; + m.targetGuid = packet.readUInt64(); // full GUID in TBC + m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (packet.getReadPos() + 5 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); truncatedTargets = true; break; } - SpellGoMissEntry m; - m.targetGuid = packet.readUInt64(); // full GUID in TBC - m.missType = packet.readUInt8(); - if (m.missType == 11) { - if (packet.getReadPos() + 5 > packet.getSize()) { - LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i, - "/", (int)rawMissCount); - truncatedTargets = true; - break; - } - (void)packet.readUInt32(); - (void)packet.readUInt8(); - } - if (i < storedMissLimit) { - data.missTargets.push_back(m); - } + (void)packet.readUInt32(); + (void)packet.readUInt8(); } - if (truncatedTargets) { - packet.setReadPos(startPos); - return false; + if (i < storedMissLimit) { + data.missTargets.push_back(m); } - data.missCount = static_cast(data.missTargets.size()); } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } + data.missCount = static_cast(data.missTargets.size()); LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index f057f90b..5a23e1c9 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3782,9 +3782,11 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } data.hitCount = static_cast(data.hitTargets.size()); - // Validate missCount field exists + // missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. if (packet.getSize() - packet.getReadPos() < 1) { - return true; // Valid, just no misses + LOG_WARNING("Spell go: missing missCount after hit target list"); + packet.setReadPos(startPos); + return false; } const uint8_t rawMissCount = packet.readUInt8(); From 24a63beb3ce61485b9c723cbc63af94da67080d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 13:44:37 -0700 Subject: [PATCH 18/38] fix(combatlog): reject truncated spell start target GUIDs --- src/game/packet_parsers_classic.cpp | 3 ++- src/game/packet_parsers_tbc.cpp | 6 +++++- src/game/world_packets.cpp | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 1e801b6b..2dde86c7 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -374,7 +374,8 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa if (rem() < 2) return true; uint16_t targetFlags = packet.readUInt16(); // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID - if (((targetFlags & 0x02) || (targetFlags & 0x800)) && hasFullPackedGuid(packet)) { + if ((targetFlags & 0x02) || (targetFlags & 0x800)) { + if (!hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ca36930a..f12e86e5 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1245,7 +1245,11 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t targetFlags = packet.readUInt32(); - if ((targetFlags & 0x02) && packet.getReadPos() + 8 <= packet.getSize()) { + const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT + if (needsTargetGuid) { + if (packet.getReadPos() + 8 > packet.getSize()) { + return false; + } data.targetGuid = packet.readUInt64(); // full GUID in TBC } } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5a23e1c9..616e9633 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3715,7 +3715,12 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { // Read target flags and target (simplified) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t targetFlags = packet.readUInt32(); - if ((targetFlags & 0x02) && hasFullPackedGuid(packet)) { // TARGET_FLAG_UNIT + const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT + if (needsTargetGuid) { + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); } } From 6ccfdc9d11c6f4c9a72b792d785cf48f4cfb1f14 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 13:51:37 -0700 Subject: [PATCH 19/38] fix(combatlog): validate packed GUID bounds in spell damage/heal logs --- src/game/world_packets.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 616e9633..71252846 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3427,7 +3427,15 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da if (packet.getSize() - packet.getReadPos() < 30) return false; size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // Validate core fields (spellId + damage + overkill + schoolMask + absorbed + resisted = 21 bytes) @@ -3469,7 +3477,15 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) if (packet.getSize() - packet.getReadPos() < 21) return false; size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // Validate remaining fields (spellId + heal + overheal + absorbed + critFlag = 17 bytes) From c9467778dcaf6e5402ccff667ca0cccbbc7ce33a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 13:59:07 -0700 Subject: [PATCH 20/38] fix(combatlog): enforce TBC spell damage/heal packet bounds --- src/game/packet_parsers_tbc.cpp | 46 ++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index f12e86e5..b988eb66 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1436,26 +1436,38 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke // TBC uses full uint64 GUIDs; WotLK uses packed GUIDs. // ============================================================================ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { - if (packet.getSize() - packet.getReadPos() < 29) return false; + // Fixed TBC payload size: + // targetGuid(8) + attackerGuid(8) + spellId(4) + damage(4) + schoolMask(1) + // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) + // = 43 bytes + if (packet.getSize() - packet.getReadPos() < 43) return false; - data.targetGuid = packet.readUInt64(); // full GUID in TBC + data = SpellDamageLogData{}; + + const size_t startPos = packet.getReadPos(); + data.targetGuid = packet.readUInt64(); // full GUID in TBC data.attackerGuid = packet.readUInt64(); // full GUID in TBC - data.spellId = packet.readUInt32(); - data.damage = packet.readUInt32(); - data.schoolMask = packet.readUInt8(); - data.absorbed = packet.readUInt32(); - data.resisted = packet.readUInt32(); + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); uint8_t periodicLog = packet.readUInt8(); (void)periodicLog; - packet.readUInt8(); // unused - packet.readUInt32(); // blocked + packet.readUInt8(); // unused + packet.readUInt32(); // blocked uint32_t flags = packet.readUInt32(); data.isCrit = (flags & 0x02) != 0; // TBC does not have an overkill field here data.overkill = 0; + if (packet.getReadPos() - startPos != 43) { + packet.setReadPos(startPos); + return false; + } + LOG_DEBUG("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, data.isCrit ? " CRIT" : ""); return true; @@ -1467,13 +1479,17 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL // TBC uses full uint64 GUIDs; WotLK uses packed GUIDs. // ============================================================================ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { - if (packet.getSize() - packet.getReadPos() < 25) return false; + // Fixed payload is 28 bytes; many cores append crit flag (1 byte). + // targetGuid(8) + casterGuid(8) + spellId(4) + heal(4) + overheal(4) + if (packet.getSize() - packet.getReadPos() < 28) return false; - data.targetGuid = packet.readUInt64(); // full GUID in TBC - data.casterGuid = packet.readUInt64(); // full GUID in TBC - data.spellId = packet.readUInt32(); - data.heal = packet.readUInt32(); - data.overheal = packet.readUInt32(); + data = SpellHealLogData{}; + + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.casterGuid = packet.readUInt64(); // full GUID in TBC + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); // TBC has no absorbed field in SMSG_SPELLHEALLOG; skip crit flag if (packet.getReadPos() < packet.getSize()) { uint8_t critFlag = packet.readUInt8(); From 80d59a80aa9a758622bc2914c8bdbf06635e0367 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:06:19 -0700 Subject: [PATCH 21/38] fix(combatlog): relax packed GUID minimum-size gates --- src/game/world_packets.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 71252846..4656f086 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3703,8 +3703,9 @@ bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { } bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { - // Upfront validation: packed GUID(1-8) + packed GUID(1-8) + castCount(1) + spellId(4) + castFlags(4) + castTime(4) = 22 bytes minimum - if (packet.getSize() - packet.getReadPos() < 22) return false; + // Packed GUIDs are variable-length; only require minimal packet shape up front: + // two GUID masks + castCount(1) + spellId(4) + castFlags(4) + castTime(4). + if (packet.getSize() - packet.getReadPos() < 15) return false; size_t startPos = packet.getReadPos(); if (!hasFullPackedGuid(packet)) { @@ -3750,8 +3751,8 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data = SpellGoData{}; // Packed GUIDs are variable-length, so only require the smallest possible - // shape up front: 2 GUID masks + fixed fields through missCount. - if (packet.getSize() - packet.getReadPos() < 17) return false; + // shape up front: 2 GUID masks + fixed fields through hitCount. + if (packet.getSize() - packet.getReadPos() < 16) return false; size_t startPos = packet.getReadPos(); if (!hasFullPackedGuid(packet)) { From 90bc9118f989479e9d3b3babe5ceca36b40f9867 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:13:39 -0700 Subject: [PATCH 22/38] fix(combatlog): validate packed GUID bounds in spell energize log --- src/game/game_handler.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c7c69c26..f629002c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4126,11 +4126,13 @@ void GameHandler::handlePacket(network::Packet& packet) { return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u) + || (!energizeTbc && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = readEnergizeGuid(); - if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u) + || (!energizeTbc && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = readEnergizeGuid(); From 71e34e41b786cf975f4a485a295f2cd70ac5e1df Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:21:55 -0700 Subject: [PATCH 23/38] fix(combatlog): clamp attacker-state subdamage count to payload --- src/game/world_packets.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4656f086..b8e3ad95 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3363,15 +3363,15 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); - // Cap subDamageCount: each entry is 20 bytes. If the claimed count + // Cap subDamageCount: each entry is 20 bytes. If the claimed count // exceeds what the remaining bytes can hold, a GUID was mis-parsed // (off by one byte), causing the school-mask byte to be read as count. - // In that case silently clamp to the number of full entries that fit. + // In that case clamp to the number of full entries that fit. { size_t remaining = packet.getSize() - packet.getReadPos(); size_t maxFit = remaining / 20; if (data.subDamageCount > maxFit) { - data.subDamageCount = static_cast(maxFit > 0 ? 1 : 0); + data.subDamageCount = static_cast(std::min(maxFit, 64)); } else if (data.subDamageCount > 64) { data.subDamageCount = 64; } From 4d4e5ed3b97078121a3e8fcb3fbca09cf549e21d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:29:09 -0700 Subject: [PATCH 24/38] fix(combatlog): enforce TBC attacker-state packet bounds --- src/game/packet_parsers_tbc.cpp | 37 +++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index b988eb66..1343b4c5 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1399,15 +1399,33 @@ bool TbcPacketParsers::parseCastFailed(network::Packet& packet, CastFailedData& // would mis-parse TBC's GUIDs and corrupt all subsequent damage fields. // ============================================================================ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { - if (packet.getSize() - packet.getReadPos() < 21) return false; + data = AttackerStateUpdateData{}; - data.hitInfo = packet.readUInt32(); - data.attackerGuid = packet.readUInt64(); // full GUID in TBC - data.targetGuid = packet.readUInt64(); // full GUID in TBC - data.totalDamage = static_cast(packet.readUInt32()); + const size_t startPos = packet.getReadPos(); + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + + // Fixed fields before sub-damage list: + // hitInfo(4) + attackerGuid(8) + targetGuid(8) + totalDamage(4) + subDamageCount(1) = 25 bytes + if (rem() < 25) return false; + + data.hitInfo = packet.readUInt32(); + data.attackerGuid = packet.readUInt64(); // full GUID in TBC + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); + // Clamp to what can fit in the remaining payload (20 bytes per sub-damage entry). + const uint8_t maxSubDamageCount = static_cast(std::min(rem() / 20, 64)); + if (data.subDamageCount > maxSubDamageCount) { + data.subDamageCount = maxSubDamageCount; + } + + data.subDamages.reserve(data.subDamageCount); for (uint8_t i = 0; i < data.subDamageCount; ++i) { + if (rem() < 20) { + packet.setReadPos(startPos); + return false; + } SubDamage sub; sub.schoolMask = packet.readUInt32(); sub.damage = packet.readFloat(); @@ -1417,10 +1435,17 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke data.subDamages.push_back(sub); } + data.subDamageCount = static_cast(data.subDamages.size()); + + // victimState + overkill are part of the expected payload. + if (rem() < 8) { + packet.setReadPos(startPos); + return false; + } data.victimState = packet.readUInt32(); data.overkill = static_cast(packet.readUInt32()); - if (packet.getReadPos() < packet.getSize()) { + if (rem() >= 4) { data.blocked = packet.readUInt32(); } From f4ecef2ec5f0701ecd89b2137f817a785fe2c208 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:35:57 -0700 Subject: [PATCH 25/38] fix(combatlog): reject truncated classic attacker-state packets --- src/game/packet_parsers_classic.cpp | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 2dde86c7..2a8b5268 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -510,6 +510,8 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da // + uint32(victimState) + int32(overkill) [+ uint32(blocked)] // ============================================================================ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { + data = AttackerStateUpdateData{}; + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 5) return false; // hitInfo(4) + at least GUID mask byte(1) @@ -526,11 +528,24 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 5) return false; // int32 totalDamage + uint8 subDamageCount + if (rem() < 5) { + packet.setReadPos(startPos); + return false; + } // int32 totalDamage + uint8 subDamageCount data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); - for (uint8_t i = 0; i < data.subDamageCount && rem() >= 20; ++i) { + const uint8_t maxSubDamageCount = static_cast(std::min(rem() / 20, 64)); + if (data.subDamageCount > maxSubDamageCount) { + data.subDamageCount = maxSubDamageCount; + } + + data.subDamages.reserve(data.subDamageCount); + for (uint8_t i = 0; i < data.subDamageCount; ++i) { + if (rem() < 20) { + packet.setReadPos(startPos); + return false; + } SubDamage sub; sub.schoolMask = packet.readUInt32(); sub.damage = packet.readFloat(); @@ -539,8 +554,12 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att sub.resisted = packet.readUInt32(); data.subDamages.push_back(sub); } + data.subDamageCount = static_cast(data.subDamages.size()); - if (rem() < 8) return true; + if (rem() < 8) { + packet.setReadPos(startPos); + return false; + } data.victimState = packet.readUInt32(); data.overkill = static_cast(packet.readUInt32()); From 83a368aa858c3edf37cade5f313f2bd067e39caf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:43:15 -0700 Subject: [PATCH 26/38] fix(combatlog): reject spell start packets missing target flags --- src/game/packet_parsers_classic.cpp | 24 ++++++++++++++++++++---- src/game/packet_parsers_tbc.cpp | 23 +++++++++++++++-------- src/game/world_packets.cpp | 26 ++++++++++++++++---------- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 2a8b5268..74935162 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -355,12 +355,21 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo // + uint16(targetFlags) [+ PackedGuid(unitTarget) if TARGET_FLAG_UNIT] // ============================================================================ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) { + data = SpellStartData{}; + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + const size_t startPos = packet.getReadPos(); if (rem() < 2) return false; - if (!hasFullPackedGuid(packet)) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (!hasFullPackedGuid(packet)) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); // uint8 castCount + uint32 spellId + uint16 castFlags + uint32 castTime = 11 bytes @@ -371,11 +380,18 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa data.castTime = packet.readUInt32(); // SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK) - if (rem() < 2) return true; + if (rem() < 2) { + LOG_WARNING("[Classic] Spell start: missing targetFlags"); + packet.setReadPos(startPos); + return false; + } uint16_t targetFlags = packet.readUInt16(); // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID if ((targetFlags & 0x02) || (targetFlags & 0x800)) { - if (!hasFullPackedGuid(packet)) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 1343b4c5..d22b0584 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1234,6 +1234,8 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector packet.getSize()) { - return false; - } - data.targetGuid = packet.readUInt64(); // full GUID in TBC + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("[TBC] Spell start: missing targetFlags"); + packet.setReadPos(startPos); + return false; + } + + uint32_t targetFlags = packet.readUInt32(); + const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT + if (needsTargetGuid) { + if (packet.getReadPos() + 8 > packet.getSize()) { + packet.setReadPos(startPos); + return false; } + data.targetGuid = packet.readUInt64(); // full GUID in TBC } LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index b8e3ad95..f5da2e5f 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3703,6 +3703,8 @@ bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { } bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { + data = SpellStartData{}; + // Packed GUIDs are variable-length; only require minimal packet shape up front: // two GUID masks + castCount(1) + spellId(4) + castFlags(4) + castTime(4). if (packet.getSize() - packet.getReadPos() < 15) return false; @@ -3729,17 +3731,21 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { data.castFlags = packet.readUInt32(); data.castTime = packet.readUInt32(); - // Read target flags and target (simplified) - if (packet.getSize() - packet.getReadPos() >= 4) { - uint32_t targetFlags = packet.readUInt32(); - const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT - if (needsTargetGuid) { - if (!hasFullPackedGuid(packet)) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + // SpellCastTargets starts with target flags and is mandatory. + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("Spell start: missing targetFlags"); + packet.setReadPos(startPos); + return false; + } + + uint32_t targetFlags = packet.readUInt32(); + const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT + if (needsTargetGuid) { + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; } + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); } LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); From 385ac1e66c4d4690a002558d43271663b9116525 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:51:27 -0700 Subject: [PATCH 27/38] fix(combatlog): reject truncated instakill logs without spell id --- src/game/game_handler.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f629002c..4780c372 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6527,7 +6527,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } uint64_t ikVictim = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; + if (ik_rem() < 4) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t ikSpell = packet.readUInt32(); // Show kill/death feedback for the local player if (ikCaster == playerGuid) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); From 5c8a2afa3526810e56037a4e394e4fc6b979642c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 14:59:24 -0700 Subject: [PATCH 28/38] fix(combatlog): accept extended TBC spell damage payloads --- src/game/packet_parsers_tbc.cpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index d22b0584..308873f1 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1472,11 +1472,12 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL // targetGuid(8) + attackerGuid(8) + spellId(4) + damage(4) + schoolMask(1) // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) // = 43 bytes + // Some servers append additional trailing fields; consume the canonical minimum + // and leave any extension bytes unread. if (packet.getSize() - packet.getReadPos() < 43) return false; data = SpellDamageLogData{}; - const size_t startPos = packet.getReadPos(); data.targetGuid = packet.readUInt64(); // full GUID in TBC data.attackerGuid = packet.readUInt64(); // full GUID in TBC data.spellId = packet.readUInt32(); @@ -1495,11 +1496,6 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL // TBC does not have an overkill field here data.overkill = 0; - if (packet.getReadPos() - startPos != 43) { - packet.setReadPos(startPos); - return false; - } - LOG_DEBUG("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, data.isCrit ? " CRIT" : ""); return true; From f07b730473d04c62797b87d22d541353aee71ce2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 15:06:29 -0700 Subject: [PATCH 29/38] fix(combatlog): reject truncated resist logs --- src/game/game_handler.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4780c372..40c4d34b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7146,19 +7146,18 @@ void GameHandler::handlePacket(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); - int32_t resistedAmount = 0; // Resist payload includes: // float resistFactor + uint32 targetResistance + uint32 resistedValue. - // Some servers may truncate optional tail fields, so parse defensively. - if (rl_rem() >= 12) { - /*float resistFactor =*/ packet.readFloat(); - /*uint32_t targetRes =*/ packet.readUInt32(); - resistedAmount = static_cast(packet.readUInt32()); - } + // Require the full payload so truncated packets cannot synthesize + // zero-value resist events. + if (rl_rem() < 12) { packet.setReadPos(packet.getSize()); break; } + /*float resistFactor =*/ packet.readFloat(); + /*uint32_t targetRes =*/ packet.readUInt32(); + int32_t resistedAmount = static_cast(packet.readUInt32()); // Show RESIST when the player is involved on either side. - if (victimGuid == playerGuid) { + if (resistedAmount > 0 && victimGuid == playerGuid) { addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, false, 0, attackerGuid, victimGuid); - } else if (attackerGuid == playerGuid) { + } else if (resistedAmount > 0 && attackerGuid == playerGuid) { addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); From f57893a4599199715751fc699ebfcaa3bada3bc8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 15:13:49 -0700 Subject: [PATCH 30/38] fix(combatlog): reject truncated spell damage log tails --- src/game/world_packets.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index f5da2e5f..00229c0c 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3423,8 +3423,11 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda } bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& data) { - // Upfront validation: packed GUIDs(1-8 each) + spellId(4) + damage(4) + overkill(4) + schoolMask(1) + absorbed(4) + resisted(4) = 30 bytes minimum - if (packet.getSize() - packet.getReadPos() < 30) return false; + // Upfront validation: + // packed GUIDs(1-8 each) + spellId(4) + damage(4) + overkill(4) + schoolMask(1) + // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) + // = 33 bytes minimum. + if (packet.getSize() - packet.getReadPos() < 33) return false; size_t startPos = packet.getReadPos(); if (!hasFullPackedGuid(packet)) { @@ -3451,11 +3454,11 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da data.absorbed = packet.readUInt32(); data.resisted = packet.readUInt32(); - // Skip remaining fields (periodicLog + unused + blocked + flags = 10 bytes) + // Remaining fields are required for a complete event. + // Reject truncated packets so we do not emit partial/incorrect combat entries. if (packet.getSize() - packet.getReadPos() < 10) { - LOG_WARNING("SpellDamageLog: truncated trailing fields"); - data.isCrit = false; - return true; + packet.setReadPos(startPos); + return false; } uint8_t periodicLog = packet.readUInt8(); From bce1f4d211a221af5915612f168eae0c6706ea55 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 21:49:30 -0700 Subject: [PATCH 31/38] fix: reject malformed monster move payloads --- src/game/world_packets.cpp | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 00229c0c..23efb3bc 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3172,12 +3172,10 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { if (pointCount == 0) return true; - // Cap pointCount to prevent excessive iteration from malformed packets + // Reject extreme point counts from malformed packets. constexpr uint32_t kMaxSplinePoints = 1000; if (pointCount > kMaxSplinePoints) { - LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, - " (guid=0x", std::hex, data.guid, std::dec, "), capping"); - pointCount = kMaxSplinePoints; + return false; } // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). @@ -3185,20 +3183,27 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; if (uncompressed) { + const size_t requiredBytes = static_cast(pointCount) * 12ull; + if (packet.getReadPos() + requiredBytes > packet.getSize()) return false; + // Read last point as destination // Skip to last point: each point is 12 bytes for (uint32_t i = 0; i < pointCount - 1; i++) { - if (packet.getReadPos() + 12 > packet.getSize()) return true; + if (packet.getReadPos() + 12 > packet.getSize()) return false; packet.readFloat(); packet.readFloat(); packet.readFloat(); } - if (packet.getReadPos() + 12 > packet.getSize()) return true; + if (packet.getReadPos() + 12 > packet.getSize()) return false; data.destX = packet.readFloat(); data.destY = packet.readFloat(); data.destZ = packet.readFloat(); data.hasDest = true; } else { // Compressed: first 3 floats are the destination (final point) - if (packet.getReadPos() + 12 > packet.getSize()) return true; + size_t requiredBytes = 12; + if (pointCount > 1) { + requiredBytes += static_cast(pointCount - 1) * 4ull; + } + if (packet.getReadPos() + requiredBytes > packet.getSize()) return false; data.destX = packet.readFloat(); data.destY = packet.readFloat(); data.destZ = packet.readFloat(); @@ -3282,16 +3287,19 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d if (pointCount == 0) return true; - // Cap pointCount to prevent excessive iteration from malformed packets + // Reject extreme point counts from malformed packets. constexpr uint32_t kMaxSplinePoints = 1000; if (pointCount > kMaxSplinePoints) { - LOG_WARNING("SMSG_MONSTER_MOVE(Vanilla): pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, - " (guid=0x", std::hex, data.guid, std::dec, "), capping"); - pointCount = kMaxSplinePoints; + return false; } + size_t requiredBytes = 12; + if (pointCount > 1) { + requiredBytes += static_cast(pointCount - 1) * 4ull; + } + if (packet.getReadPos() + requiredBytes > packet.getSize()) return false; + // First float[3] is destination. - if (packet.getReadPos() + 12 > packet.getSize()) return true; data.destX = packet.readFloat(); data.destY = packet.readFloat(); data.destZ = packet.readFloat(); @@ -3301,9 +3309,8 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d if (pointCount > 1) { size_t skipBytes = static_cast(pointCount - 1) * 4; size_t newPos = packet.getReadPos() + skipBytes; - if (newPos <= packet.getSize()) { - packet.setReadPos(newPos); - } + if (newPos > packet.getSize()) return false; + packet.setReadPos(newPos); } LOG_DEBUG("MonsterMove(turtle): guid=0x", std::hex, data.guid, std::dec, From f44ef7b9ea9b0471e6888289310e08669f19171e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 22:01:26 -0700 Subject: [PATCH 32/38] fix: optimize turtle monster move wrapped parsing --- src/game/game_handler.cpp | 49 ++++++++++++++++++++++++++++---------- src/game/world_packets.cpp | 19 ++++++--------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 40c4d34b..072944e0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16290,6 +16290,22 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { LOG_WARNING(msg, " (occurrence=", failCount, ")"); } }; + auto logWrappedFallbackUsed = [&]() { + static uint32_t wrappedFallbackCount = 0; + ++wrappedFallbackCount; + if (wrappedFallbackCount <= 10 || (wrappedFallbackCount % 100) == 0) { + LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback", + " (occurrence=", wrappedFallbackCount, ")"); + } + }; + auto logWrappedUncompressedFallbackUsed = [&]() { + static uint32_t wrappedUncompressedFallbackCount = 0; + ++wrappedUncompressedFallbackCount; + if (wrappedUncompressedFallbackCount <= 10 || (wrappedUncompressedFallbackCount % 100) == 0) { + LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback", + " (occurrence=", wrappedUncompressedFallbackCount, ")"); + } + }; auto stripWrappedSubpacket = [&](const std::vector& bytes, std::vector& stripped) -> bool { if (bytes.size() < 3) return false; uint8_t subSize = bytes[0]; @@ -16331,22 +16347,31 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { std::vector stripped; bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped); - // Try unwrapped payload first (common form), then wrapped-subpacket fallback. - network::Packet decompPacket(packet.getOpcode(), decompressed); - if (!packetParsers_->parseMonsterMove(decompPacket, data)) { - if (!hasWrappedForm) { - logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + - std::to_string(destLen) + " bytes)"); - return; - } + bool parsed = false; + if (hasWrappedForm) { network::Packet wrappedPacket(packet.getOpcode(), stripped); - if (!packetParsers_->parseMonsterMove(wrappedPacket, data)) { + if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { + parsed = true; + logWrappedFallbackUsed(); + } + } + if (!parsed) { + network::Packet decompPacket(packet.getOpcode(), decompressed); + if (packetParsers_->parseMonsterMove(decompPacket, data)) { + parsed = true; + } + } + + if (!parsed) { + if (hasWrappedForm) { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + std::to_string(destLen) + " bytes, wrapped payload " + std::to_string(stripped.size()) + " bytes)"); - return; + } else { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + + std::to_string(destLen) + " bytes)"); } - LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback"); + return; } } else if (!packetParsers_->parseMonsterMove(packet, data)) { // Some realms occasionally embed an extra [size|opcode] wrapper even when the @@ -16355,7 +16380,7 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { if (stripWrappedSubpacket(rawData, stripped)) { network::Packet wrappedPacket(packet.getOpcode(), stripped); if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { - LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback"); + logWrappedUncompressedFallbackUsed(); } else { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); return; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 23efb3bc..ba036f2d 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3172,10 +3172,12 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { if (pointCount == 0) return true; - // Reject extreme point counts from malformed packets. + // Cap pointCount to prevent excessive iteration from malformed packets. constexpr uint32_t kMaxSplinePoints = 1000; if (pointCount > kMaxSplinePoints) { - return false; + LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, + " (guid=0x", std::hex, data.guid, std::dec, "), capping"); + pointCount = kMaxSplinePoints; } // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). @@ -3183,27 +3185,20 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; if (uncompressed) { - const size_t requiredBytes = static_cast(pointCount) * 12ull; - if (packet.getReadPos() + requiredBytes > packet.getSize()) return false; - // Read last point as destination // Skip to last point: each point is 12 bytes for (uint32_t i = 0; i < pointCount - 1; i++) { - if (packet.getReadPos() + 12 > packet.getSize()) return false; + if (packet.getReadPos() + 12 > packet.getSize()) return true; packet.readFloat(); packet.readFloat(); packet.readFloat(); } - if (packet.getReadPos() + 12 > packet.getSize()) return false; + if (packet.getReadPos() + 12 > packet.getSize()) return true; data.destX = packet.readFloat(); data.destY = packet.readFloat(); data.destZ = packet.readFloat(); data.hasDest = true; } else { // Compressed: first 3 floats are the destination (final point) - size_t requiredBytes = 12; - if (pointCount > 1) { - requiredBytes += static_cast(pointCount - 1) * 4ull; - } - if (packet.getReadPos() + requiredBytes > packet.getSize()) return false; + if (packet.getReadPos() + 12 > packet.getSize()) return true; data.destX = packet.readFloat(); data.destY = packet.readFloat(); data.destZ = packet.readFloat(); From eea3784976922ffad06cf3978a515a4ffff347de Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 22:18:28 -0700 Subject: [PATCH 33/38] fix: harden turtle movement parsing and warden fallback --- include/game/game_handler.hpp | 2 + src/game/game_handler.cpp | 85 +++++++++++++++++++---------- src/game/packet_parsers_classic.cpp | 33 +++++++++++ 3 files changed, 91 insertions(+), 29 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b1ddeb97..c308dadb 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2434,6 +2434,8 @@ private: std::chrono::steady_clock::time_point movementClockStart_ = std::chrono::steady_clock::now(); uint32_t lastMovementTimestampMs_ = 0; bool serverMovementAllowed_ = true; + uint32_t monsterMovePacketsThisTick_ = 0; + uint32_t monsterMovePacketsDroppedThisTick_ = 0; // Fall/jump tracking for movement packet correctness. // fallTime must be the elapsed ms since the FALLING flag was set; the server diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 072944e0..f0acaeb3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -757,6 +757,10 @@ void GameHandler::update(float deltaTime) { return; } + // Reset per-tick monster-move budget tracking (Classic/Turtle flood protection). + monsterMovePacketsThisTick_ = 0; + monsterMovePacketsDroppedThisTick_ = 0; + // Update socket (processes incoming data and triggers callbacks) if (socket) { auto socketStart = std::chrono::steady_clock::now(); @@ -7960,7 +7964,7 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t kickerGuid = packet.readUInt64(); uint32_t reasonType = packet.readUInt32(); std::string reason; - if (packet.getSize() - packet.getReadPos() > 0) + if (packet.getReadPos() < packet.getSize()) reason = packet.readString(); (void)kickerGuid; (void)reasonType; @@ -8006,14 +8010,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t ticketId = packet.readUInt32(); std::string subject; std::string body; - if (packet.getSize() - packet.getReadPos() > 0) subject = packet.readString(); - if (packet.getSize() - packet.getReadPos() > 0) body = packet.readString(); + if (packet.getReadPos() < packet.getSize()) subject = packet.readString(); + if (packet.getReadPos() < packet.getSize()) body = packet.readString(); uint32_t responseCount = 0; if (packet.getSize() - packet.getReadPos() >= 4) responseCount = packet.readUInt32(); std::string responseText; for (uint32_t i = 0; i < responseCount && i < 10; ++i) { - if (packet.getSize() - packet.getReadPos() > 0) { + if (packet.getReadPos() < packet.getSize()) { std::string t = packet.readString(); if (i == 0) responseText = t; } @@ -9034,6 +9038,18 @@ void GameHandler::handleWardenData(network::Packet& packet) { } std::vector seed(decrypted.begin() + 1, decrypted.begin() + 17); + auto applyWardenSeedRekey = [&](const std::vector& rekeySeed) { + // Derive new RC4 keys from the seed using SHA1Randx. + uint8_t newEncryptKey[16], newDecryptKey[16]; + WardenCrypto::sha1RandxGenerate(rekeySeed, newEncryptKey, newDecryptKey); + + std::vector ek(newEncryptKey, newEncryptKey + 16); + std::vector dk(newDecryptKey, newDecryptKey + 16); + wardenCrypto_->replaceKeys(ek, dk); + for (auto& b : newEncryptKey) b = 0; + for (auto& b : newDecryptKey) b = 0; + LOG_DEBUG("Warden: Derived and applied key update from seed"); + }; // --- Try CR lookup (pre-computed challenge/response entries) --- if (!wardenCREntries_.empty()) { @@ -9082,7 +9098,24 @@ void GameHandler::handleWardenData(network::Packet& packet) { LOG_WARNING("Warden: No CR match, computing hash from loaded module"); if (!wardenLoadedModule_ || !wardenLoadedModule_->isLoaded()) { - LOG_ERROR("Warden: No loaded module and no CR match — cannot compute hash"); + LOG_WARNING("Warden: No loaded module and no CR match — using raw module fallback hash"); + + // Never skip HASH_RESULT: some realms disconnect quickly if this response is missing. + std::vector fallbackReply; + if (!wardenModuleData_.empty()) { + fallbackReply = auth::Crypto::sha1(wardenModuleData_); + } else if (!wardenModuleHash_.empty()) { + fallbackReply = auth::Crypto::sha1(wardenModuleHash_); + } else { + fallbackReply.assign(20, 0); + } + + std::vector resp; + resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT + resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end()); + sendWardenResponse(resp); + + applyWardenSeedRekey(seed); wardenState_ = WardenState::WAIT_CHECKS; break; } @@ -9171,19 +9204,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { resp.insert(resp.end(), reply.begin(), reply.end()); sendWardenResponse(resp); - // Derive new RC4 keys from the seed using SHA1Randx - std::vector seedVec(seed.begin(), seed.end()); - // Pad seed to at least 2 bytes for SHA1Randx split - // SHA1Randx splits input in half: first_half and second_half - uint8_t newEncryptKey[16], newDecryptKey[16]; - WardenCrypto::sha1RandxGenerate(seedVec, newEncryptKey, newDecryptKey); - - std::vector ek(newEncryptKey, newEncryptKey + 16); - std::vector dk(newDecryptKey, newDecryptKey + 16); - wardenCrypto_->replaceKeys(ek, dk); - for (auto& b : newEncryptKey) b = 0; - for (auto& b : newDecryptKey) b = 0; - LOG_DEBUG("Warden: Derived and applied key update from seed"); + applyWardenSeedRekey(seed); } wardenState_ = WardenState::WAIT_CHECKS; @@ -15560,9 +15581,9 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { lfgBootNeeded_ = votesNeeded; // Optional: reason string and target name (null-terminated) follow the fixed fields - if (packet.getSize() - packet.getReadPos() > 0) + if (packet.getReadPos() < packet.getSize()) lfgBootReason_ = packet.readString(); - if (packet.getSize() - packet.getReadPos() > 0) + if (packet.getReadPos() < packet.getSize()) lfgBootTargetName_ = packet.readString(); if (inProgress) { @@ -16282,6 +16303,21 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { } void GameHandler::handleMonsterMove(network::Packet& packet) { + if (isActiveExpansion("classic") || isActiveExpansion("turtle")) { + constexpr uint32_t kMaxMonsterMovesPerTick = 256; + ++monsterMovePacketsThisTick_; + if (monsterMovePacketsThisTick_ > kMaxMonsterMovesPerTick) { + ++monsterMovePacketsDroppedThisTick_; + if (monsterMovePacketsDroppedThisTick_ <= 3 || + (monsterMovePacketsDroppedThisTick_ % 100) == 0) { + LOG_WARNING("SMSG_MONSTER_MOVE: per-tick cap exceeded, dropping packet", + " (processed=", monsterMovePacketsThisTick_, + " dropped=", monsterMovePacketsDroppedThisTick_, ")"); + } + return; + } + } + MonsterMoveData data; auto logMonsterMoveParseFailure = [&](const std::string& msg) { static uint32_t failCount = 0; @@ -16290,14 +16326,6 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { LOG_WARNING(msg, " (occurrence=", failCount, ")"); } }; - auto logWrappedFallbackUsed = [&]() { - static uint32_t wrappedFallbackCount = 0; - ++wrappedFallbackCount; - if (wrappedFallbackCount <= 10 || (wrappedFallbackCount % 100) == 0) { - LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback", - " (occurrence=", wrappedFallbackCount, ")"); - } - }; auto logWrappedUncompressedFallbackUsed = [&]() { static uint32_t wrappedUncompressedFallbackCount = 0; ++wrappedUncompressedFallbackCount; @@ -16352,7 +16380,6 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { network::Packet wrappedPacket(packet.getOpcode(), stripped); if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { parsed = true; - logWrappedFallbackUsed(); } } if (!parsed) { diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 74935162..4a28e556 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1818,6 +1818,39 @@ bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveD return true; } + auto looksLikeWotlkMonsterMove = [&](network::Packet& probe) -> bool { + const size_t probeStart = probe.getReadPos(); + uint64_t guid = UpdateObjectParser::readPackedGuid(probe); + if (guid == 0) { + probe.setReadPos(probeStart); + return false; + } + if (probe.getReadPos() >= probe.getSize()) { + probe.setReadPos(probeStart); + return false; + } + uint8_t unk = probe.readUInt8(); + if (unk > 1) { + probe.setReadPos(probeStart); + return false; + } + if (probe.getReadPos() + 12 + 4 + 1 > probe.getSize()) { + probe.setReadPos(probeStart); + return false; + } + probe.readFloat(); probe.readFloat(); probe.readFloat(); // xyz + probe.readUInt32(); // splineId + uint8_t moveType = probe.readUInt8(); + probe.setReadPos(probeStart); + return moveType >= 1 && moveType <= 4; + }; + + packet.setReadPos(start); + if (!looksLikeWotlkMonsterMove(packet)) { + packet.setReadPos(start); + return false; + } + packet.setReadPos(start); if (MonsterMoveParser::parse(packet, data)) { LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); From 4dba20b757cf21501a4fe6cbd36e4fa19d37d4f3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Mar 2026 22:27:42 -0700 Subject: [PATCH 34/38] fix: avoid unsigned subtraction checks in packet bounds --- src/game/game_handler.cpp | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f0acaeb3..4007c4c1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -116,6 +116,12 @@ bool hasFullPackedGuid(const network::Packet& packet) { return packet.getSize() - packet.getReadPos() >= guidBytes; } +bool packetHasRemaining(const network::Packet& packet, size_t need) { + const size_t size = packet.getSize(); + const size_t pos = packet.getReadPos(); + return pos <= size && need <= (size - pos); +} + CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) { switch (missInfo) { case 0: return CombatTextEntry::MISS; @@ -7957,7 +7963,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_KICK_REASON: { // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string // kickReasonType: 0=other, 1=afk, 2=vote kick - if (packet.getSize() - packet.getReadPos() < 12) { + if (!packetHasRemaining(packet, 12)) { packet.setReadPos(packet.getSize()); break; } @@ -7984,7 +7990,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_GROUPACTION_THROTTLED: { // uint32 throttleMs — rate-limited group action; notify the player - if (packet.getSize() - packet.getReadPos() >= 4) { + if (packetHasRemaining(packet, 4)) { uint32_t throttleMs = packet.readUInt32(); char buf[128]; if (throttleMs > 0) { @@ -8003,7 +8009,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_GMRESPONSE_RECEIVED: { // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count // per count: string responseText - if (packet.getSize() - packet.getReadPos() < 4) { + if (!packetHasRemaining(packet, 4)) { packet.setReadPos(packet.getSize()); break; } @@ -8013,7 +8019,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getReadPos() < packet.getSize()) subject = packet.readString(); if (packet.getReadPos() < packet.getSize()) body = packet.readString(); uint32_t responseCount = 0; - if (packet.getSize() - packet.getReadPos() >= 4) + if (packetHasRemaining(packet, 4)) responseCount = packet.readUInt32(); std::string responseText; for (uint32_t i = 0; i < responseCount && i < 10; ++i) { @@ -15518,8 +15524,7 @@ void GameHandler::handleLfgUpdatePlayer(network::Packet& packet) { } void GameHandler::handleLfgPlayerReward(network::Packet& packet) { - size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining < 4 + 4 + 1 + 4 + 4 + 4) return; + if (!packetHasRemaining(packet, 4 + 4 + 1 + 4 + 4 + 4)) return; /*uint32_t randomDungeonEntry =*/ packet.readUInt32(); /*uint32_t dungeonEntry =*/ packet.readUInt32(); @@ -15542,9 +15547,9 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { std::string rewardMsg = std::string("Dungeon Finder reward: ") + moneyBuf + ", " + std::to_string(xp) + " XP"; - if (packet.getSize() - packet.getReadPos() >= 4) { + if (packetHasRemaining(packet, 4)) { uint32_t rewardCount = packet.readUInt32(); - for (uint32_t i = 0; i < rewardCount && packet.getSize() - packet.getReadPos() >= 9; ++i) { + for (uint32_t i = 0; i < rewardCount && packetHasRemaining(packet, 9); ++i) { uint32_t itemId = packet.readUInt32(); uint32_t itemCount = packet.readUInt32(); packet.readUInt8(); // unk @@ -15564,8 +15569,7 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { } void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { - size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining < 7 + 4 + 4 + 4 + 4) return; + if (!packetHasRemaining(packet, 7 + 4 + 4 + 4 + 4)) return; bool inProgress = packet.readUInt8() != 0; /*bool myVote =*/ packet.readUInt8(); // whether local player has voted From b0fafe5efa3fcbd31af3c1506b4f20cc919bb435 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 15 Mar 2026 01:21:23 -0700 Subject: [PATCH 35/38] fix: stabilize turtle world entry session handling --- Data/expansions/turtle/opcodes.json | 1 + include/core/application.hpp | 12 + include/game/game_handler.hpp | 18 + include/game/opcode_table.hpp | 4 +- include/game/packet_parsers.hpp | 1 + include/network/world_socket.hpp | 25 + include/rendering/character_renderer.hpp | 2 + include/rendering/m2_renderer.hpp | 2 + include/rendering/wmo_renderer.hpp | 2 + src/core/application.cpp | 404 ++-- src/game/game_handler.cpp | 2629 ++++++++++++---------- src/game/packet_parsers_classic.cpp | 190 ++ src/game/warden_memory.cpp | 25 +- src/game/warden_module.cpp | 20 +- src/game/world_packets.cpp | 6 +- src/network/world_socket.cpp | 242 +- src/rendering/character_renderer.cpp | 18 +- src/rendering/m2_renderer.cpp | 17 +- src/rendering/terrain_manager.cpp | 14 +- src/rendering/wmo_renderer.cpp | 31 +- 20 files changed, 2283 insertions(+), 1380 deletions(-) diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index 95a22888..c3d7cbd7 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -249,6 +249,7 @@ "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", "SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", "CMSG_BATTLEMASTER_JOIN": "0x2EE", + "SMSG_ADDON_INFO": "0x2EF", "CMSG_EMOTE": "0x102", "SMSG_EMOTE": "0x103", "CMSG_TEXT_EMOTE": "0x104", diff --git a/include/core/application.hpp b/include/core/application.hpp index 85339c04..d9b19e39 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -224,6 +224,7 @@ private: std::future future; }; std::vector asyncCreatureLoads_; + std::unordered_set asyncCreatureDisplayLoads_; // displayIds currently loading in background void processAsyncCreatureResults(bool unlimited = false); static constexpr int MAX_ASYNC_CREATURE_LOADS = 4; // concurrent background loads std::unordered_set deadCreatureGuids_; // GUIDs that should spawn in corpse/death pose @@ -280,7 +281,17 @@ private: float z = 0.0f; float orientation = 0.0f; }; + struct PendingTransportRegistration { + uint64_t guid = 0; + uint32_t entry = 0; + uint32_t displayId = 0; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float orientation = 0.0f; + }; std::unordered_map pendingTransportMoves_; // guid -> latest pre-registration move + std::deque pendingTransportRegistrations_; uint32_t nextGameObjectModelId_ = 20000; uint32_t nextGameObjectWmoModelId_ = 40000; bool testTransportSetup_ = false; @@ -433,6 +444,7 @@ private: }; std::vector pendingTransportDoodadBatches_; static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4; + void processPendingTransportRegistrations(); void processPendingTransportDoodads(); // Quest marker billboard sprites (above NPCs) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c308dadb..ec2e343e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -7,6 +7,7 @@ #include "game/inventory.hpp" #include "game/spell_defines.hpp" #include "game/group_defines.hpp" +#include "network/packet.hpp" #include #include #include @@ -2089,6 +2090,15 @@ private: * Handle incoming packet from world server */ void handlePacket(network::Packet& packet); + void enqueueIncomingPacket(const network::Packet& packet); + void enqueueIncomingPacketFront(network::Packet&& packet); + void processQueuedIncomingPackets(); + void enqueueUpdateObjectWork(UpdateObjectData&& data); + void processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs); + void processOutOfRangeObjects(const std::vector& guids); + void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); + void finalizeUpdateObjectBatch(bool newItemCreated); /** * Handle SMSG_AUTH_CHALLENGE from server @@ -2413,6 +2423,14 @@ private: // Network std::unique_ptr socket; + std::deque pendingIncomingPackets_; + struct PendingUpdateObjectWork { + UpdateObjectData data; + size_t nextBlockIndex = 0; + bool outOfRangeProcessed = false; + bool newItemCreated = false; + }; + std::deque pendingUpdateObjectWork_; // State WorldState state = WorldState::DISCONNECTED; diff --git a/include/game/opcode_table.hpp b/include/game/opcode_table.hpp index 91242206..aaecc837 100644 --- a/include/game/opcode_table.hpp +++ b/include/game/opcode_table.hpp @@ -49,12 +49,14 @@ public: /** Number of mapped opcodes. */ size_t size() const { return logicalToWire_.size(); } + /** Get canonical enum name for a logical opcode. */ + static const char* logicalToName(LogicalOpcode op); + private: std::unordered_map logicalToWire_; // LogicalOpcode → wire std::unordered_map wireToLogical_; // wire → LogicalOpcode static std::optional nameToLogical(const std::string& name); - static const char* logicalToName(LogicalOpcode op); }; /** diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 38560cc7..4446deba 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -451,6 +451,7 @@ public: class TurtlePacketParsers : public ClassicPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } + bool parseUpdateObject(network::Packet& packet, UpdateObjectData& data) override; bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; }; diff --git a/include/network/world_socket.hpp b/include/network/world_socket.hpp index 1d8f1d00..4192195c 100644 --- a/include/network/world_socket.hpp +++ b/include/network/world_socket.hpp @@ -7,7 +7,13 @@ #include "auth/vanilla_crypt.hpp" #include #include +#include #include +#include +#include +#include +#include +#include namespace wowee { namespace network { @@ -66,6 +72,8 @@ public: */ void initEncryption(const std::vector& sessionKey, uint32_t build = 12340); + void tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason); + /** * Check if header encryption is enabled */ @@ -76,11 +84,23 @@ private: * Try to parse complete packets from receive buffer */ void tryParsePackets(); + void pumpNetworkIO(); + void dispatchQueuedPackets(); + void asyncPumpLoop(); + void startAsyncPump(); + void stopAsyncPump(); + void closeSocketNoJoin(); socket_t sockfd = INVALID_SOCK; bool connected = false; bool encryptionEnabled = false; bool useVanillaCrypt = false; // true = XOR cipher, false = RC4 + bool useAsyncPump_ = true; + std::thread asyncPumpThread_; + std::atomic asyncPumpStop_{false}; + std::atomic asyncPumpRunning_{false}; + mutable std::mutex ioMutex_; + mutable std::mutex callbackMutex_; // WotLK RC4 ciphers for header encryption/decryption auth::RC4 encryptCipher; @@ -94,6 +114,8 @@ private: size_t receiveReadOffset_ = 0; // Optional reused packet queue (feature-gated) to reduce per-update allocations. std::vector parsedPacketsScratch_; + // Parsed packets waiting for callback dispatch; drained with a strict per-update budget. + std::deque pendingPacketCallbacks_; // Runtime-gated network optimization toggles (default off). bool useFastRecvAppend_ = false; @@ -105,6 +127,9 @@ private: // Debug-only tracing window for post-auth packet framing verification. int headerTracePacketsLeft = 0; + std::chrono::steady_clock::time_point packetTraceStart_{}; + std::chrono::steady_clock::time_point packetTraceUntil_{}; + std::string packetTraceReason_; // Packet callback std::function packetCallback; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 67b2274a..6129940f 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -296,7 +296,9 @@ private: std::unordered_map textureColorKeyBlackByPtr_; std::unordered_map compositeCache_; // key → texture for reuse std::unordered_set failedTextureCache_; // negative cache for budget exhaustion + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; // dedup warning logs + uint64_t textureLookupSerial_ = 0; size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 4ddea931..22578309 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -477,7 +477,9 @@ private: uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; std::unordered_set failedTextureCache_; + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; + uint64_t textureLookupSerial_ = 0; uint32_t textureBudgetRejectWarnings_ = 0; std::unique_ptr whiteTexture_; std::unique_ptr glowTexture_; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 7f6728af..07f3ac9d 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -671,7 +671,9 @@ private: uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 8192ull * 1024 * 1024; // 8 GB default, overridden at init std::unordered_set failedTextureCache_; + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; + uint64_t textureLookupSerial_ = 0; uint32_t textureBudgetRejectWarnings_ = 0; // Default white texture diff --git a/src/core/application.cpp b/src/core/application.cpp index 6f023fee..76556a6e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -824,6 +824,7 @@ void Application::logoutToLogin() { if (load.future.valid()) load.future.wait(); } asyncCreatureLoads_.clear(); + asyncCreatureDisplayLoads_.clear(); // --- Creature spawn queues --- pendingCreatureSpawns_.clear(); @@ -842,6 +843,7 @@ void Application::logoutToLogin() { gameObjectInstances_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportMoves_.clear(); + pendingTransportRegistrations_.clear(); pendingTransportDoodadBatches_.clear(); world.reset(); @@ -1053,6 +1055,7 @@ void Application::update(float deltaTime) { updateCheckpoint = "in_game: gameobject/transport queues"; runInGameStage("gameobject/transport queues", [&] { processGameObjectSpawnQueue(); + processPendingTransportRegistrations(); processPendingTransportDoodads(); }); inGameStep = "pending mount"; @@ -1725,6 +1728,19 @@ void Application::update(float deltaTime) { break; } + if (pendingWorldEntry_ && !loadingWorld_ && state != AppState::DISCONNECTED) { + auto entry = *pendingWorldEntry_; + pendingWorldEntry_.reset(); + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(1.0f); + } + loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); + } + // Update renderer (camera, etc.) only when in-game updateCheckpoint = "renderer update"; if (renderer && state == AppState::IN_GAME) { @@ -2025,24 +2041,19 @@ void Application::setupUICallbacks() { // If a world load is already in progress (re-entrant call from // gameHandler->update() processing SMSG_NEW_WORLD during warmup), - // defer this entry. The current load will pick it up when it finishes. + // defer this entry. The current load will pick it up when it finishes. if (loadingWorld_) { LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); pendingWorldEntry_ = {mapId, x, y, z}; return; } - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - // Stop any movement that was active before the teleport - if (renderer && renderer->getCameraController()) { - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(1.0f); - } - loadOnlineWorldTerrain(mapId, x, y, z); - // loadedMapId_ is set inside loadOnlineWorldTerrain (including - // any deferred entries it processes), so we must NOT override it here. + // Full world loads are expensive and `loadOnlineWorldTerrain()` itself + // drives `gameHandler->update()` during warmup. Queue the load here so + // it runs after the current packet handler returns instead of recursing + // from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`. + LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + pendingWorldEntry_ = {mapId, x, y, z}; }); auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { @@ -2712,133 +2723,28 @@ void Application::setupUICallbacks() { // Transport spawn callback (online mode) - register transports with TransportManager gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - auto* transportManager = gameHandler->getTransportManager(); - if (!transportManager || !renderer) return; + if (!renderer) return; - // Get the WMO instance ID from the GameObject spawn + // Get the GameObject instance now so late queue processing can rely on stable IDs. auto it = gameObjectInstances_.find(guid); if (it == gameObjectInstances_.end()) { LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); return; } - uint32_t wmoInstanceId = it->second.instanceId; - LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId, - " pos=(", x, ", ", y, ", ", z, ")"); - - // TransportAnimation.dbc is indexed by GameObject entry - uint32_t pathId = entry; - const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); - - bool clientAnim = transportManager->isClientSideAnimation(); - LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, - " guid=0x", std::hex, guid, std::dec, " entry=", entry, " pathId=", pathId, - " preferServer=", preferServerData); - - // Coordinates are already canonical (converted in game_handler.cpp when entity was created) - glm::vec3 canonicalSpawnPos(x, y, z); - - // Check if we have a real path from TransportAnimation.dbc (indexed by entry). - // AzerothCore transport entries are not always 1:1 with DBC path ids. - const bool shipOrZeppelinDisplay = - (displayId == 3015 || displayId == 3031 || displayId == 7546 || - displayId == 7446 || displayId == 1587 || displayId == 2454 || - displayId == 807 || displayId == 808); - bool hasUsablePath = transportManager->hasPathForEntry(entry); - if (shipOrZeppelinDisplay) { - // For true transports, reject tiny XY tracks that effectively look stationary. - hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); - } - - LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath, - " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); - - if (preferServerData) { - // Strict server-authoritative mode: do not infer/remap fallback routes. - if (!hasUsablePath) { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", - std::hex, guid, std::dec, " entry=", entry); - } else { - LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry); - } - } else if (!hasUsablePath) { - // Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids. - // For elevators (TB lift platforms), we must allow z-only paths here. - bool allowZOnly = (displayId == 455 || displayId == 462); - uint32_t inferredPath = transportManager->inferDbcPathForSpawn( - canonicalSpawnPos, 1200.0f, allowZOnly); - if (inferredPath != 0) { - pathId = inferredPath; - LOG_WARNING("Using inferred transport path ", pathId, " for entry ", entry); - } else { - uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); - if (remappedPath != 0) { - pathId = remappedPath; - LOG_WARNING("Using remapped fallback transport path ", pathId, - " for entry ", entry, " displayId=", displayId, - " (usableEntryPath=", transportManager->hasPathForEntry(entry), ")"); - } else { - LOG_WARNING("No TransportAnimation.dbc path for entry ", entry, - " - transport will be stationary"); - - // Fallback: Stationary at spawn point (wait for server to send real position) - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - } - } + auto pendingIt = std::find_if( + pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), + [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); + if (pendingIt != pendingTransportRegistrations_.end()) { + pendingIt->entry = entry; + pendingIt->displayId = displayId; + pendingIt->x = x; + pendingIt->y = y; + pendingIt->z = z; + pendingIt->orientation = orientation; } else { - LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", entry); - } - - // Register the transport with spawn position (prevents rendering at origin until server update) - transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); - - // Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer - if (!it->second.isWmo) { - if (auto* tr = transportManager->getTransport(guid)) { - tr->isM2 = true; - } - } - - // Server-authoritative movement - set initial position from spawn data - glm::vec3 canonicalPos(x, y, z); - transportManager->updateServerTransport(guid, canonicalPos, orientation); - - // If a move packet arrived before registration completed, replay latest now. - auto pendingIt = pendingTransportMoves_.find(guid); - if (pendingIt != pendingTransportMoves_.end()) { - const PendingTransportMove pending = pendingIt->second; - transportManager->updateServerTransport(guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, guid, std::dec, - " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ") orientation=", pending.orientation); - pendingTransportMoves_.erase(pendingIt); - } - - // For MO_TRANSPORT at (0,0,0): check if GO data is already cached with a taxiPathId - if (glm::length(canonicalSpawnPos) < 1.0f && gameHandler) { - auto goData = gameHandler->getCachedGameObjectInfo(entry); - if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { - uint32_t taxiPathId = goData->data[0]; - if (transportManager->hasTaxiPath(taxiPathId)) { - transportManager->assignTaxiPathToTransport(entry, taxiPathId); - LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", entry, - " taxiPathId=", taxiPathId); - } - } - } - - if (auto* tr = transportManager->getTransport(guid); tr) { - LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, - " pathId=", tr->pathId, - " mode=", (tr->useClientAnimation ? "client" : "server"), - " serverUpdates=", tr->serverUpdateCount); - } else { - LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, " (TransportManager instance missing)"); + pendingTransportRegistrations_.push_back( + PendingTransportRegistration{guid, entry, displayId, x, y, z, orientation}); } }); @@ -2853,6 +2759,15 @@ void Application::setupUICallbacks() { return; } + auto pendingRegIt = std::find_if( + pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), + [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); + if (pendingRegIt != pendingTransportRegistrations_.end()) { + pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; + LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); + return; + } + // Check if transport exists - if not, treat this as a late spawn (reconnection/server restart) if (!transportManager->getTransport(guid)) { LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec, @@ -4155,6 +4070,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float deferredEquipmentQueue_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportMoves_.clear(); + pendingTransportRegistrations_.clear(); pendingTransportDoodadBatches_.clear(); if (renderer) { @@ -4210,6 +4126,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (load.future.valid()) load.future.wait(); } asyncCreatureLoads_.clear(); + asyncCreatureDisplayLoads_.clear(); playerInstances_.clear(); onlinePlayerAppearance_.clear(); @@ -4866,25 +4783,23 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (world) world->update(1.0f / 60.0f); processPlayerSpawnQueue(); - // During load screen warmup: lift per-frame budgets so GPU uploads - // and spawns happen in bulk while the loading screen is still visible. - processCreatureSpawnQueue(true); - processAsyncNpcCompositeResults(true); - // Process equipment queue more aggressively during warmup (multiple per iteration) - for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { + // Keep warmup bounded: unbounded queue draining can stall the main thread + // long enough to trigger socket timeouts. + processCreatureSpawnQueue(false); + processAsyncNpcCompositeResults(false); + // Process equipment queue with a small bounded burst during warmup. + for (int i = 0; i < 2 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { processDeferredEquipmentQueue(); } if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { - cr->processPendingNormalMaps(INT_MAX); + cr->processPendingNormalMaps(4); } - // Process ALL pending game object spawns. - while (!pendingGameObjectSpawns_.empty()) { - auto& s = pendingGameObjectSpawns_.front(); - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - } + // Keep warmup responsive: process gameobject queue with the same bounded + // budget logic used in-world instead of draining everything in one tick. + processGameObjectSpawnQueue(); + processPendingTransportRegistrations(); processPendingTransportDoodads(); processPendingMount(); updateQuestMarkers(); @@ -7437,12 +7352,23 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t void Application::processAsyncCreatureResults(bool unlimited) { // Check completed async model loads and finalize on main thread (GPU upload + instance creation). - // Limit GPU model uploads per frame to avoid spikes, but always drain cheap bookkeeping. - // In unlimited mode (load screen), process all pending uploads without cap. - static constexpr int kMaxModelUploadsPerFrame = 1; + // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. + // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. + static constexpr int kMaxModelUploadsPerTick = 1; + static constexpr int kMaxModelUploadsPerTickWarmup = 1; + static constexpr float kFinalizeBudgetMs = 2.0f; + static constexpr float kFinalizeBudgetWarmupMs = 2.0f; + const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; + const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; + const auto tickStart = std::chrono::steady_clock::now(); int modelUploads = 0; for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { + if (std::chrono::duration( + std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { + break; + } + if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; @@ -7451,12 +7377,13 @@ void Application::processAsyncCreatureResults(bool unlimited) { // Peek: if this result needs a NEW model upload (not cached) and we've hit // the upload budget, defer to next frame without consuming the future. - if (!unlimited && modelUploads >= kMaxModelUploadsPerFrame) { + if (modelUploads >= maxUploadsThisTick) { break; } auto result = it->future.get(); it = asyncCreatureLoads_.erase(it); + asyncCreatureDisplayLoads_.erase(result.displayId); if (result.permanent_failure) { nonRenderableCreatureDisplayIds_.insert(result.displayId); @@ -7471,6 +7398,27 @@ void Application::processAsyncCreatureResults(bool unlimited) { continue; } + // Another async result may have already uploaded this displayId while this + // task was still running; in that case, skip duplicate GPU upload. + if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + continue; + } + // Model parsed on background thread — upload to GPU on main thread. auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; if (!charRenderer) { @@ -7478,6 +7426,10 @@ void Application::processAsyncCreatureResults(bool unlimited) { continue; } + // Count upload attempts toward the frame budget even if upload fails. + // Otherwise repeated failures can consume an unbounded amount of frame time. + modelUploads++; + // Upload model to GPU (must happen on main thread) // Use pre-decoded BLP cache to skip main-thread texture decode auto uploadStart = std::chrono::steady_clock::now(); @@ -7504,8 +7456,6 @@ void Application::processAsyncCreatureResults(bool unlimited) { displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); } displayIdModelCache_[result.displayId] = result.modelId; - modelUploads++; - pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); @@ -7659,6 +7609,14 @@ void Application::processCreatureSpawnQueue(bool unlimited) { // For new models: launch async load on background thread instead of blocking. if (needsNewModel) { + // Keep exactly one background load per displayId. Additional spawns for + // the same displayId stay queued and will spawn once cache is populated. + if (asyncCreatureDisplayLoads_.count(s.displayId)) { + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { // Too many in-flight — defer to next frame @@ -7904,6 +7862,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { return result; }); asyncCreatureLoads_.push_back(std::move(load)); + asyncCreatureDisplayLoads_.insert(s.displayId); asyncLaunched++; // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it rotationsLeft = pendingCreatureSpawns_.size(); @@ -8304,6 +8263,151 @@ void Application::processGameObjectSpawnQueue() { } } +void Application::processPendingTransportRegistrations() { + if (pendingTransportRegistrations_.empty()) return; + if (!gameHandler || !renderer) return; + + auto* transportManager = gameHandler->getTransportManager(); + if (!transportManager) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr int kMaxRegistrationsPerFrame = 2; + static constexpr float kRegistrationBudgetMs = 2.0f; + int processed = 0; + + for (auto it = pendingTransportRegistrations_.begin(); + it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kRegistrationBudgetMs) break; + + const PendingTransportRegistration pending = *it; + auto goIt = gameObjectInstances_.find(pending.guid); + if (goIt == gameObjectInstances_.end()) { + it = pendingTransportRegistrations_.erase(it); + continue; + } + + if (transportManager->getTransport(pending.guid)) { + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + it = pendingTransportRegistrations_.erase(it); + continue; + } + + const uint32_t wmoInstanceId = goIt->second.instanceId; + LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, + " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); + + // TransportAnimation.dbc is indexed by GameObject entry. + uint32_t pathId = pending.entry; + const bool preferServerData = gameHandler->hasServerTransportUpdate(pending.guid); + + bool clientAnim = transportManager->isClientSideAnimation(); + LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, + " guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " pathId=", pathId, + " preferServer=", preferServerData); + + glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); + const bool shipOrZeppelinDisplay = + (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || + pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || + pending.displayId == 807 || pending.displayId == 808); + bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); + if (shipOrZeppelinDisplay) { + hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); + } + + LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + + if (preferServerData) { + if (!hasUsablePath) { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", + std::hex, pending.guid, std::dec, " entry=", pending.entry); + } else { + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); + } + } else if (!hasUsablePath) { + bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); + uint32_t inferredPath = transportManager->inferDbcPathForSpawn( + canonicalSpawnPos, 1200.0f, allowZOnly); + if (inferredPath != 0) { + pathId = inferredPath; + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); + } else { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_WARNING("Using remapped fallback transport path ", pathId, + " for entry ", pending.entry, " displayId=", pending.displayId, + " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); + } else { + LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, + " - transport will be stationary"); + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + } + } + } else { + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); + } + + transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); + + if (!goIt->second.isWmo) { + if (auto* tr = transportManager->getTransport(pending.guid)) { + tr->isM2 = true; + } + } + + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + + auto moveIt = pendingTransportMoves_.find(pending.guid); + if (moveIt != pendingTransportMoves_.end()) { + const PendingTransportMove latestMove = moveIt->second; + transportManager->updateServerTransport( + pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); + LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, + " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, + ") orientation=", latestMove.orientation); + pendingTransportMoves_.erase(moveIt); + } + + if (glm::length(canonicalSpawnPos) < 1.0f) { + auto goData = gameHandler->getCachedGameObjectInfo(pending.entry); + if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { + uint32_t taxiPathId = goData->data[0]; + if (transportManager->hasTaxiPath(taxiPathId)) { + transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); + LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, + " taxiPathId=", taxiPathId); + } + } + } + + if (auto* tr = transportManager->getTransport(pending.guid); tr) { + LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " pathId=", tr->pathId, + " mode=", (tr->useClientAnimation ? "client" : "server"), + " serverUpdates=", tr->serverUpdateCount); + } else { + LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " (TransportManager instance missing)"); + } + + ++processed; + it = pendingTransportRegistrations_.erase(it); + } +} + void Application::processPendingTransportDoodads() { if (pendingTransportDoodadBatches_.empty()) return; if (!renderer || !assetManager) return; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4007c4c1..0bd11890 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -100,6 +100,53 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { raw[0] == 'n' || raw[0] == 'N'); } +int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return defaultValue; + return static_cast(std::clamp(parsed, minValue, maxValue)); +} + +int incomingPacketsBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float incomingPacketBudgetMs(WorldState state) { + static const int inWorldBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50); + static const int loginBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50); + return static_cast(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs); +} + +int updateObjectBlocksBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float slowPacketLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +float slowUpdateObjectBlockLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +constexpr size_t kMaxQueuedInboundPackets = 4096; + bool hasFullPackedGuid(const network::Packet& packet) { if (packet.getReadPos() >= packet.getSize()) { return false; @@ -659,8 +706,7 @@ bool GameHandler::connect(const std::string& host, // Set up packet callback socket->setPacketCallback([this](const network::Packet& packet) { - network::Packet mutablePacket = packet; - handlePacket(mutablePacket); + enqueueIncomingPacket(packet); }); // Connect to world server @@ -712,6 +758,8 @@ void GameHandler::disconnect() { wardenModuleSize_ = 0; wardenModuleData_.clear(); wardenLoadedModule_.reset(); + pendingIncomingPackets_.clear(); + pendingUpdateObjectWork_.clear(); // Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects. entityManager.clear(); setState(WorldState::DISCONNECTED); @@ -778,11 +826,26 @@ void GameHandler::update(float deltaTime) { } } + { + auto packetStart = std::chrono::steady_clock::now(); + processQueuedIncomingPackets(); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetStart).count(); + if (packetMs > 3.0f) { + LOG_WARNING("SLOW queued packet handling: ", packetMs, "ms"); + } + } + // Detect server-side disconnect (socket closed during update) if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) { - LOG_WARNING("Server closed connection in state: ", worldStateName(state)); - disconnect(); - return; + if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + LOG_WARNING("Server closed connection in state: ", worldStateName(state)); + disconnect(); + return; + } + LOG_DEBUG("World socket closed with ", pendingIncomingPackets_.size(), + " queued packet(s) and ", pendingUpdateObjectWork_.size(), + " update-object batch(es) pending dispatch"); } // Post-gate visibility: determine whether server goes silent or closes after Warden requirement. @@ -971,7 +1034,9 @@ void GameHandler::update(float deltaTime) { timeSinceLastPing += deltaTime; timeSinceLastMoveHeartbeat_ += deltaTime; - if (timeSinceLastPing >= pingInterval) { + const float currentPingInterval = + (isClassicLikeExpansion() || isActiveExpansion("tbc")) ? 10.0f : pingInterval; + if (timeSinceLastPing >= currentPingInterval) { if (socket) { sendPing(); } @@ -7420,6 +7485,7 @@ void GameHandler::handlePacket(network::Packet& packet) { size_t dataLen = pdata.size(); size_t pos = packet.getReadPos(); static uint32_t multiPktWarnCount = 0; + std::vector subPackets; while (pos + 4 <= dataLen) { uint16_t subSize = static_cast( (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); @@ -7436,10 +7502,12 @@ void GameHandler::handlePacket(network::Packet& packet) { (static_cast(pdata[pos + 3]) << 8); std::vector subPayload(pdata.begin() + pos + 4, pdata.begin() + pos + 4 + payloadLen); - network::Packet subPacket(subOpcode, std::move(subPayload)); - handlePacket(subPacket); + subPackets.emplace_back(subOpcode, std::move(subPayload)); pos += 4 + payloadLen; } + for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) { + enqueueIncomingPacketFront(std::move(*it)); + } packet.setReadPos(packet.getSize()); break; } @@ -8168,6 +8236,159 @@ void GameHandler::handlePacket(network::Packet& packet) { } } +void GameHandler::enqueueIncomingPacket(const network::Packet& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(), + " packets); dropping oldest packet to preserve responsiveness"); + pendingIncomingPackets_.pop_front(); + } + pendingIncomingPackets_.push_back(packet); +} + +void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(), + " packets); dropping newest queued packet to preserve ordering"); + pendingIncomingPackets_.pop_back(); + } + pendingIncomingPackets_.emplace_front(std::move(packet)); +} + +void GameHandler::enqueueUpdateObjectWork(UpdateObjectData&& data) { + pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); +} + +void GameHandler::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs) { + if (pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(state); + int processedBlocks = 0; + + while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + auto& work = pendingUpdateObjectWork_.front(); + if (!work.outOfRangeProcessed) { + auto outOfRangeStart = std::chrono::steady_clock::now(); + processOutOfRangeObjects(work.data.outOfRangeGuids); + float outOfRangeMs = std::chrono::duration( + std::chrono::steady_clock::now() - outOfRangeStart).count(); + if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, + "ms guidCount=", work.data.outOfRangeGuids.size()); + } + work.outOfRangeProcessed = true; + } + + while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { + elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; + auto blockStart = std::chrono::steady_clock::now(); + applyUpdateObjectBlock(block, work.newItemCreated); + float blockMs = std::chrono::duration( + std::chrono::steady_clock::now() - blockStart).count(); + if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object block apply: ", blockMs, + "ms index=", work.nextBlockIndex, + " type=", static_cast(block.updateType), + " guid=0x", std::hex, block.guid, std::dec, + " objectType=", static_cast(block.objectType), + " fieldCount=", block.fields.size(), + " hasMovement=", block.hasMovement ? 1 : 0); + } + ++work.nextBlockIndex; + ++processedBlocks; + } + + if (work.nextBlockIndex >= work.data.blocks.size()) { + finalizeUpdateObjectBatch(work.newItemCreated); + pendingUpdateObjectWork_.pop_front(); + continue; + } + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + const auto& work = pendingUpdateObjectWork_.front(); + LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", + pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, + "/", work.data.blocks.size(), ", state=", worldStateName(state), ")"); + } +} + +void GameHandler::processQueuedIncomingPackets() { + if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state); + const float budgetMs = incomingPacketBudgetMs(state); + const auto start = std::chrono::steady_clock::now(); + int processed = 0; + + while (processed < maxPacketsThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + processPendingUpdateObjectWork(start, budgetMs); + if (!pendingUpdateObjectWork_.empty()) { + break; + } + continue; + } + + if (pendingIncomingPackets_.empty()) { + break; + } + + network::Packet packet = std::move(pendingIncomingPackets_.front()); + pendingIncomingPackets_.pop_front(); + const uint16_t wireOp = packet.getOpcode(); + const auto logicalOp = opcodeTable_.fromWire(wireOp); + auto packetHandleStart = std::chrono::steady_clock::now(); + handlePacket(packet); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetHandleStart).count(); + if (packetMs > slowPacketLogThresholdMs()) { + const char* logicalName = logicalOp + ? OpcodeTable::logicalToName(*logicalOp) + : "UNKNOWN"; + LOG_WARNING("SLOW packet handler: ", packetMs, + "ms wire=0x", std::hex, wireOp, std::dec, + " logical=", logicalName, + " size=", packet.getSize(), + " state=", worldStateName(state)); + } + ++processed; + } + + if (!pendingUpdateObjectWork_.empty()) { + return; + } + + if (!pendingIncomingPackets_.empty()) { + LOG_DEBUG("GameHandler packet budget reached (processed=", processed, + ", remaining=", pendingIncomingPackets_.size(), + ", state=", worldStateName(state), ")"); + } +} + void GameHandler::handleAuthChallenge(network::Packet& packet) { LOG_INFO("Handling SMSG_AUTH_CHALLENGE"); @@ -8643,9 +8864,29 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { return; } + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); + const bool alreadyInWorld = (state == WorldState::IN_WORLD); + const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId); + const float dxCurrent = movementInfo.x - canonical.x; + const float dyCurrent = movementInfo.y - canonical.y; + const float dzCurrent = movementInfo.z - canonical.z; + const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent; + + // Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already + // in-world. Re-running full world-entry handling here can trigger an expensive + // same-map reload/reset path and starve networking for tens of seconds. + if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) { + LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=", + data.mapId, " dist=", std::sqrt(distSqCurrent)); + return; + } + // Successfully entered the world (or teleported) currentMapId_ = data.mapId; setState(WorldState::IN_WORLD); + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world"); + } LOG_INFO("========================================"); LOG_INFO(" SUCCESSFULLY ENTERED WORLD!"); @@ -8656,7 +8897,6 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { LOG_INFO("Player is now in the game world"); // Initialize movement info with world entry position (server → canonical) - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z, ") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId); movementInfo.x = canonical.x; @@ -8695,49 +8935,30 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { encounterUnitGuids_.fill(0); raidTargetGuids_.fill(0); - // Clear inspect caches on world entry to avoid showing stale data - inspectedPlayerAchievements_.clear(); - - // Reset talent initialization so the first SMSG_TALENTS_INFO after login - // correctly sets the active spec (static locals don't reset across logins) - talentsInitialized_ = false; - learnedTalents_[0].clear(); - learnedTalents_[1].clear(); - learnedGlyphs_[0].fill(0); - learnedGlyphs_[1].fill(0); - unspentTalentPoints_[0] = 0; - unspentTalentPoints_[1] = 0; - activeTalentSpec_ = 0; - // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. activeAreaTriggers_.clear(); areaTriggerCheckTimer_ = -5.0f; areaTriggerSuppressFirst_ = true; - // Send CMSG_SET_ACTIVE_MOVER (required by some servers) + // Notify application to load terrain for this map/position (online mode) + if (worldEntryCallback_) { + worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); + } + + // Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers. if (playerGuid != 0 && socket) { auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid); socket->send(activeMoverPacket); LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec); } - // Notify application to load terrain for this map/position (online mode) - if (worldEntryCallback_) { - worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); - } - - // Auto-join default chat channels - autoJoinDefaultChannels(); - - // Auto-query guild info on login - const Character* activeChar = getActiveCharacter(); - if (activeChar && activeChar->hasGuild() && socket) { - auto gqPacket = GuildQueryPacket::build(activeChar->guildId); - socket->send(gqPacket); - auto grPacket = GuildRosterPacket::build(); - socket->send(grPacket); - LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + // Kick the first keepalive immediately on world entry. Classic-like realms + // can close the session before our default 30s ping cadence fires. + timeSinceLastPing = 0.0f; + if (socket) { + LOG_WARNING("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD"); + sendPing(); } // If we disconnected mid-taxi, attempt to recover to destination after login. @@ -8755,6 +8976,33 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { } if (initialWorldEntry) { + // Clear inspect caches on world entry to avoid showing stale data. + inspectedPlayerAchievements_.clear(); + + // Reset talent initialization so the first SMSG_TALENTS_INFO after login + // correctly sets the active spec (static locals don't reset across logins). + talentsInitialized_ = false; + learnedTalents_[0].clear(); + learnedTalents_[1].clear(); + learnedGlyphs_[0].fill(0); + learnedGlyphs_[1].fill(0); + unspentTalentPoints_[0] = 0; + unspentTalentPoints_[1] = 0; + activeTalentSpec_ = 0; + + // Auto-join default chat channels only on first world entry. + autoJoinDefaultChannels(); + + // Auto-query guild info on login. + const Character* activeChar = getActiveCharacter(); + if (activeChar && activeChar->hasGuild() && socket) { + auto gqPacket = GuildQueryPacket::build(activeChar->guildId); + socket->send(gqPacket); + auto grPacket = GuildRosterPacket::build(); + socket->send(grPacket); + LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + } + pendingQuestAcceptTimeouts_.clear(); pendingQuestAcceptNpcGuids_.clear(); pendingQuestQueryIds_.clear(); @@ -8763,11 +9011,18 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { completedQuests_.clear(); LOG_INFO("Queued quest log resync for login (from server quest slots)"); - // Request completed quest IDs from server (populates completedQuests_ when response arrives) + // Request completed quest IDs when the expansion supports it. Classic-like + // opcode tables do not define this packet, and sending 0xFFFF during world + // entry can desync the early session handshake. if (socket) { - network::Packet cqcPkt(wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED)); - socket->send(cqcPkt); - LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED); + if (queryCompletedWire != 0xFFFF) { + network::Packet cqcPkt(queryCompletedWire); + socket->send(cqcPkt); + LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + } else { + LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); + } } } } @@ -9131,6 +9386,19 @@ void GameHandler::handleWardenData(network::Packet& packet) { size_t moduleImageSize = wardenLoadedModule_->getModuleSize(); const auto& decompressedData = wardenLoadedModule_->getDecompressedData(); + if (!moduleImage || moduleImageSize == 0) { + LOG_WARNING("Warden: Loaded module has no executable image — using raw module hash fallback"); + std::vector fallbackReply = + !wardenModuleData_.empty() ? auth::Crypto::sha1(wardenModuleData_) : std::vector(20, 0); + std::vector resp; + resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT + resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end()); + sendWardenResponse(resp); + applyWardenSeedRekey(seed); + wardenState_ = WardenState::WAIT_CHECKS; + break; + } + // --- Empirical test: try multiple SHA1 computations and check against first CR entry --- if (!wardenCREntries_.empty()) { const auto& firstCR = wardenCREntries_[0]; @@ -9721,8 +9989,8 @@ void GameHandler::sendPing() { // Increment sequence number pingSequence++; - LOG_DEBUG("Sending CMSG_PING (heartbeat)"); - LOG_DEBUG(" Sequence: ", pingSequence); + LOG_WARNING("Sending CMSG_PING: sequence=", pingSequence, + " latencyHintMs=", lastLatency); // Record send time for RTT measurement pingTimestamp_ = std::chrono::steady_clock::now(); @@ -9772,7 +10040,7 @@ void GameHandler::sendMinimapPing(float wowX, float wowY) { } void GameHandler::handlePong(network::Packet& packet) { - LOG_DEBUG("Handling SMSG_PONG"); + LOG_WARNING("Handling SMSG_PONG"); PongData data; if (!PongParser::parse(packet, data)) { @@ -9792,7 +10060,8 @@ void GameHandler::handlePong(network::Packet& packet) { lastLatency = static_cast( std::chrono::duration_cast(rtt).count()); - LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ", latency: ", lastLatency, "ms)"); + LOG_WARNING("SMSG_PONG acknowledged: sequence=", data.sequence, + " latencyMs=", lastLatency); } uint32_t GameHandler::nextMovementTimestampMs() { @@ -10105,7 +10374,6 @@ void GameHandler::setOrientation(float orientation) { } void GameHandler::handleUpdateObject(network::Packet& packet) { - static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); UpdateObjectData data; if (!packetParsers_->parseUpdateObject(packet, data)) { static int updateObjErrors = 0; @@ -10115,6 +10383,61 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Fall through: process any blocks that were successfully parsed before the failure. } + enqueueUpdateObjectWork(std::move(data)); +} + +void GameHandler::processOutOfRangeObjects(const std::vector& guids) { + // Process out-of-range objects first + for (uint64_t guid : guids) { + auto entity = entityManager.getEntity(guid); + if (!entity) continue; + + const bool isKnownTransport = transportGuids_.count(guid) > 0; + if (isKnownTransport) { + // Keep transports alive across out-of-range flapping. + // Boats/zeppelins are global movers and removing them here can make + // them disappear until a later movement snapshot happens to recreate them. + const bool playerAboardNow = (playerTransportGuid_ == guid); + const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); + const bool movementSaysAboard = (movementInfo.transportGuid == guid); + LOG_INFO("Preserving transport on out-of-range: 0x", + std::hex, guid, std::dec, + " now=", playerAboardNow, + " sticky=", stickyAboard, + " movement=", movementSaysAboard); + continue; + } + + LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); + // Trigger despawn callbacks before removing entity + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); + otherPlayerVisibleItemEntries_.erase(guid); + otherPlayerVisibleDirty_.erase(guid); + otherPlayerMoveTimeMs_.erase(guid); + inspectedPlayerItemEntries_.erase(guid); + pendingAutoInspect_.erase(guid); + // Clear pending name query so the query is re-sent when this player + // comes back into range (entity is recreated as a new object). + pendingNameQueries.erase(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { + gameObjectDespawnCallback_(guid); + } + transportGuids_.erase(guid); + serverUpdatedTransportGuids_.erase(guid); + clearTransportAttachment(guid); + if (playerTransportGuid_ == guid) { + clearPlayerTransport(); + } + entityManager.removeEntity(guid); + } + +} + +void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { + static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); auto extractPlayerAppearance = [&](const std::map& fields, uint8_t& outRace, uint8_t& outGender, @@ -10236,1135 +10559,571 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { pendingMoneyDeltaTimer_ = 0.0f; }; - // Process out-of-range objects first - for (uint64_t guid : data.outOfRangeGuids) { - auto entity = entityManager.getEntity(guid); - if (!entity) continue; + switch (block.updateType) { + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: { + // Create new entity + std::shared_ptr entity; - const bool isKnownTransport = transportGuids_.count(guid) > 0; - if (isKnownTransport) { - // Keep transports alive across out-of-range flapping. - // Boats/zeppelins are global movers and removing them here can make - // them disappear until a later movement snapshot happens to recreate them. - const bool playerAboardNow = (playerTransportGuid_ == guid); - const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (movementInfo.transportGuid == guid); - LOG_INFO("Preserving transport on out-of-range: 0x", - std::hex, guid, std::dec, - " now=", playerAboardNow, - " sticky=", stickyAboard, - " movement=", movementSaysAboard); - continue; - } + switch (block.objectType) { + case ObjectType::PLAYER: + entity = std::make_shared(block.guid); + break; - LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); - // Trigger despawn callbacks before removing entity - if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { - creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { - playerDespawnCallback_(guid); - otherPlayerVisibleItemEntries_.erase(guid); - otherPlayerVisibleDirty_.erase(guid); - otherPlayerMoveTimeMs_.erase(guid); - inspectedPlayerItemEntries_.erase(guid); - pendingAutoInspect_.erase(guid); - // Clear pending name query so the query is re-sent when this player - // comes back into range (entity is recreated as a new object). - pendingNameQueries.erase(guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { - gameObjectDespawnCallback_(guid); - } - transportGuids_.erase(guid); - serverUpdatedTransportGuids_.erase(guid); - clearTransportAttachment(guid); - if (playerTransportGuid_ == guid) { - clearPlayerTransport(); - } - entityManager.removeEntity(guid); - } + case ObjectType::UNIT: + entity = std::make_shared(block.guid); + break; - // Process update blocks - bool newItemCreated = false; - for (const auto& block : data.blocks) { - switch (block.updateType) { - case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - // Create new entity - std::shared_ptr entity; + case ObjectType::GAMEOBJECT: + entity = std::make_shared(block.guid); + break; - switch (block.objectType) { - case ObjectType::PLAYER: - entity = std::make_shared(block.guid); - break; + default: + entity = std::make_shared(block.guid); + entity->setType(block.objectType); + break; + } - case ObjectType::UNIT: - entity = std::make_shared(block.guid); - break; - - case ObjectType::GAMEOBJECT: - entity = std::make_shared(block.guid); - break; - - default: - entity = std::make_shared(block.guid); - entity->setType(block.objectType); - break; + // Set position from movement block (server → canonical) + if (block.hasMovement) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + serverRunSpeed_ = block.runSpeed; } - - // Set position from movement block (server → canonical) - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - } - // Track player-on-transport state - if (block.guid == playerGuid) { - if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } - LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, - " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); - } else { - // Don't clear client-side M2 transport boarding (trams) — - // the server doesn't know about client-detected transport attachment. - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); - } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport"); - clearPlayerTransport(); - } + // Track player-on-transport state + if (block.guid == playerGuid) { + if (block.onTransport) { + setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; } - } - - // Track transport-relative children so they follow parent transport motion. - if (block.guid != playerGuid && - (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, block.objectType, block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); + LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, + " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); + } else { + // Don't clear client-side M2 transport boarding (trams) — + // the server doesn't know about client-detected transport attachment. + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport"); + clearPlayerTransport(); } } } - // Set fields - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - // Add to manager - entityManager.addEntity(block.guid, entity); - - // For the local player, capture the full initial field state (CREATE_OBJECT carries the - // large baseline update-field set, including visible item fields on many cores). - // Later VALUES updates often only include deltas and may never touch visible item fields. - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - lastPlayerFields_ = entity->getFields(); - maybeDetectVisibleItemLayout(); - } - - // Auto-query names (Phase 1) - if (block.objectType == ObjectType::PLAYER) { - queryPlayerName(block.guid); - if (block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - } else if (block.objectType == ObjectType::UNIT) { - auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (it != block.fields.end() && it->second != 0) { - auto unit = std::static_pointer_cast(entity); - unit->setEntry(it->second); - // Set name from cache immediately if available - std::string cached = getCachedCreatureName(it->second); - if (!cached.empty()) { - unit->setName(cached); + // Track transport-relative children so they follow parent transport motion. + if (block.guid != playerGuid && + (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + setTransportAttachment(block.guid, block.objectType, block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); } - queryCreatureInfo(it->second, block.guid); + } else { + clearTransportAttachment(block.guid); } } + } - // Extract health/mana/power from fields (Phase 2) — single pass - if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { + // Set fields + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + // Add to manager + entityManager.addEntity(block.guid, entity); + + // For the local player, capture the full initial field state (CREATE_OBJECT carries the + // large baseline update-field set, including visible item fields on many cores). + // Later VALUES updates often only include deltas and may never touch visible item fields. + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + lastPlayerFields_ = entity->getFields(); + maybeDetectVisibleItemLayout(); + } + + // Auto-query names (Phase 1) + if (block.objectType == ObjectType::PLAYER) { + queryPlayerName(block.guid); + if (block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + } else if (block.objectType == ObjectType::UNIT) { + auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (it != block.fields.end() && it->second != 0) { auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - bool unitInitiallyDead = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - for (const auto& [key, val] : block.fields) { - // Check all specific fields BEFORE power/maxpower range checks. - // In Classic, power indices (23-27) are adjacent to maxHealth (28), - // and maxPower indices (29-33) are adjacent to level (34) and faction (35). - // A range check like "key >= powerBase && key < powerBase+7" would - // incorrectly capture maxHealth/level/faction in Classic's tight layout. - if (key == ufHealth) { - unit->setHealth(val); - if (block.objectType == ObjectType::UNIT && val == 0) { - unitInitiallyDead = true; - } - if (block.guid == playerGuid && val == 0) { - playerDead_ = true; - LOG_INFO("Player logged in dead"); - } - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufLevel) { - unit->setLevel(val); - } else if (key == ufFaction) { unit->setFactionTemplate(val); } - else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufDisplayId) { unit->setDisplayId(val); } - else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - else if (key == ufDynFlags) { - unit->setDynamicFlags(val); - if (block.objectType == ObjectType::UNIT && - ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { - unitInitiallyDead = true; - } + unit->setEntry(it->second); + // Set name from cache immediately if available + std::string cached = getCachedCreatureName(it->second); + if (!cached.empty()) { + unit->setName(cached); + } + queryCreatureInfo(it->second, block.guid); + } + } + + // Extract health/mana/power from fields (Phase 2) — single pass + if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + bool unitInitiallyDead = false; + const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); + const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); + const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); + const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); + const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); + const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); + const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); + const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); + const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); + const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + for (const auto& [key, val] : block.fields) { + // Check all specific fields BEFORE power/maxpower range checks. + // In Classic, power indices (23-27) are adjacent to maxHealth (28), + // and maxPower indices (29-33) are adjacent to level (34) and faction (35). + // A range check like "key >= powerBase && key < powerBase+7" would + // incorrectly capture maxHealth/level/faction in Classic's tight layout. + if (key == ufHealth) { + unit->setHealth(val); + if (block.objectType == ObjectType::UNIT && val == 0) { + unitInitiallyDead = true; } - // Power/maxpower range checks AFTER all specific fields - else if (key >= ufPowerBase && key < ufPowerBase + 7) { - unit->setPowerByType(static_cast(key - ufPowerBase), val); - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + if (block.guid == playerGuid && val == 0) { + playerDead_ = true; + LOG_INFO("Player logged in dead"); } - else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (old == 0 && val != 0) { - // Just mounted — find the mount aura (indefinite duration, self-cast) - mountAuraSpellId_ = 0; - for (const auto& a : playerAuras) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } + else if (key == ufLevel) { + unit->setLevel(val); + } else if (key == ufFaction) { unit->setFactionTemplate(val); } + else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufDisplayId) { unit->setDisplayId(val); } + else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + else if (key == ufDynFlags) { + unit->setDynamicFlags(val); + if (block.objectType == ObjectType::UNIT && + ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { + unitInitiallyDead = true; + } + } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + } + else if (key == ufMountDisplayId) { + if (block.guid == playerGuid) { + uint32_t old = currentMountDisplayId_; + currentMountDisplayId_ = val; + if (val != old && mountCallback_) mountCallback_(val); + if (old == 0 && val != 0) { + // Just mounted — find the mount aura (indefinite duration, self-cast) + mountAuraSpellId_ = 0; + for (const auto& a : playerAuras) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { + mountAuraSpellId_ = a.spellId; } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + mountAuraSpellId_ = fv; + break; } } } - LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - for (auto& a : playerAuras) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; } + LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); + } + if (old != 0 && val == 0) { + mountAuraSpellId_ = 0; + for (auto& a : playerAuras) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; } - unit->setMountDisplayId(val); - } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - } - if (block.guid == playerGuid) { - constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; - if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { - onTaxiFlight_ = true; - taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); - sanitizeMovementForTaxi(); - applyTaxiMountForCurrentNode(); } + unit->setMountDisplayId(val); + } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + } + if (block.guid == playerGuid) { + constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; + if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { + onTaxiFlight_ = true; + taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); + sanitizeMovementForTaxi(); + applyTaxiMountForCurrentNode(); } - if (block.guid == playerGuid && - (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { + } + if (block.guid == playerGuid && + (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { + playerDead_ = true; + LOG_INFO("Player logged in dead (dynamic flags)"); + } + // Detect ghost state on login via PLAYER_FLAGS + if (block.guid == playerGuid) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); + if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { + releasedSpirit_ = true; playerDead_ = true; - LOG_INFO("Player logged in dead (dynamic flags)"); + LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); } - // Detect ghost state on login via PLAYER_FLAGS - if (block.guid == playerGuid) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); - if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { - releasedSpirit_ = true; - playerDead_ = true; - LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); - if (ghostStateCallback_) ghostStateCallback_(true); + } + // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraField = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } } - } - // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create - if (block.guid == playerGuid && isClassicLikeExpansion()) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); - if (ufAuras != 0xFFFF) { - bool hasAuraField = false; - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } - } - if (hasAuraField) { - playerAuras.clear(); - playerAuras.resize(48); - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - const auto& allFields = entity->getFields(); - for (int slot = 0; slot < 48; ++slot) { - auto it = allFields.find(static_cast(ufAuras + slot)); - if (it != allFields.end() && it->second != 0) { - AuraSlot& a = playerAuras[slot]; - a.spellId = it->second; - // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful - // Normalize to WotLK convention: 0x80 = negative (debuff) - uint8_t classicFlag = 0; - if (ufAuraFlags != 0xFFFF) { - auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); - if (fit != allFields.end()) - classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); - } - // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) - a.flags = (classicFlag & 0x02) ? 0x80u : 0u; - a.durationMs = -1; - a.maxDurationMs = -1; - a.casterGuid = playerGuid; - a.receivedAtMs = nowMs; + if (hasAuraField) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful + // Normalize to WotLK convention: 0x80 = negative (debuff) + uint8_t classicFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); } - } - LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); - } - } - } - // Determine hostility from faction template for online creatures. - // Always call isHostileFaction — factionTemplate=0 defaults to hostile - // in the lookup rather than silently staying at the struct default (false). - unit->setHostile(isHostileFaction(unit->getFactionTemplate())); - // Trigger creature spawn callback for units/players with displayId - if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { - LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " has displayId=0 — no spawn (entry=", unit->getEntry(), - " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - } - if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { - if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately via spawnPlayerCharacter() - } else if (block.objectType == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } else { - LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) + a.flags = (classicFlag & 0x02) ? 0x80u : 0u; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; } } - } else if (creatureSpawnCallback_) { - LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " at (", - unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - float unitScale = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&unitScale, &raw, sizeof(float)); - if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; - } - } - } - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); - if (unitInitiallyDead && npcDeathCallback_) { - npcDeathCallback_(block.guid); - } - } - // Initialise swim/walk state from spawn-time movement flags (cold-join fix). - // Without this, an entity already swimming/walking when the client joins - // won't get its animation state set until the next MSG_MOVE_* heartbeat. - if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && - block.guid != playerGuid) { - unitMoveFlagsCallback_(block.guid, block.moveFlags); - } - // Query quest giver status for NPCs with questgiver flag (0x02) - if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); } } } - // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) - if (block.objectType == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); - if (itDisp != block.fields.end()) { - go->setDisplayId(itDisp->second); - } - auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (itEntry != block.fields.end() && itEntry->second != 0) { - go->setEntry(itEntry->second); - auto cacheIt = gameObjectInfoCache_.find(itEntry->second); - if (cacheIt != gameObjectInfoCache_.end()) { - go->setName(cacheIt->second.name); + // Determine hostility from faction template for online creatures. + // Always call isHostileFaction — factionTemplate=0 defaults to hostile + // in the lookup rather than silently staying at the struct default (false). + unit->setHostile(isHostileFaction(unit->getFactionTemplate())); + // Trigger creature spawn callback for units/players with displayId + if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { + LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " has displayId=0 — no spawn (entry=", unit->getEntry(), + " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + } + if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { + if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately via spawnPlayerCharacter() + } else if (block.objectType == ObjectType::PLAYER) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + } } - queryGameObjectInfo(itEntry->second, block.guid); - } - // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) - LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, - " entry=", go->getEntry(), " displayId=", go->getDisplayId(), - " updateFlags=0x", std::hex, block.updateFlags, std::dec, - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - if (block.updateFlags & 0x0002) { - transportGuids_.insert(block.guid); - LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, - " entry=", go->getEntry(), - " displayId=", go->getDisplayId(), - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created - } - if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { - float goScale = 1.0f; + } else if (creatureSpawnCallback_) { + LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " at (", + unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + float unitScale = 1.0f; { uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); if (scaleIdx != 0xFFFF) { uint32_t raw = entity->getField(scaleIdx); if (raw != 0) { - std::memcpy(&goScale, &raw, sizeof(float)); - if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; + std::memcpy(&unitScale, &raw, sizeof(float)); + if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; } } } - gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), - go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); - } - // Fire transport move callback for transports (position update on re-creation) - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, - go->getX(), go->getY(), go->getZ(), go->getOrientation()); - } - } - // Detect player's own corpse object so we have the position even when - // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). - if (block.objectType == ObjectType::CORPSE && block.hasMovement) { - // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) - uint16_t ownerLowIdx = 6; - auto ownerLowIt = block.fields.find(ownerLowIdx); - uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; - auto ownerHighIt = block.fields.find(ownerLowIdx + 1); - uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; - uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; - if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { - // Server coords from movement block - corpseGuid_ = block.guid; - corpseX_ = block.x; - corpseY_ = block.y; - corpseZ_ = block.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, - " server=(", block.x, ", ", block.y, ", ", block.z, - ") map=", corpseMapId_); - } - } - - // Track online item objects (CONTAINER = bags, also tracked as items) - if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { - auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); - auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); - auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); - if (entryIt != block.fields.end() && entryIt->second != 0) { - // Preserve existing info when doing partial updates - OnlineItemInfo info = onlineItems_.count(block.guid) - ? onlineItems_[block.guid] : OnlineItemInfo{}; - info.entry = entryIt->second; - if (stackIt != block.fields.end()) info.stackCount = stackIt->second; - if (durIt != block.fields.end()) info.curDurability = durIt->second; - if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; - bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); - onlineItems_[block.guid] = info; - if (isNew) newItemCreated = true; - queryItemInfo(info.entry, block.guid); - } - // Extract container slot GUIDs for bags - if (block.objectType == ObjectType::CONTAINER) { - extractContainerFields(block.guid, block.fields); - } - } - - // Extract XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - // Auto-detect coinage index using the previous snapshot vs this full snapshot. - maybeDetectCoinageIndex(lastPlayerFields_, block.fields); - - lastPlayerFields_ = block.fields; - detectInventorySlotBases(block.fields); - - if (kVerboseUpdateObject) { - uint16_t maxField = 0; - for (const auto& [key, _val] : block.fields) { - if (key > maxField) maxField = key; + creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); + if (unitInitiallyDead && npcDeathCallback_) { + npcDeathCallback_(block.guid); } - LOG_INFO("Player update with ", block.fields.size(), - " fields (max index=", maxField, ")"); } - - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); - const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); - const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); - const uint16_t ufStats[5] = { - fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), - fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), - fieldIndex(UF::UNIT_FIELD_STAT4) - }; - const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); - const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); - const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); - const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); - const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); - const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); - const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); - const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); - const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); - const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); - const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { playerXp_ = val; } - else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } - else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - for (auto& ch : characters) { - if (ch.guid == playerGuid) { ch.level = val; break; } + // Initialise swim/walk state from spawn-time movement flags (cold-join fix). + // Without this, an entity already swimming/walking when the client joins + // won't get its animation state set until the next MSG_MOVE_* heartbeat. + if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && + block.guid != playerGuid) { + unitMoveFlagsCallback_(block.guid, block.moveFlags); + } + // Query quest giver status for NPCs with questgiver flag (0x02) + if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + socket->send(qsPkt); + } + } + } + // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) + if (block.objectType == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); + if (itDisp != block.fields.end()) { + go->setDisplayId(itDisp->second); + } + auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (itEntry != block.fields.end() && itEntry->second != 0) { + go->setEntry(itEntry->second); + auto cacheIt = gameObjectInfoCache_.find(itEntry->second); + if (cacheIt != gameObjectInfoCache_.end()) { + go->setName(cacheIt->second.name); + } + queryGameObjectInfo(itEntry->second, block.guid); + } + // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) + LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), " displayId=", go->getDisplayId(), + " updateFlags=0x", std::hex, block.updateFlags, std::dec, + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + if (block.updateFlags & 0x0002) { + transportGuids_.insert(block.guid); + LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), + " displayId=", go->getDisplayId(), + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created + } + if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { + float goScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&goScale, &raw, sizeof(float)); + if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; } } - else if (key == ufCoinage) { - playerMoneyCopper_ = val; - LOG_DEBUG("Money set from update fields: ", val, " copper"); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); - } - else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { - playerResistances_[key - ufArmor - 1] = static_cast(val); - } - else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE - // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY - uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte != 0); - } - else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { - chosenTitleBit_ = static_cast(val); - LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); - } - else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } - else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } - else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { - playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); - } - else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } - else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } - else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } - else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } - else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } - else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } - else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { - std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); - } - else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { - playerCombatRatings_[key - ufRating1] = static_cast(val); - } - else { - for (int si = 0; si < 5; ++si) { - if (ufStats[si] != 0xFFFF && key == ufStats[si]) { - playerStats_[si] = static_cast(val); - break; - } - } - } - // Do not synthesize quest-log entries from raw update-field slots. - // Slot layouts differ on some classic-family realms and can produce - // phantom "already accepted" quests that block quest acceptance. } - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - maybeDetectVisibleItemLayout(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - applyQuestStateFromFields(lastPlayerFields_); + gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), + go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); + } + // Fire transport move callback for transports (position update on re-creation) + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, + go->getX(), go->getY(), go->getZ(), go->getOrientation()); + } + } + // Detect player's own corpse object so we have the position even when + // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). + if (block.objectType == ObjectType::CORPSE && block.hasMovement) { + // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) + uint16_t ownerLowIdx = 6; + auto ownerLowIt = block.fields.find(ownerLowIdx); + uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; + auto ownerHighIt = block.fields.find(ownerLowIdx + 1); + uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; + uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; + if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { + // Server coords from movement block + corpseGuid_ = block.guid; + corpseX_ = block.x; + corpseY_ = block.y; + corpseZ_ = block.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, + " server=(", block.x, ", ", block.y, ", ", block.z, + ") map=", corpseMapId_); } - break; } - case UpdateType::VALUES: { - // Update existing entity fields - auto entity = entityManager.getEntity(block.guid); - if (entity) { - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - - if (block.guid != playerGuid && - (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, entity->getType(), block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - } - - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - - // Update cached health/mana/power values (Phase 2) — single pass - if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - uint32_t oldDisplayId = unit->getDisplayId(); - bool displayIdChanged = false; - bool npcDeathNotified = false; - bool npcRespawnNotified = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - for (const auto& [key, val] : block.fields) { - if (key == ufHealth) { - uint32_t oldHealth = unit->getHealth(); - unit->setHealth(val); - if (val == 0) { - if (block.guid == autoAttackTarget) { - stopAutoAttack(); - } - hostileAttackers_.erase(block.guid); - if (block.guid == playerGuid) { - playerDead_ = true; - releasedSpirit_ = false; - stopAutoAttack(); - // Cache death position as corpse location. - // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so - // this is the primary source for canReclaimCorpse(). - // movementInfo is canonical (x=north, y=west); corpseX_/Y_ - // are raw server coords (x=west, y=north) — swap axes. - corpseX_ = movementInfo.y; // canonical west = server X - corpseY_ = movementInfo.x; // canonical north = server Y - corpseZ_ = movementInfo.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Player died! Corpse position cached at server=(", - corpseX_, ",", corpseY_, ",", corpseZ_, - ") map=", corpseMapId_); - } - if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (oldHealth == 0 && val > 0) { - if (block.guid == playerGuid) { - playerDead_ = false; - if (!releasedSpirit_) { - LOG_INFO("Player resurrected!"); - } else { - LOG_INFO("Player entered ghost form"); - } - } - if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - // Specific fields checked BEFORE power/maxpower range checks - // (Classic packs maxHealth/level/faction adjacent to power indices) - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (key == ufDynFlags) { - uint32_t oldDyn = unit->getDynamicFlags(); - unit->setDynamicFlags(val); - if (block.guid == playerGuid) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - playerDead_ = true; - releasedSpirit_ = false; - corpseX_ = movementInfo.y; - corpseY_ = movementInfo.x; - corpseZ_ = movementInfo.z; - corpseMapId_ = currentMapId_; - LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); - } else if (wasDead && !nowDead) { - playerDead_ = false; - releasedSpirit_ = false; - LOG_INFO("Player resurrected (dynamic flags)"); - } - } else if (entity->getType() == ObjectType::UNIT) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - if (!npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (wasDead && !nowDead) { - if (!npcRespawnNotified && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - } - } else if (key == ufLevel) { - uint32_t oldLvl = unit->getLevel(); - unit->setLevel(val); - if (block.guid != playerGuid && - entity->getType() == ObjectType::PLAYER && - val > oldLvl && oldLvl > 0 && - otherPlayerLevelUpCallback_) { - otherPlayerLevelUpCallback_(block.guid, val); - } - } - else if (key == ufFaction) { - unit->setFactionTemplate(val); - unit->setHostile(isHostileFaction(val)); - } else if (key == ufDisplayId) { - if (val != unit->getDisplayId()) { - unit->setDisplayId(val); - displayIdChanged = true; - } - } else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (old == 0 && val != 0) { - mountAuraSpellId_ = 0; - for (const auto& a : playerAuras) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } - } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } - } - } - } - LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - for (auto& a : playerAuras) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; - } - } - unit->setMountDisplayId(val); - } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - // Power/maxpower range checks AFTER all specific fields - else if (key >= ufPowerBase && key < ufPowerBase + 7) { - unit->setPowerByType(static_cast(key - ufPowerBase), val); - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); - } - } - - // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated - if (block.guid == playerGuid && isClassicLikeExpansion()) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); - if (ufAuras != 0xFFFF) { - bool hasAuraUpdate = false; - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } - } - if (hasAuraUpdate) { - playerAuras.clear(); - playerAuras.resize(48); - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - const auto& allFields = entity->getFields(); - for (int slot = 0; slot < 48; ++slot) { - auto it = allFields.find(static_cast(ufAuras + slot)); - if (it != allFields.end() && it->second != 0) { - AuraSlot& a = playerAuras[slot]; - a.spellId = it->second; - // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags - uint8_t aFlag = 0; - if (ufAuraFlags != 0xFFFF) { - auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); - if (fit != allFields.end()) - aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); - } - a.flags = aFlag; - a.durationMs = -1; - a.maxDurationMs = -1; - a.casterGuid = playerGuid; - a.receivedAtMs = nowMs; - } - } - LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); - } - } - } - - // Some units/players are created without displayId and get it later via VALUES. - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && - displayIdChanged && - unit->getDisplayId() != 0 && - unit->getDisplayId() != oldDisplayId) { - if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately - } else if (entity->getType() == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } else { - LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); - } - } - } else if (creatureSpawnCallback_) { - float unitScale2 = 1.0f; - { - uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); - if (scaleIdx != 0xFFFF) { - uint32_t raw = entity->getField(scaleIdx); - if (raw != 0) { - std::memcpy(&unitScale2, &raw, sizeof(float)); - if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; - } - } - } - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); - bool isDeadNow = (unit->getHealth() == 0) || - ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); - if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } - if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); - } - } - } - // Update XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid) { - const bool needCoinageDetectSnapshot = - (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); - std::map oldFieldsSnapshot; - if (needCoinageDetectSnapshot) { - oldFieldsSnapshot = lastPlayerFields_; - } - if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - // Some server dismount paths update run speed without updating mount display field. - if (!onTaxiFlight_ && !taxiMountActive_ && - currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { - LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, - " displayId=", currentMountDisplayId_); - currentMountDisplayId_ = 0; - if (mountCallback_) { - mountCallback_(0); - } - } - } - auto mergeHint = lastPlayerFields_.end(); - for (const auto& [key, val] : block.fields) { - mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); - } - if (needCoinageDetectSnapshot) { - maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); - } - maybeDetectVisibleItemLayout(); - detectInventorySlotBases(block.fields); - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); - const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); - const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); - const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); - const uint16_t ufStatsV[5] = { - fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), - fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), - fieldIndex(UF::UNIT_FIELD_STAT4) - }; - const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); - const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); - const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); - const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); - const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); - const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); - const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); - const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); - const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); - const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); - const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { - playerXp_ = val; - LOG_DEBUG("XP updated: ", val); - } - else if (key == ufPlayerNextXp) { - playerNextLevelXp_ = val; - LOG_DEBUG("Next level XP updated: ", val); - } - else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { - playerRestedXp_ = val; - } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - LOG_DEBUG("Level updated: ", val); - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.level = val; - break; - } - } - } - else if (key == ufCoinage) { - playerMoneyCopper_ = val; - LOG_DEBUG("Money updated via VALUES: ", val, " copper"); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - } - else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { - playerResistances_[key - ufArmor - 1] = static_cast(val); - } - else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE - // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY - uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte != 0); - } - else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { - chosenTitleBit_ = static_cast(val); - LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); - } - else if (key == ufPlayerFlags) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - bool wasGhost = releasedSpirit_; - bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; - if (!wasGhost && nowGhost) { - releasedSpirit_ = true; - LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); - if (ghostStateCallback_) ghostStateCallback_(true); - } else if (wasGhost && !nowGhost) { - releasedSpirit_ = false; - playerDead_ = false; - repopPending_ = false; - resurrectPending_ = false; - corpseMapId_ = 0; // corpse reclaimed - corpseGuid_ = 0; - LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); - if (ghostStateCallback_) ghostStateCallback_(false); - } - } - else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } - else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } - else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { - playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); - } - else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } - else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } - else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } - else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } - else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } - else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } - else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { - std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); - } - else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { - playerCombatRatings_[key - ufRating1V] = static_cast(val); - } - else { - for (int si = 0; si < 5; ++si) { - if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { - playerStats_[si] = static_cast(val); - break; - } - } - } - } - // Do not auto-create quests from VALUES quest-log slot fields for the - // same reason as CREATE_OBJECT2 above (can be misaligned per realm). - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - applyQuestStateFromFields(lastPlayerFields_); - } - - // Update item stack count / durability for online items - if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { - bool inventoryChanged = false; - const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); - const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); - const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); - const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); - const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); - - auto it = onlineItems_.find(block.guid); - bool isItemInInventory = (it != onlineItems_.end()); - - for (const auto& [key, val] : block.fields) { - if (key == itemStackField && isItemInInventory) { - if (it->second.stackCount != val) { - it->second.stackCount = val; - inventoryChanged = true; - } - } else if (key == itemDurField && isItemInInventory) { - if (it->second.curDurability != val) { - it->second.curDurability = val; - inventoryChanged = true; - } - } else if (key == itemMaxDurField && isItemInInventory) { - if (it->second.maxDurability != val) { - it->second.maxDurability = val; - inventoryChanged = true; - } - } - } - // Update container slot GUIDs on bag content changes - if (entity->getType() == ObjectType::CONTAINER) { - for (const auto& [key, _] : block.fields) { - if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || - (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { - inventoryChanged = true; - break; - } - } - extractContainerFields(block.guid, block.fields); - } - if (inventoryChanged) { - rebuildOnlineInventory(); - } - } - if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } else if (gameObjectMoveCallback_) { - gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } - } - - LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); - } else { + // Track online item objects (CONTAINER = bags, also tracked as items) + if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { + auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); + auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); + auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); + if (entryIt != block.fields.end() && entryIt->second != 0) { + // Preserve existing info when doing partial updates + OnlineItemInfo info = onlineItems_.count(block.guid) + ? onlineItems_[block.guid] : OnlineItemInfo{}; + info.entry = entryIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; + bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); + onlineItems_[block.guid] = info; + if (isNew) newItemCreated = true; + queryItemInfo(info.entry, block.guid); + } + // Extract container slot GUIDs for bags + if (block.objectType == ObjectType::CONTAINER) { + extractContainerFields(block.guid, block.fields); } - break; } - case UpdateType::MOVEMENT: { - // Diagnostic: Log if we receive MOVEMENT blocks for transports - if (transportGuids_.count(block.guid)) { - LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); + // Extract XP / inventory slot / skill fields for player entity + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + // Auto-detect coinage index using the previous snapshot vs this full snapshot. + maybeDetectCoinageIndex(lastPlayerFields_, block.fields); + + lastPlayerFields_ = block.fields; + detectInventorySlotBases(block.fields); + + if (kVerboseUpdateObject) { + uint16_t maxField = 0; + for (const auto& [key, _val] : block.fields) { + if (key > maxField) maxField = key; + } + LOG_INFO("Player update with ", block.fields.size(), + " fields (max index=", maxField, ")"); } - // Update entity position (server → canonical) - auto entity = entityManager.getEntity(block.guid); - if (entity) { + bool slotsChanged = false; + const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); + const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); + const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { playerXp_ = val; } + else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } + else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } + else if (key == ufPlayerLevel) { + serverPlayerLevel_ = val; + for (auto& ch : characters) { + if (ch.guid == playerGuid) { ch.level = val; break; } + } + } + else if (key == ufCoinage) { + playerMoneyCopper_ = val; + LOG_DEBUG("Money set from update fields: ", val, " copper"); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + playerArmorRating_ = static_cast(val); + LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte != 0); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); + } + else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { + playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); + } + else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); + } + else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { + playerCombatRatings_[key - ufRating1] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } + // Do not synthesize quest-log entries from raw update-field slots. + // Slot layouts differ on some classic-family realms and can produce + // phantom "already accepted" quests that block quest acceptance. + } + if (applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) rebuildOnlineInventory(); + maybeDetectVisibleItemLayout(); + extractSkillFields(lastPlayerFields_); + extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); + } + break; + } + + case UpdateType::VALUES: { + // Update existing entity fields + auto entity = entityManager.getEntity(block.guid); + if (entity) { + if (block.hasMovement) { glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); if (block.guid != playerGuid && (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { @@ -11383,78 +11142,593 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { clearTransportAttachment(block.guid); } } + } - if (block.guid == playerGuid) { - movementInfo.orientation = oCanonical; + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } - // Track player-on-transport state from MOVEMENT updates - if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; + if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + + // Update cached health/mana/power values (Phase 2) — single pass + if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + uint32_t oldDisplayId = unit->getDisplayId(); + bool displayIdChanged = false; + bool npcDeathNotified = false; + bool npcRespawnNotified = false; + const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); + const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); + const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); + const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); + const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); + const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); + const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); + const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); + const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); + const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + for (const auto& [key, val] : block.fields) { + if (key == ufHealth) { + uint32_t oldHealth = unit->getHealth(); + unit->setHealth(val); + if (val == 0) { + if (block.guid == autoAttackTarget) { + stopAutoAttack(); + } + hostileAttackers_.erase(block.guid); + if (block.guid == playerGuid) { + playerDead_ = true; + releasedSpirit_ = false; + stopAutoAttack(); + // Cache death position as corpse location. + // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so + // this is the primary source for canReclaimCorpse(). + // movementInfo is canonical (x=north, y=west); corpseX_/Y_ + // are raw server coords (x=west, y=north) — swap axes. + corpseX_ = movementInfo.y; // canonical west = server X + corpseY_ = movementInfo.x; // canonical north = server Y + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died! Corpse position cached at server=(", + corpseX_, ",", corpseY_, ",", corpseZ_, + ") map=", corpseMapId_); + } + if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (oldHealth == 0 && val > 0) { + if (block.guid == playerGuid) { + playerDead_ = false; + if (!releasedSpirit_) { + LOG_INFO("Player resurrected!"); + } else { + LOG_INFO("Player entered ghost form"); + } + } + if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { + npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } } - LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; - // Don't clear client-side M2 transport boarding - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); + // Specific fields checked BEFORE power/maxpower range checks + // (Classic packs maxHealth/level/faction adjacent to power indices) + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (key == ufDynFlags) { + uint32_t oldDyn = unit->getDynamicFlags(); + unit->setDynamicFlags(val); + if (block.guid == playerGuid) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + playerDead_ = true; + releasedSpirit_ = false; + corpseX_ = movementInfo.y; + corpseY_ = movementInfo.x; + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); + } else if (wasDead && !nowDead) { + playerDead_ = false; + releasedSpirit_ = false; + LOG_INFO("Player resurrected (dynamic flags)"); + } + } else if (entity->getType() == ObjectType::UNIT) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + if (!npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (wasDead && !nowDead) { + if (!npcRespawnNotified && npcRespawnCallback_) { + npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } + } } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport (MOVEMENT)"); - clearPlayerTransport(); + } else if (key == ufLevel) { + uint32_t oldLvl = unit->getLevel(); + unit->setLevel(val); + if (block.guid != playerGuid && + entity->getType() == ObjectType::PLAYER && + val > oldLvl && oldLvl > 0 && + otherPlayerLevelUpCallback_) { + otherPlayerLevelUpCallback_(block.guid, val); + } + } + else if (key == ufFaction) { + unit->setFactionTemplate(val); + unit->setHostile(isHostileFaction(val)); + } else if (key == ufDisplayId) { + if (val != unit->getDisplayId()) { + unit->setDisplayId(val); + displayIdChanged = true; + } + } else if (key == ufMountDisplayId) { + if (block.guid == playerGuid) { + uint32_t old = currentMountDisplayId_; + currentMountDisplayId_ = val; + if (val != old && mountCallback_) mountCallback_(val); + if (old == 0 && val != 0) { + mountAuraSpellId_ = 0; + for (const auto& a : playerAuras) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { + mountAuraSpellId_ = a.spellId; + } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + mountAuraSpellId_ = fv; + break; + } + } + } + } + LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); + } + if (old != 0 && val == 0) { + mountAuraSpellId_ = 0; + for (auto& a : playerAuras) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; + } + } + unit->setMountDisplayId(val); + } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + } + } + + // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraUpdate = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } + } + if (hasAuraUpdate) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); } } } - // Fire transport move callback if this is a known transport + // Some units/players are created without displayId and get it later via VALUES. + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && + displayIdChanged && + unit->getDisplayId() != 0 && + unit->getDisplayId() != oldDisplayId) { + if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately + } else if (entity->getType() == ObjectType::PLAYER) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); + } + } + } else if (creatureSpawnCallback_) { + float unitScale2 = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale2, &raw, sizeof(float)); + if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; + } + } + } + creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } + if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + socket->send(qsPkt); + } + } + } + // Update XP / inventory slot / skill fields for player entity + if (block.guid == playerGuid) { + const bool needCoinageDetectSnapshot = + (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); + std::map oldFieldsSnapshot; + if (needCoinageDetectSnapshot) { + oldFieldsSnapshot = lastPlayerFields_; + } + if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + serverRunSpeed_ = block.runSpeed; + // Some server dismount paths update run speed without updating mount display field. + if (!onTaxiFlight_ && !taxiMountActive_ && + currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { + LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, + " displayId=", currentMountDisplayId_); + currentMountDisplayId_ = 0; + if (mountCallback_) { + mountCallback_(0); + } + } + } + auto mergeHint = lastPlayerFields_.end(); + for (const auto& [key, val] : block.fields) { + mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); + } + if (needCoinageDetectSnapshot) { + maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); + } + maybeDetectVisibleItemLayout(); + detectInventorySlotBases(block.fields); + bool slotsChanged = false; + const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); + const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); + const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { + playerXp_ = val; + LOG_DEBUG("XP updated: ", val); + } + else if (key == ufPlayerNextXp) { + playerNextLevelXp_ = val; + LOG_DEBUG("Next level XP updated: ", val); + } + else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { + playerRestedXp_ = val; + } + else if (key == ufPlayerLevel) { + serverPlayerLevel_ = val; + LOG_DEBUG("Level updated: ", val); + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.level = val; + break; + } + } + } + else if (key == ufCoinage) { + playerMoneyCopper_ = val; + LOG_DEBUG("Money updated via VALUES: ", val, " copper"); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + playerArmorRating_ = static_cast(val); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte != 0); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); + } + else if (key == ufPlayerFlags) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + bool wasGhost = releasedSpirit_; + bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; + if (!wasGhost && nowGhost) { + releasedSpirit_ = true; + LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); + } else if (wasGhost && !nowGhost) { + releasedSpirit_ = false; + playerDead_ = false; + repopPending_ = false; + resurrectPending_ = false; + corpseMapId_ = 0; // corpse reclaimed + corpseGuid_ = 0; + LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); + if (ghostStateCallback_) ghostStateCallback_(false); + } + } + else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { + playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); + } + else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); + } + else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { + playerCombatRatings_[key - ufRating1V] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } + } + // Do not auto-create quests from VALUES quest-log slot fields for the + // same reason as CREATE_OBJECT2 above (can be misaligned per realm). + if (applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) rebuildOnlineInventory(); + extractSkillFields(lastPlayerFields_); + extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); + } + + // Update item stack count / durability for online items + if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { + bool inventoryChanged = false; + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); + const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); + const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); + const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); + + auto it = onlineItems_.find(block.guid); + bool isItemInInventory = (it != onlineItems_.end()); + + for (const auto& [key, val] : block.fields) { + if (key == itemStackField && isItemInInventory) { + if (it->second.stackCount != val) { + it->second.stackCount = val; + inventoryChanged = true; + } + } else if (key == itemDurField && isItemInInventory) { + if (it->second.curDurability != val) { + it->second.curDurability = val; + inventoryChanged = true; + } + } else if (key == itemMaxDurField && isItemInInventory) { + if (it->second.maxDurability != val) { + it->second.maxDurability = val; + inventoryChanged = true; + } + } + } + // Update container slot GUIDs on bag content changes + if (entity->getType() == ObjectType::CONTAINER) { + for (const auto& [key, _] : block.fields) { + if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || + (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { + inventoryChanged = true; + break; + } + } + extractContainerFields(block.guid, block.fields); + } + if (inventoryChanged) { + rebuildOnlineInventory(); + } + } + if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { if (transportGuids_.count(block.guid) && transportMoveCallback_) { serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); - } - // Fire move callback for non-transport gameobjects. - if (entity->getType() == ObjectType::GAMEOBJECT && - transportGuids_.count(block.guid) == 0 && - gameObjectMoveCallback_) { + transportMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } else if (gameObjectMoveCallback_) { gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); } - // Fire move callback for non-player units (creatures). - // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many - // servers (especially vanilla/Turtle WoW) communicate NPC positions - // via MOVEMENT blocks instead. Use duration=0 for an instant snap. - if (block.guid != playerGuid && - entity->getType() == ObjectType::UNIT && - transportGuids_.count(block.guid) == 0 && - creatureMoveCallback_) { - creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); - } - } else { - LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); } - break; + + LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); + } else { + } + break; + } + + case UpdateType::MOVEMENT: { + // Diagnostic: Log if we receive MOVEMENT blocks for transports + if (transportGuids_.count(block.guid)) { + LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); } - default: - break; - } - } + // Update entity position (server → canonical) + auto entity = entityManager.getEntity(block.guid); + if (entity) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); + if (block.guid != playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); + } + } + + if (block.guid == playerGuid) { + movementInfo.orientation = oCanonical; + + // Track player-on-transport state from MOVEMENT updates + if (block.onTransport) { + setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + } + LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + // Don't clear client-side M2 transport boarding + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport (MOVEMENT)"); + clearPlayerTransport(); + } + } + } + + // Fire transport move callback if this is a known transport + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); + } + // Fire move callback for non-transport gameobjects. + if (entity->getType() == ObjectType::GAMEOBJECT && + transportGuids_.count(block.guid) == 0 && + gameObjectMoveCallback_) { + gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } + // Fire move callback for non-player units (creatures). + // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many + // servers (especially vanilla/Turtle WoW) communicate NPC positions + // via MOVEMENT blocks instead. Use duration=0 for an instant snap. + if (block.guid != playerGuid && + entity->getType() == ObjectType::UNIT && + transportGuids_.count(block.guid) == 0 && + creatureMoveCallback_) { + creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); + } + } else { + LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); + } + break; + } + + default: + break; + } +} + +void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) { tabCycleStale = true; // Entity count logging disabled @@ -20653,6 +20927,9 @@ void GameHandler::handleNewWorld(network::Packet& packet) { } currentMapId_ = mapId; + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "new_world"); + } // Update player position glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); @@ -20706,6 +20983,12 @@ void GameHandler::handleNewWorld(network::Packet& packet) { LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK"); } + timeSinceLastPing = 0.0f; + if (socket) { + LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK"); + sendPing(); + } + // Reload terrain at new position. // Pass isSameMap as isInitialEntry so the application despawns and // re-registers renderer instances before the server resends CREATE_OBJECTs. diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 4a28e556..d3d55dc6 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -441,6 +441,18 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da if (rawHitCount > 128) { LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); } + // Packed GUIDs are variable length, but each target needs at least 1 byte (mask). + // Require the minimum bytes before entering per-target parsing loops. + if (rem() < static_cast(rawHitCount) + 1u) { // +1 for mandatory missCount byte + static uint32_t badHitCountTrunc = 0; + ++badHitCountTrunc; + if (badHitCountTrunc <= 10 || (badHitCountTrunc % 100) == 0) { + LOG_WARNING("[Classic] Spell go: invalid hitCount/remaining (hits=", (int)rawHitCount, + " remaining=", rem(), " occurrence=", badHitCountTrunc, ")"); + } + packet.setReadPos(startPos); + return false; + } const uint8_t storedHitLimit = std::min(rawHitCount, 128); data.hitTargets.reserve(storedHitLimit); bool truncatedTargets = false; @@ -472,6 +484,17 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da if (rawMissCount > 128) { LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); } + // Each miss entry needs at least packed-guid mask (1) + missType (1). + if (rem() < static_cast(rawMissCount) * 2u) { + static uint32_t badMissCountTrunc = 0; + ++badMissCountTrunc; + if (badMissCountTrunc <= 10 || (badMissCountTrunc % 100) == 0) { + LOG_WARNING("[Classic] Spell go: invalid missCount/remaining (misses=", (int)rawMissCount, + " remaining=", rem(), " occurrence=", badMissCountTrunc, ")"); + } + packet.setReadPos(startPos); + return false; + } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); for (uint16_t i = 0; i < rawMissCount; ++i) { @@ -1810,6 +1833,173 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc return true; } +bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectData& data) { + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + + auto parseWithLayout = [&](bool withHasTransportByte, UpdateObjectData& out) -> bool { + out = UpdateObjectData{}; + const size_t start = packet.getReadPos(); + if (packet.getSize() - start < 4) return false; + + out.blockCount = packet.readUInt32(); + if (out.blockCount > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + + if (withHasTransportByte) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + /*uint8_t hasTransport =*/ packet.readUInt8(); + } + + if (packet.getReadPos() + 1 <= packet.getSize()) { + uint8_t firstByte = packet.readUInt8(); + if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (packet.getReadPos() + 4 > packet.getSize()) { + packet.setReadPos(start); + return false; + } + uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + for (uint32_t i = 0; i < count; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + out.outOfRangeGuids.push_back(UpdateObjectParser::readPackedGuid(packet)); + } + } else { + packet.setReadPos(packet.getReadPos() - 1); + } + } + + out.blocks.reserve(out.blockCount); + for (uint32_t i = 0; i < out.blockCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + + const size_t blockStart = packet.getReadPos(); + uint8_t updateTypeVal = packet.readUInt8(); + if (updateTypeVal > static_cast(UpdateType::NEAR_OBJECTS)) { + packet.setReadPos(start); + return false; + } + + const UpdateType updateType = static_cast(updateTypeVal); + UpdateBlock block; + block.updateType = updateType; + bool ok = false; + + auto parseMovementVariant = [&](auto&& movementParser, const char* layoutName) -> bool { + packet.setReadPos(blockStart + 1); + block = UpdateBlock{}; + block.updateType = updateType; + + switch (updateType) { + case UpdateType::MOVEMENT: + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (!movementParser(packet, block)) return false; + LOG_DEBUG("[Turtle] Parsed MOVEMENT block via ", layoutName, " layout"); + return true; + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getReadPos() >= packet.getSize()) return false; + block.objectType = static_cast(packet.readUInt8()); + if (!movementParser(packet, block)) return false; + if (!UpdateObjectParser::parseUpdateFields(packet, block)) return false; + LOG_DEBUG("[Turtle] Parsed CREATE block via ", layoutName, " layout"); + return true; + default: + return false; + } + }; + + switch (updateType) { + case UpdateType::VALUES: + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + case UpdateType::MOVEMENT: + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->TurtlePacketParsers::parseMovementBlock(p, b); + }, "turtle"); + if (!ok) { + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->ClassicPacketParsers::parseMovementBlock(p, b); + }, "classic"); + } + if (!ok) { + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->TbcPacketParsers::parseMovementBlock(p, b); + }, "tbc"); + } + break; + case UpdateType::OUT_OF_RANGE_OBJECTS: + case UpdateType::NEAR_OBJECTS: + ok = true; + break; + default: + ok = false; + break; + } + + if (!ok) { + packet.setReadPos(start); + return false; + } + + out.blocks.push_back(std::move(block)); + } + + return true; + }; + + const size_t startPos = packet.getReadPos(); + UpdateObjectData parsed; + if (parseWithLayout(true, parsed)) { + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (parseWithLayout(false, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (ClassicPacketParsers::parseUpdateObject(packet, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full classic fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (TbcPacketParsers::parseUpdateObject(packet, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full TBC fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + return false; +} + bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { // Turtle realms can emit both vanilla-like and WotLK-like monster move bodies. // Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout. diff --git a/src/game/warden_memory.cpp b/src/game/warden_memory.cpp index c5a0afd2..aa921155 100644 --- a/src/game/warden_memory.cpp +++ b/src/game/warden_memory.cpp @@ -272,9 +272,28 @@ bool WardenMemory::readMemory(uint32_t va, uint8_t length, uint8_t* outBuf) cons return true; } - // PE image range - if (!loaded_ || va < imageBase_) return false; - uint32_t offset = va - imageBase_; + if (!loaded_) return false; + + // Warden MEM_CHECK offsets are seen in multiple forms: + // 1) Absolute VA (e.g. 0x00401337) + // 2) RVA (e.g. 0x000139A9) + // 3) Tiny module-relative offsets (e.g. 0x00000229, 0x00000008) + // Accept all three to avoid fallback-to-zeros on Classic/Turtle. + uint32_t offset = 0; + if (va >= imageBase_) { + // Absolute VA. + offset = va - imageBase_; + } else if (va < imageSize_) { + // RVA into WoW.exe image. + offset = va; + } else { + // Tiny relative offsets frequently target fake Warden runtime globals. + constexpr uint32_t kFakeWardenBase = 0xCE8000; + const uint32_t remappedVa = kFakeWardenBase + va; + if (remappedVa < imageBase_) return false; + offset = remappedVa - imageBase_; + } + if (static_cast(offset) + length > imageSize_) return false; std::memcpy(outBuf, image_.data() + offset, length); diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 1c253459..5bb76027 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -59,15 +59,14 @@ bool WardenModule::load(const std::vector& moduleData, // Step 1: Verify MD5 hash if (!verifyMD5(moduleData, md5Hash)) { - std::cerr << "[WardenModule] MD5 verification failed!" << '\n'; - return false; + std::cerr << "[WardenModule] MD5 verification failed; continuing in compatibility mode" << '\n'; } std::cout << "[WardenModule] ✓ MD5 verified" << '\n'; // Step 2: RC4 decrypt (Warden protocol-required legacy RC4; server-mandated, cannot be changed) if (!decryptRC4(moduleData, rc4Key, decryptedData_)) { // codeql[cpp/weak-cryptographic-algorithm] - std::cerr << "[WardenModule] RC4 decryption failed!" << '\n'; - return false; + std::cerr << "[WardenModule] RC4 decryption failed; using raw module bytes fallback" << '\n'; + decryptedData_ = moduleData; } std::cout << "[WardenModule] ✓ RC4 decrypted (" << decryptedData_.size() << " bytes)" << '\n'; @@ -85,20 +84,18 @@ bool WardenModule::load(const std::vector& moduleData, dataWithoutSig = decryptedData_; } if (!decompressZlib(dataWithoutSig, decompressedData_)) { - std::cerr << "[WardenModule] zlib decompression failed!" << '\n'; - return false; + std::cerr << "[WardenModule] zlib decompression failed; using decrypted bytes fallback" << '\n'; + decompressedData_ = decryptedData_; } // Step 5: Parse custom executable format if (!parseExecutableFormat(decompressedData_)) { - std::cerr << "[WardenModule] Executable format parsing failed!" << '\n'; - return false; + std::cerr << "[WardenModule] Executable format parsing failed; continuing with minimal module image" << '\n'; } // Step 6: Apply relocations if (!applyRelocations()) { - std::cerr << "[WardenModule] Address relocations failed!" << '\n'; - return false; + std::cerr << "[WardenModule] Address relocations failed; continuing with unrelocated image" << '\n'; } // Step 7: Bind APIs @@ -109,8 +106,7 @@ bool WardenModule::load(const std::vector& moduleData, // Step 8: Initialize module if (!initializeModule()) { - std::cerr << "[WardenModule] Module initialization failed!" << '\n'; - return false; + std::cerr << "[WardenModule] Module initialization failed; continuing with stub callbacks" << '\n'; } // Module loading pipeline complete! diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index ba036f2d..9af7c692 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1344,8 +1344,10 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) { - constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; - constexpr uint32_t kMaxReasonableOutOfRangeGuids = 16384; + // Keep worst-case packet parsing bounded. Extremely large counts are typically + // malformed/desynced and can stall a frame long enough to trigger disconnects. + constexpr uint32_t kMaxReasonableUpdateBlocks = 1024; + constexpr uint32_t kMaxReasonableOutOfRangeGuids = 4096; // Read block count data.blockCount = packet.readUInt32(); diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 78c90c8e..e84d7426 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -1,6 +1,7 @@ #include "network/world_socket.hpp" #include "network/packet.hpp" #include "network/net_platform.hpp" +#include "game/opcode_table.hpp" #include "auth/crypto.hpp" #include "core/logger.hpp" #include @@ -9,10 +10,49 @@ #include #include #include +#include +#include namespace { constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024; -constexpr int kMaxParsedPacketsPerUpdate = 220; +constexpr int kDefaultMaxParsedPacketsPerUpdate = 16; +constexpr int kAbsoluteMaxParsedPacketsPerUpdate = 220; +constexpr int kMinParsedPacketsPerUpdate = 8; +constexpr int kDefaultMaxPacketCallbacksPerUpdate = 6; +constexpr int kAbsoluteMaxPacketCallbacksPerUpdate = 64; +constexpr int kMinPacketCallbacksPerUpdate = 1; +constexpr int kMaxRecvCallsPerUpdate = 64; +constexpr size_t kMaxRecvBytesPerUpdate = 512 * 1024; +constexpr size_t kMaxQueuedPacketCallbacks = 4096; +constexpr int kAsyncPumpSleepMs = 2; + +inline int parsedPacketsBudgetPerUpdate() { + static int budget = []() { + const char* raw = std::getenv("WOWEE_NET_MAX_PARSED_PACKETS"); + if (!raw || !*raw) return kDefaultMaxParsedPacketsPerUpdate; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return kDefaultMaxParsedPacketsPerUpdate; + if (parsed < kMinParsedPacketsPerUpdate) return kMinParsedPacketsPerUpdate; + if (parsed > kAbsoluteMaxParsedPacketsPerUpdate) return kAbsoluteMaxParsedPacketsPerUpdate; + return static_cast(parsed); + }(); + return budget; +} + +inline int packetCallbacksBudgetPerUpdate() { + static int budget = []() { + const char* raw = std::getenv("WOWEE_NET_MAX_PACKET_CALLBACKS"); + if (!raw || !*raw) return kDefaultMaxPacketCallbacksPerUpdate; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return kDefaultMaxPacketCallbacksPerUpdate; + if (parsed < kMinPacketCallbacksPerUpdate) return kMinPacketCallbacksPerUpdate; + if (parsed > kAbsoluteMaxPacketCallbacksPerUpdate) return kAbsoluteMaxPacketCallbacksPerUpdate; + return static_cast(parsed); + }(); + return budget; +} inline bool isLoginPipelineSmsg(uint16_t opcode) { switch (opcode) { @@ -49,6 +89,14 @@ inline bool envFlagEnabled(const char* key, bool defaultValue = false) { return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || raw[0] == 'n' || raw[0] == 'N'); } + +const char* opcodeNameForTrace(uint16_t wireOpcode) { + const auto* table = wowee::game::getActiveOpcodeTable(); + if (!table) return "UNKNOWN"; + auto logical = table->fromWire(wireOpcode); + if (!logical) return "UNKNOWN"; + return wowee::game::OpcodeTable::logicalToName(*logical); +} } // namespace namespace wowee { @@ -71,6 +119,7 @@ WorldSocket::WorldSocket() { receiveBuffer.reserve(64 * 1024); useFastRecvAppend_ = envFlagEnabled("WOWEE_NET_FAST_RECV_APPEND", true); useParseScratchQueue_ = envFlagEnabled("WOWEE_NET_PARSE_SCRATCH", false); + useAsyncPump_ = envFlagEnabled("WOWEE_NET_ASYNC_PUMP", true); if (useParseScratchQueue_) { LOG_WARNING("WOWEE_NET_PARSE_SCRATCH is temporarily disabled (known unstable); forcing off"); useParseScratchQueue_ = false; @@ -79,7 +128,10 @@ WorldSocket::WorldSocket() { parsedPacketsScratch_.reserve(64); } LOG_INFO("WorldSocket net opts: fast_recv_append=", useFastRecvAppend_ ? "on" : "off", - " parse_scratch=", useParseScratchQueue_ ? "on" : "off"); + " async_pump=", useAsyncPump_ ? "on" : "off", + " parse_scratch=", useParseScratchQueue_ ? "on" : "off", + " max_parsed_packets=", parsedPacketsBudgetPerUpdate(), + " max_packet_callbacks=", packetCallbacksBudgetPerUpdate()); } WorldSocket::~WorldSocket() { @@ -89,6 +141,8 @@ WorldSocket::~WorldSocket() { bool WorldSocket::connect(const std::string& host, uint16_t port) { LOG_INFO("Connecting to world server: ", host, ":", port); + stopAsyncPump(); + // Create socket sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == INVALID_SOCK) { @@ -165,32 +219,59 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { connected = true; LOG_INFO("Connected to world server: ", host, ":", port); + startAsyncPump(); return true; } void WorldSocket::disconnect() { + stopAsyncPump(); + { + std::lock_guard lock(ioMutex_); + closeSocketNoJoin(); + encryptionEnabled = false; + useVanillaCrypt = false; + receiveBuffer.clear(); + receiveReadOffset_ = 0; + parsedPacketsScratch_.clear(); + headerBytesDecrypted = 0; + packetTraceStart_ = {}; + packetTraceUntil_ = {}; + packetTraceReason_.clear(); + } + { + std::lock_guard lock(callbackMutex_); + pendingPacketCallbacks_.clear(); + } + LOG_INFO("Disconnected from world server"); +} + +void WorldSocket::tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason) { + std::lock_guard lock(ioMutex_); + packetTraceStart_ = std::chrono::steady_clock::now(); + packetTraceUntil_ = packetTraceStart_ + duration; + packetTraceReason_ = reason; + LOG_WARNING("WS TRACE enabled: reason='", packetTraceReason_, + "' durationMs=", duration.count()); +} + +bool WorldSocket::isConnected() const { + std::lock_guard lock(ioMutex_); + return connected; +} + +void WorldSocket::closeSocketNoJoin() { if (sockfd != INVALID_SOCK) { net::closeSocket(sockfd); sockfd = INVALID_SOCK; } connected = false; - encryptionEnabled = false; - useVanillaCrypt = false; - receiveBuffer.clear(); - receiveReadOffset_ = 0; - parsedPacketsScratch_.clear(); - headerBytesDecrypted = 0; - LOG_INFO("Disconnected from world server"); -} - -bool WorldSocket::isConnected() const { - return connected; } void WorldSocket::send(const Packet& packet) { - if (!connected) return; static const bool kLogCharCreatePayload = envFlagEnabled("WOWEE_NET_LOG_CHAR_CREATE", false); static const bool kLogSwapItemPackets = envFlagEnabled("WOWEE_NET_LOG_SWAP_ITEM", false); + std::lock_guard lock(ioMutex_); + if (!connected || sockfd == INVALID_SOCK) return; const auto& data = packet.getData(); uint16_t opcode = packet.getOpcode(); @@ -254,6 +335,17 @@ void WorldSocket::send(const Packet& packet) { LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]"); } + const auto traceNow = std::chrono::steady_clock::now(); + if (packetTraceUntil_ > traceNow) { + const auto elapsedMs = std::chrono::duration_cast( + traceNow - packetTraceStart_).count(); + LOG_WARNING("WS TRACE TX +", elapsedMs, "ms opcode=0x", + std::hex, opcode, std::dec, + " logical=", opcodeNameForTrace(opcode), + " payload=", payloadLen, + " reason='", packetTraceReason_, "'"); + } + // WotLK 3.3.5 CMSG header (6 bytes total): // - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG) // - opcode (4 bytes, little-endian) @@ -317,7 +409,46 @@ void WorldSocket::send(const Packet& packet) { } void WorldSocket::update() { - if (!connected) return; + if (!useAsyncPump_) { + pumpNetworkIO(); + } + dispatchQueuedPackets(); +} + +void WorldSocket::startAsyncPump() { + if (!useAsyncPump_ || asyncPumpRunning_.load(std::memory_order_acquire)) { + return; + } + asyncPumpStop_.store(false, std::memory_order_release); + asyncPumpThread_ = std::thread(&WorldSocket::asyncPumpLoop, this); +} + +void WorldSocket::stopAsyncPump() { + asyncPumpStop_.store(true, std::memory_order_release); + if (asyncPumpThread_.joinable()) { + asyncPumpThread_.join(); + } + asyncPumpRunning_.store(false, std::memory_order_release); +} + +void WorldSocket::asyncPumpLoop() { + asyncPumpRunning_.store(true, std::memory_order_release); + while (!asyncPumpStop_.load(std::memory_order_acquire)) { + pumpNetworkIO(); + { + std::lock_guard lock(ioMutex_); + if (!connected) { + break; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(kAsyncPumpSleepMs)); + } + asyncPumpRunning_.store(false, std::memory_order_release); +} + +void WorldSocket::pumpNetworkIO() { + std::lock_guard lock(ioMutex_); + if (!connected || sockfd == INVALID_SOCK) return; auto bufferedBytes = [&]() -> size_t { return (receiveBuffer.size() >= receiveReadOffset_) ? (receiveBuffer.size() - receiveReadOffset_) @@ -343,7 +474,8 @@ void WorldSocket::update() { bool receivedAny = false; size_t bytesReadThisTick = 0; int readOps = 0; - while (connected) { + while (connected && readOps < kMaxRecvCallsPerUpdate && + bytesReadThisTick < kMaxRecvBytesPerUpdate) { uint8_t buffer[4096]; ssize_t received = net::portableRecv(sockfd, buffer, sizeof(buffer)); @@ -362,7 +494,7 @@ void WorldSocket::update() { LOG_ERROR("World socket receive buffer would overflow (buffered=", liveBytes, " incoming=", receivedSize, " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } const size_t oldSize = receiveBuffer.size(); @@ -375,7 +507,7 @@ void WorldSocket::update() { if (newCap < needed) { LOG_ERROR("World socket receive buffer capacity growth failed (needed=", needed, " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } receiveBuffer.reserve(newCap); @@ -387,7 +519,7 @@ void WorldSocket::update() { if (bufferedBytes() > kMaxReceiveBufferBytes) { LOG_ERROR("World socket receive buffer overflow (", bufferedBytes(), " bytes). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } continue; @@ -409,7 +541,7 @@ void WorldSocket::update() { } LOG_ERROR("Receive failed: ", net::errorString(err)); - disconnect(); + closeSocketNoJoin(); return; } @@ -434,10 +566,15 @@ void WorldSocket::update() { } } + if (connected && (readOps >= kMaxRecvCallsPerUpdate || bytesReadThisTick >= kMaxRecvBytesPerUpdate)) { + LOG_DEBUG("World socket recv budget reached (calls=", readOps, + ", bytes=", bytesReadThisTick, "), deferring remaining socket drain"); + } + if (sawClose) { LOG_INFO("World server connection closed (receivedAny=", receivedAny, " buffered=", bufferedBytes(), ")"); - disconnect(); + closeSocketNoJoin(); return; } } @@ -462,7 +599,8 @@ void WorldSocket::tryParsePackets() { } else { parsedPacketsLocal.reserve(32); } - while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < kMaxParsedPacketsPerUpdate) { + const int maxParsedThisTick = parsedPacketsBudgetPerUpdate(); + while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < maxParsedThisTick) { uint8_t rawHeader[4] = {0, 0, 0, 0}; std::memcpy(rawHeader, receiveBuffer.data() + parseOffset, 4); @@ -491,7 +629,7 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), std::dec, " enc=", encryptionEnabled, ". Disconnecting to recover stream."); - disconnect(); + closeSocketNoJoin(); return; } constexpr uint16_t kMaxWorldPacketSize = 0x4000; @@ -503,7 +641,7 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), std::dec, " enc=", encryptionEnabled, ". Disconnecting to recover stream."); - disconnect(); + closeSocketNoJoin(); return; } @@ -535,6 +673,16 @@ void WorldSocket::tryParsePackets() { " buffered=", (receiveBuffer.size() - parseOffset), " enc=", encryptionEnabled ? "yes" : "no"); } + const auto traceNow = std::chrono::steady_clock::now(); + if (packetTraceUntil_ > traceNow) { + const auto elapsedMs = std::chrono::duration_cast( + traceNow - packetTraceStart_).count(); + LOG_WARNING("WS TRACE RX +", elapsedMs, "ms opcode=0x", + std::hex, opcode, std::dec, + " logical=", opcodeNameForTrace(opcode), + " payload=", payloadLen, + " reason='", packetTraceReason_, "'"); + } if ((receiveBuffer.size() - parseOffset) < totalSize) { // Not enough data yet - header stays decrypted in buffer @@ -555,7 +703,7 @@ void WorldSocket::tryParsePackets() { " payload=", payloadLen, " buffered=", receiveBuffer.size(), " parseOffset=", parseOffset, " what=", e.what(), ". Disconnecting to recover."); - disconnect(); + closeSocketNoJoin(); return; } parseOffset += totalSize; @@ -578,23 +726,57 @@ void WorldSocket::tryParsePackets() { } headerBytesDecrypted = localHeaderBytesDecrypted; - if (packetCallback) { - for (const auto& packet : *parsedPackets) { - if (!connected) break; - packetCallback(packet); + // Queue parsed packets for main-thread dispatch. + if (!parsedPackets->empty()) { + std::lock_guard callbackLock(callbackMutex_); + for (auto& packet : *parsedPackets) { + pendingPacketCallbacks_.push_back(std::move(packet)); + } + if (pendingPacketCallbacks_.size() > kMaxQueuedPacketCallbacks) { + LOG_ERROR("World socket callback queue overflow (", pendingPacketCallbacks_.size(), + " packets). Disconnecting to recover."); + pendingPacketCallbacks_.clear(); + closeSocketNoJoin(); + return; } } const size_t buffered = (receiveBuffer.size() >= receiveReadOffset_) ? (receiveBuffer.size() - receiveReadOffset_) : 0; - if (parsedThisTick >= kMaxParsedPacketsPerUpdate && buffered >= 4) { + if (parsedThisTick >= maxParsedThisTick && buffered >= 4) { LOG_DEBUG("World socket parse budget reached (", parsedThisTick, " packets); deferring remaining buffered data=", buffered, " bytes"); } } +void WorldSocket::dispatchQueuedPackets() { + std::deque localPackets; + { + std::lock_guard lock(callbackMutex_); + if (!packetCallback || pendingPacketCallbacks_.empty()) { + return; + } + const int maxCallbacksThisTick = packetCallbacksBudgetPerUpdate(); + for (int i = 0; i < maxCallbacksThisTick && !pendingPacketCallbacks_.empty(); ++i) { + localPackets.push_back(std::move(pendingPacketCallbacks_.front())); + pendingPacketCallbacks_.pop_front(); + } + if (!pendingPacketCallbacks_.empty()) { + LOG_DEBUG("World socket callback budget reached (", localPackets.size(), + " callbacks); deferring ", pendingPacketCallbacks_.size(), + " queued packet callbacks"); + } + } + + while (!localPackets.empty()) { + packetCallback(localPackets.front()); + localPackets.pop_front(); + } +} + void WorldSocket::initEncryption(const std::vector& sessionKey, uint32_t build) { + std::lock_guard lock(ioMutex_); if (sessionKey.size() != 40) { LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)"); return; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index fc8842f3..6b4e00b8 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -343,6 +343,8 @@ void CharacterRenderer::shutdown() { // Clean up composite cache compositeCache_.clear(); failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); + textureLookupSerial_ = 0; whiteTexture_.reset(); transparentTexture_.reset(); @@ -430,6 +432,8 @@ void CharacterRenderer::clear() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; loggedTextureLoadFails_.clear(); + failedTextureRetryAt_.clear(); + textureLookupSerial_ = 0; // Clear composite and failed caches compositeCache_.clear(); @@ -604,6 +608,7 @@ CharacterRenderer::NormalMapResult CharacterRenderer::generateNormalHeightMapCPU } VkTexture* CharacterRenderer::loadTexture(const std::string& path) { + constexpr uint64_t kFailedTextureRetryLookups = 512; // Skip empty or whitespace-only paths (type-0 textures have no filename) if (path.empty()) return whiteTexture_.get(); bool allWhitespace = true; @@ -619,6 +624,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { return key; }; std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; }; @@ -634,6 +640,10 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } if (!assetManager || !assetManager->isInitialized()) { return whiteTexture_.get(); @@ -652,8 +662,9 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { blpImage = assetManager->loadTexture(key); } if (!blpImage.isValid()) { - // Return white fallback but don't cache the failure — allow retry - // on next character load in case the asset becomes available. + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; if (loggedTextureLoadFails_.insert(key).second) { core::Logger::getInstance().warning("Failed to load texture: ", path); } @@ -666,6 +677,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { if (failedTextureCache_.size() < kMaxFailedTextureCache) { // Budget is saturated; avoid repeatedly decoding/uploading this texture. failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; } if (textureBudgetRejectWarnings_ < 3) { core::Logger::getInstance().warning( @@ -724,6 +736,8 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")"); return texPtr; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index aad92ab5..ea815963 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -714,7 +714,9 @@ void M2Renderer::shutdown() { textureHasAlphaByPtr_.clear(); textureColorKeyBlackByPtr_.clear(); failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; whiteTexture_.reset(); glowTexture_.reset(); @@ -4251,6 +4253,7 @@ void M2Renderer::cleanupUnusedModels() { } VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { + constexpr uint64_t kFailedTextureRetryLookups = 512; auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -4258,6 +4261,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { return key; }; std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; // Check cache auto it = textureCache.find(key); @@ -4265,7 +4269,10 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } - // No negative cache check — allow retries for transiently missing textures + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; @@ -4296,8 +4303,9 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { blp = assetManager->loadTexture(key); } if (!blp.isValid()) { - // Return white fallback but don't cache the failure — MPQ reads can - // fail transiently during streaming; allow retry on next model load. + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; if (loggedTextureLoadFails_.insert(key).second) { LOG_WARNING("M2: Failed to load texture: ", path); } @@ -4312,6 +4320,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { // Cache budget-rejected keys too; without this we repeatedly decode/load // the same textures every frame once budget is saturated. failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; } if (textureBudgetRejectWarnings_ < 3) { LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), @@ -4350,6 +4359,8 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 340b242d..f380cc65 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -54,9 +54,11 @@ int computeTerrainWorkerCount() { unsigned hc = std::thread::hardware_concurrency(); if (hc > 0) { - // Use most cores for loading — leave 1-2 for render/update threads. - const unsigned reserved = (hc >= 8u) ? 2u : 1u; - const unsigned targetWorkers = std::max(4u, hc - reserved); + // Keep terrain workers conservative by default. Over-subscribing loader + // threads can starve main-thread networking/render updates on large-core CPUs. + const unsigned reserved = (hc >= 16u) ? 4u : ((hc >= 8u) ? 2u : 1u); + const unsigned maxDefaultWorkers = 8u; + const unsigned targetWorkers = std::max(4u, std::min(maxDefaultWorkers, hc - reserved)); return static_cast(targetWorkers); } return 4; // Fallback @@ -896,6 +898,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) { continue; } + if (!m2Renderer->hasModel(p.modelId)) { + continue; + } uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale); if (instId) { ft.m2InstanceIds.push_back(instId); @@ -961,6 +966,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { continue; } + if (!wmoRenderer->isModelLoaded(wmoReady.modelId)) { + continue; + } uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation); if (wmoInstId) { ft.wmoInstanceIds.push_back(wmoInstId); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 523bf818..c15bad3f 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -307,7 +307,9 @@ void WMORenderer::shutdown() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; // Free white texture and flat normal texture @@ -1087,7 +1089,9 @@ void WMORenderer::clearAll() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; precomputedFloorGrid.clear(); @@ -2237,6 +2241,7 @@ std::unique_ptr WMORenderer::generateNormalHeightMap( } VkTexture* WMORenderer::loadTexture(const std::string& path) { + constexpr uint64_t kFailedTextureRetryLookups = 512; if (!assetManager || !vkCtx_) { return whiteTexture_.get(); } @@ -2312,7 +2317,19 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } - const auto& attemptedCandidates = uniqueCandidates; + const uint64_t lookupSerial = ++textureLookupSerial_; + std::vector attemptedCandidates; + attemptedCandidates.reserve(uniqueCandidates.size()); + for (const auto& c : uniqueCandidates) { + auto fit = failedTextureRetryAt_.find(c); + if (fit != failedTextureRetryAt_.end() && lookupSerial < fit->second) { + continue; + } + attemptedCandidates.push_back(c); + } + if (attemptedCandidates.empty()) { + return whiteTexture_.get(); + } // Try loading all candidates until one succeeds // Check pre-decoded BLP cache first (populated by background worker threads) @@ -2339,6 +2356,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } if (!blp.isValid()) { + for (const auto& c : attemptedCandidates) { + failedTextureCache_.insert(c); + failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups; + } if (loggedTextureLoadFails_.insert(key).second) { core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); } @@ -2353,6 +2374,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; size_t approxBytes = base + (base / 3); if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + for (const auto& c : attemptedCandidates) { + failedTextureCache_.insert(c); + failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups; + } if (textureBudgetRejectWarnings_ < 3) { core::Logger::getInstance().warning( "WMO texture cache full (", textureCacheBytes_ / (1024 * 1024), @@ -2394,8 +2419,12 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { textureCacheBytes_ += e.approxBytes; if (!resolvedKey.empty()) { textureCache[resolvedKey] = std::move(e); + failedTextureCache_.erase(resolvedKey); + failedTextureRetryAt_.erase(resolvedKey); } else { textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); } core::Logger::getInstance().debug("WMO: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); From 0b6265bc557a4facde2087881ef3875d61045767 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 15 Mar 2026 01:47:36 -0700 Subject: [PATCH 36/38] fix: align turtle protocol compatibility --- Data/expansions/turtle/opcodes.json | 3 +- include/game/world_packets.hpp | 66 +++++++++++++++-------------- src/game/packet_parsers_classic.cpp | 6 +++ 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index c3d7cbd7..b39f7b8f 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -127,7 +127,7 @@ "SMSG_SPELL_FAILURE": "0x133", "SMSG_SPELL_COOLDOWN": "0x134", "SMSG_COOLDOWN_EVENT": "0x135", - "SMSG_EQUIPMENT_SET_SAVED": "0x137", + "SMSG_UPDATE_AURA_DURATION": "0x137", "SMSG_INITIAL_SPELLS": "0x12A", "SMSG_LEARNED_SPELL": "0x12B", "SMSG_SUPERCEDED_SPELL": "0x12C", @@ -242,6 +242,7 @@ "SMSG_BATTLEFIELD_STATUS": "0x2D4", "CMSG_BATTLEFIELD_PORT": "0x2D5", "CMSG_BATTLEMASTER_HELLO": "0x2D7", + "SMSG_SPELL_FAILED_OTHER": "0x2A6", "MSG_PVP_LOG_DATA": "0x2E0", "CMSG_LEAVE_BATTLEFIELD": "0x2E1", "SMSG_GROUP_JOINED_BATTLEGROUND": "0x2E8", diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 15b8f8ff..293953ea 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -756,38 +756,40 @@ public: * Channel notification types */ enum class ChannelNotifyType : uint8_t { - YOU_JOINED = 0x00, - YOU_LEFT = 0x01, - WRONG_PASSWORD = 0x02, - NOT_MEMBER = 0x03, - NOT_MODERATOR = 0x04, - PASSWORD_CHANGED = 0x05, - OWNER_CHANGED = 0x06, - PLAYER_NOT_FOUND = 0x07, - NOT_OWNER = 0x08, - CHANNEL_OWNER = 0x09, - MODE_CHANGE = 0x0A, - ANNOUNCEMENTS_ON = 0x0B, - ANNOUNCEMENTS_OFF = 0x0C, - MODERATION_ON = 0x0D, - MODERATION_OFF = 0x0E, - MUTED = 0x0F, - PLAYER_KICKED = 0x10, - BANNED = 0x11, - PLAYER_BANNED = 0x12, - PLAYER_UNBANNED = 0x13, - PLAYER_NOT_BANNED = 0x14, - PLAYER_ALREADY_MEMBER = 0x15, - INVITE = 0x16, - INVITE_WRONG_FACTION = 0x17, - WRONG_FACTION = 0x18, - INVALID_NAME = 0x19, - NOT_MODERATED = 0x1A, - PLAYER_INVITED = 0x1B, - PLAYER_INVITE_BANNED = 0x1C, - THROTTLED = 0x1D, - NOT_IN_AREA = 0x1E, - NOT_IN_LFG = 0x1F, + PLAYER_JOINED = 0x00, + PLAYER_LEFT = 0x01, + YOU_JOINED = 0x02, + YOU_LEFT = 0x03, + WRONG_PASSWORD = 0x04, + NOT_MEMBER = 0x05, + NOT_MODERATOR = 0x06, + PASSWORD_CHANGED = 0x07, + OWNER_CHANGED = 0x08, + PLAYER_NOT_FOUND = 0x09, + NOT_OWNER = 0x0A, + CHANNEL_OWNER = 0x0B, + MODE_CHANGE = 0x0C, + ANNOUNCEMENTS_ON = 0x0D, + ANNOUNCEMENTS_OFF = 0x0E, + MODERATION_ON = 0x0F, + MODERATION_OFF = 0x10, + MUTED = 0x11, + PLAYER_KICKED = 0x12, + BANNED = 0x13, + PLAYER_BANNED = 0x14, + PLAYER_UNBANNED = 0x15, + PLAYER_NOT_BANNED = 0x16, + PLAYER_ALREADY_MEMBER = 0x17, + INVITE = 0x18, + INVITE_WRONG_FACTION = 0x19, + WRONG_FACTION = 0x1A, + INVALID_NAME = 0x1B, + NOT_MODERATED = 0x1C, + PLAYER_INVITED = 0x1D, + PLAYER_INVITE_BANNED = 0x1E, + THROTTLED = 0x1F, + NOT_IN_AREA = 0x20, + NOT_IN_LFG = 0x21, }; /** diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index d3d55dc6..663bafd3 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1947,6 +1947,12 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec return this->TbcPacketParsers::parseMovementBlock(p, b); }, "tbc"); } + if (!ok) { + ok = parseMovementVariant( + [](network::Packet& p, UpdateBlock& b) { + return UpdateObjectParser::parseMovementBlock(p, b); + }, "wotlk"); + } break; case UpdateType::OUT_OF_RANGE_OBJECTS: case UpdateType::NEAR_OBJECTS: From 6ede9a2968c948bf8d4793d3895ec2bdfac51654 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 15 Mar 2026 02:55:05 -0700 Subject: [PATCH 37/38] refactor: derive turtle opcodes from classic --- Data/expansions/classic/opcodes.json | 18 +- Data/expansions/turtle/opcodes.json | 304 +--------------------- Data/opcodes/aliases.json | 1 - include/game/opcode_aliases_generated.inc | 1 - include/game/opcode_table.hpp | 5 +- include/game/packet_parsers.hpp | 14 +- src/game/game_handler.cpp | 19 +- src/game/opcode_table.cpp | 218 +++++++++++----- src/game/packet_parsers_classic.cpp | 11 +- tools/diff_classic_turtle_opcodes.py | 175 +++++++++++++ tools/opcode_map_utils.py | 46 ++++ tools/validate_opcode_maps.py | 10 +- 12 files changed, 428 insertions(+), 394 deletions(-) create mode 100644 tools/diff_classic_turtle_opcodes.py create mode 100644 tools/opcode_map_utils.py diff --git a/Data/expansions/classic/opcodes.json b/Data/expansions/classic/opcodes.json index b99e4223..760647d9 100644 --- a/Data/expansions/classic/opcodes.json +++ b/Data/expansions/classic/opcodes.json @@ -273,7 +273,7 @@ "SMSG_INVENTORY_CHANGE_FAILURE": "0x112", "SMSG_OPEN_CONTAINER": "0x113", "CMSG_INSPECT": "0x114", - "SMSG_INSPECT": "0x115", + "SMSG_INSPECT_RESULTS_UPDATE": "0x115", "CMSG_INITIATE_TRADE": "0x116", "CMSG_BEGIN_TRADE": "0x117", "CMSG_BUSY_TRADE": "0x118", @@ -300,7 +300,7 @@ "CMSG_NEW_SPELL_SLOT": "0x12D", "CMSG_CAST_SPELL": "0x12E", "CMSG_CANCEL_CAST": "0x12F", - "SMSG_CAST_RESULT": "0x130", + "SMSG_CAST_FAILED": "0x130", "SMSG_SPELL_START": "0x131", "SMSG_SPELL_GO": "0x132", "SMSG_SPELL_FAILURE": "0x133", @@ -504,8 +504,7 @@ "CMSG_GM_SET_SECURITY_GROUP": "0x1F9", "CMSG_GM_NUKE": "0x1FA", "MSG_RANDOM_ROLL": "0x1FB", - "SMSG_ENVIRONMENTALDAMAGELOG": "0x1FC", - "CMSG_RWHOIS_OBSOLETE": "0x1FD", + "SMSG_ENVIRONMENTAL_DAMAGE_LOG": "0x1FC", "SMSG_RWHOIS": "0x1FE", "MSG_LOOKING_FOR_GROUP": "0x1FF", "CMSG_SET_LOOKING_FOR_GROUP": "0x200", @@ -528,7 +527,6 @@ "CMSG_GMTICKET_GETTICKET": "0x211", "SMSG_GMTICKET_GETTICKET": "0x212", "CMSG_UNLEARN_TALENTS": "0x213", - "SMSG_GAMEOBJECT_SPAWN_ANIM_OBSOLETE": "0x214", "SMSG_GAMEOBJECT_DESPAWN_ANIM": "0x215", "MSG_CORPSE_QUERY": "0x216", "CMSG_GMTICKET_DELETETICKET": "0x217", @@ -538,7 +536,7 @@ "SMSG_GMTICKET_SYSTEMSTATUS": "0x21B", "CMSG_SPIRIT_HEALER_ACTIVATE": "0x21C", "CMSG_SET_STAT_CHEAT": "0x21D", - "SMSG_SET_REST_START": "0x21E", + "SMSG_QUEST_FORCE_REMOVE": "0x21E", "CMSG_SKILL_BUY_STEP": "0x21F", "CMSG_SKILL_BUY_RANK": "0x220", "CMSG_XP_CHEAT": "0x221", @@ -571,8 +569,6 @@ "CMSG_BATTLEFIELD_LIST": "0x23C", "SMSG_BATTLEFIELD_LIST": "0x23D", "CMSG_BATTLEFIELD_JOIN": "0x23E", - "SMSG_BATTLEFIELD_WIN_OBSOLETE": "0x23F", - "SMSG_BATTLEFIELD_LOSE_OBSOLETE": "0x240", "CMSG_TAXICLEARNODE": "0x241", "CMSG_TAXIENABLENODE": "0x242", "CMSG_ITEM_TEXT_QUERY": "0x243", @@ -605,7 +601,6 @@ "SMSG_AUCTION_BIDDER_NOTIFICATION": "0x25E", "SMSG_AUCTION_OWNER_NOTIFICATION": "0x25F", "SMSG_PROCRESIST": "0x260", - "SMSG_STANDSTATE_CHANGE_FAILURE_OBSOLETE": "0x261", "SMSG_DISPEL_FAILED": "0x262", "SMSG_SPELLORDAMAGE_IMMUNE": "0x263", "CMSG_AUCTION_LIST_BIDDER_ITEMS": "0x264", @@ -693,8 +688,8 @@ "SMSG_SCRIPT_MESSAGE": "0x2B6", "SMSG_DUEL_COUNTDOWN": "0x2B7", "SMSG_AREA_TRIGGER_MESSAGE": "0x2B8", - "CMSG_TOGGLE_HELM": "0x2B9", - "CMSG_TOGGLE_CLOAK": "0x2BA", + "CMSG_SHOWING_HELM": "0x2B9", + "CMSG_SHOWING_CLOAK": "0x2BA", "SMSG_MEETINGSTONE_JOINFAILED": "0x2BB", "SMSG_PLAYER_SKINNED": "0x2BC", "SMSG_DURABILITY_DAMAGE_DEATH": "0x2BD", @@ -821,6 +816,5 @@ "SMSG_LOTTERY_RESULT_OBSOLETE": "0x337", "SMSG_CHARACTER_PROFILE": "0x338", "SMSG_CHARACTER_PROFILE_REALM_CONNECTED": "0x339", - "SMSG_UNK": "0x33A", "SMSG_DEFENSE_MESSAGE": "0x33B" } diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index b39f7b8f..d0f84599 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -1,302 +1,6 @@ { - "CMSG_PING": "0x1DC", - "CMSG_AUTH_SESSION": "0x1ED", - "CMSG_CHAR_CREATE": "0x036", - "CMSG_CHAR_ENUM": "0x037", - "CMSG_CHAR_DELETE": "0x038", - "CMSG_PLAYER_LOGIN": "0x03D", - "MSG_MOVE_START_FORWARD": "0x0B5", - "MSG_MOVE_START_BACKWARD": "0x0B6", - "MSG_MOVE_STOP": "0x0B7", - "MSG_MOVE_START_STRAFE_LEFT": "0x0B8", - "MSG_MOVE_START_STRAFE_RIGHT": "0x0B9", - "MSG_MOVE_STOP_STRAFE": "0x0BA", - "MSG_MOVE_JUMP": "0x0BB", - "MSG_MOVE_START_TURN_LEFT": "0x0BC", - "MSG_MOVE_START_TURN_RIGHT": "0x0BD", - "MSG_MOVE_STOP_TURN": "0x0BE", - "MSG_MOVE_SET_FACING": "0x0DA", - "MSG_MOVE_FALL_LAND": "0x0C9", - "MSG_MOVE_START_SWIM": "0x0CA", - "MSG_MOVE_STOP_SWIM": "0x0CB", - "MSG_MOVE_HEARTBEAT": "0x0EE", - "SMSG_AUTH_CHALLENGE": "0x1EC", - "SMSG_AUTH_RESPONSE": "0x1EE", - "SMSG_CHAR_CREATE": "0x03A", - "SMSG_CHAR_ENUM": "0x03B", - "SMSG_CHAR_DELETE": "0x03C", - "SMSG_CHARACTER_LOGIN_FAILED": "0x041", - "SMSG_PONG": "0x1DD", - "SMSG_LOGIN_VERIFY_WORLD": "0x236", - "SMSG_INIT_WORLD_STATES": "0x2C2", - "SMSG_LOGIN_SETTIMESPEED": "0x042", - "SMSG_TUTORIAL_FLAGS": "0x0FD", - "SMSG_INITIALIZE_FACTIONS": "0x122", - "SMSG_WARDEN_DATA": "0x2E6", - "CMSG_WARDEN_DATA": "0x2E7", - "SMSG_NOTIFICATION": "0x1CB", - "SMSG_ACCOUNT_DATA_TIMES": "0x209", - "SMSG_UPDATE_OBJECT": "0x0A9", - "SMSG_COMPRESSED_UPDATE_OBJECT": "0x1F6", - "SMSG_PARTYKILLLOG": "0x1F5", - "SMSG_MONSTER_MOVE_TRANSPORT": "0x2AE", - "SMSG_SPLINE_MOVE_SET_WALK_MODE": "0x30E", - "SMSG_SPLINE_MOVE_SET_RUN_MODE": "0x30D", - "SMSG_SPLINE_SET_RUN_SPEED": "0x2FE", - "SMSG_SPLINE_SET_RUN_BACK_SPEED": "0x2FF", - "SMSG_SPLINE_SET_SWIM_SPEED": "0x300", - "SMSG_DESTROY_OBJECT": "0x0AA", - "CMSG_MESSAGECHAT": "0x095", - "SMSG_MESSAGECHAT": "0x096", - "CMSG_WHO": "0x062", - "SMSG_WHO": "0x063", - "CMSG_PLAYED_TIME": "0x1CC", - "SMSG_PLAYED_TIME": "0x1CD", - "CMSG_QUERY_TIME": "0x1CE", - "SMSG_QUERY_TIME_RESPONSE": "0x1CF", - "SMSG_FRIEND_STATUS": "0x068", - "SMSG_CONTACT_LIST": "0x067", - "CMSG_ADD_FRIEND": "0x069", - "CMSG_DEL_FRIEND": "0x06A", - "CMSG_ADD_IGNORE": "0x06C", - "CMSG_DEL_IGNORE": "0x06D", - "CMSG_PLAYER_LOGOUT": "0x04A", - "CMSG_LOGOUT_REQUEST": "0x04B", - "CMSG_LOGOUT_CANCEL": "0x04E", - "SMSG_LOGOUT_RESPONSE": "0x04C", - "SMSG_LOGOUT_COMPLETE": "0x04D", - "CMSG_STANDSTATECHANGE": "0x101", - "CMSG_SHOWING_HELM": "0x2B9", - "CMSG_SHOWING_CLOAK": "0x2BA", - "CMSG_TOGGLE_PVP": "0x253", - "CMSG_GUILD_INVITE": "0x082", - "CMSG_GUILD_ACCEPT": "0x084", - "CMSG_GUILD_DECLINE": "0x085", - "CMSG_GUILD_INFO": "0x087", - "CMSG_GUILD_ROSTER": "0x089", - "CMSG_GUILD_PROMOTE": "0x08B", - "CMSG_GUILD_DEMOTE": "0x08C", - "CMSG_GUILD_LEAVE": "0x08D", - "CMSG_GUILD_MOTD": "0x091", - "SMSG_GUILD_INFO": "0x088", - "SMSG_GUILD_ROSTER": "0x08A", - "CMSG_GUILD_QUERY": "0x054", - "SMSG_GUILD_QUERY_RESPONSE": "0x055", - "SMSG_GUILD_INVITE": "0x083", - "CMSG_GUILD_REMOVE": "0x08E", - "SMSG_GUILD_EVENT": "0x092", - "SMSG_GUILD_COMMAND_RESULT": "0x093", - "MSG_RAID_READY_CHECK": "0x322", - "SMSG_ITEM_PUSH_RESULT": "0x166", - "CMSG_DUEL_ACCEPTED": "0x16C", - "CMSG_DUEL_CANCELLED": "0x16D", - "SMSG_DUEL_REQUESTED": "0x167", - "CMSG_INITIATE_TRADE": "0x116", - "MSG_RANDOM_ROLL": "0x1FB", - "CMSG_SET_SELECTION": "0x13D", - "CMSG_NAME_QUERY": "0x050", - "SMSG_NAME_QUERY_RESPONSE": "0x051", - "CMSG_CREATURE_QUERY": "0x060", - "SMSG_CREATURE_QUERY_RESPONSE": "0x061", - "CMSG_GAMEOBJECT_QUERY": "0x05E", - "SMSG_GAMEOBJECT_QUERY_RESPONSE": "0x05F", - "CMSG_SET_ACTIVE_MOVER": "0x26A", - "CMSG_BINDER_ACTIVATE": "0x1B5", - "SMSG_LOG_XPGAIN": "0x1D0", - "_NOTE_MONSTER_MOVE": "These look swapped vs vanilla (0x0DD/0x2FB) but may be intentional Turtle WoW changes. Check if NPC movement breaks.", - "SMSG_MONSTER_MOVE": "0x2FB", - "SMSG_COMPRESSED_MOVES": "0x06B", - "CMSG_ATTACKSWING": "0x141", - "CMSG_ATTACKSTOP": "0x142", - "SMSG_ATTACKSTART": "0x143", - "SMSG_ATTACKSTOP": "0x144", - "SMSG_ATTACKERSTATEUPDATE": "0x14A", - "SMSG_AI_REACTION": "0x13C", - "SMSG_SPELLNONMELEEDAMAGELOG": "0x250", - "SMSG_PLAY_SPELL_VISUAL": "0x1F3", - "SMSG_SPELLHEALLOG": "0x150", - "SMSG_SPELLENERGIZELOG": "0x151", - "SMSG_PERIODICAURALOG": "0x24E", - "SMSG_ENVIRONMENTAL_DAMAGE_LOG": "0x1FC", - "CMSG_CAST_SPELL": "0x12E", - "CMSG_CANCEL_CAST": "0x12F", - "CMSG_CANCEL_AURA": "0x136", - "SMSG_CAST_FAILED": "0x130", - "SMSG_SPELL_START": "0x131", - "SMSG_SPELL_GO": "0x132", - "SMSG_SPELL_FAILURE": "0x133", - "SMSG_SPELL_COOLDOWN": "0x134", - "SMSG_COOLDOWN_EVENT": "0x135", - "SMSG_UPDATE_AURA_DURATION": "0x137", - "SMSG_INITIAL_SPELLS": "0x12A", - "SMSG_LEARNED_SPELL": "0x12B", - "SMSG_SUPERCEDED_SPELL": "0x12C", - "SMSG_REMOVED_SPELL": "0x203", - "SMSG_SPELL_DELAYED": "0x1E2", - "SMSG_SET_FLAT_SPELL_MODIFIER": "0x266", - "SMSG_SET_PCT_SPELL_MODIFIER": "0x267", - "CMSG_LEARN_TALENT": "0x251", - "MSG_TALENT_WIPE_CONFIRM": "0x2AA", - "CMSG_GROUP_INVITE": "0x06E", - "SMSG_GROUP_INVITE": "0x06F", - "CMSG_GROUP_ACCEPT": "0x072", - "CMSG_GROUP_DECLINE": "0x073", - "SMSG_GROUP_DECLINE": "0x074", - "CMSG_GROUP_UNINVITE_GUID": "0x076", - "SMSG_GROUP_UNINVITE": "0x077", - "CMSG_GROUP_SET_LEADER": "0x078", - "SMSG_GROUP_SET_LEADER": "0x079", - "CMSG_GROUP_DISBAND": "0x07B", - "SMSG_GROUP_LIST": "0x07D", - "SMSG_PARTY_COMMAND_RESULT": "0x07F", - "MSG_RAID_TARGET_UPDATE": "0x321", - "CMSG_REQUEST_RAID_INFO": "0x2CD", - "SMSG_RAID_INSTANCE_INFO": "0x2CC", - "CMSG_AUTOSTORE_LOOT_ITEM": "0x108", - "CMSG_LOOT": "0x15D", - "CMSG_LOOT_MONEY": "0x15E", - "CMSG_LOOT_RELEASE": "0x15F", - "SMSG_LOOT_RESPONSE": "0x160", - "SMSG_LOOT_RELEASE_RESPONSE": "0x161", - "SMSG_LOOT_REMOVED": "0x162", - "SMSG_LOOT_MONEY_NOTIFY": "0x163", - "SMSG_LOOT_CLEAR_MONEY": "0x165", - "CMSG_ACTIVATETAXI": "0x1AD", - "CMSG_GOSSIP_HELLO": "0x17B", - "CMSG_GOSSIP_SELECT_OPTION": "0x17C", - "SMSG_GOSSIP_MESSAGE": "0x17D", - "SMSG_GOSSIP_COMPLETE": "0x17E", - "SMSG_NPC_TEXT_UPDATE": "0x180", - "CMSG_GAMEOBJ_USE": "0x0B1", - "CMSG_QUESTGIVER_STATUS_QUERY": "0x182", - "SMSG_QUESTGIVER_STATUS": "0x183", - "CMSG_QUESTGIVER_HELLO": "0x184", - "SMSG_QUESTGIVER_QUEST_LIST": "0x185", - "CMSG_QUESTGIVER_QUERY_QUEST": "0x186", - "SMSG_QUESTGIVER_QUEST_DETAILS": "0x188", - "CMSG_QUESTGIVER_ACCEPT_QUEST": "0x189", - "CMSG_QUESTGIVER_COMPLETE_QUEST": "0x18A", - "SMSG_QUESTGIVER_REQUEST_ITEMS": "0x18B", - "CMSG_QUESTGIVER_REQUEST_REWARD": "0x18C", - "SMSG_QUESTGIVER_OFFER_REWARD": "0x18D", - "CMSG_QUESTGIVER_CHOOSE_REWARD": "0x18E", - "SMSG_QUESTGIVER_QUEST_INVALID": "0x18F", - "SMSG_QUESTGIVER_QUEST_COMPLETE": "0x191", - "CMSG_QUESTLOG_REMOVE_QUEST": "0x194", - "SMSG_QUESTUPDATE_ADD_KILL": "0x199", - "SMSG_QUESTUPDATE_COMPLETE": "0x198", - "SMSG_QUEST_FORCE_REMOVE": "0x21E", - "CMSG_QUEST_QUERY": "0x05C", - "SMSG_QUEST_QUERY_RESPONSE": "0x05D", - "SMSG_QUESTLOG_FULL": "0x195", - "CMSG_LIST_INVENTORY": "0x19E", - "SMSG_LIST_INVENTORY": "0x19F", - "CMSG_SELL_ITEM": "0x1A0", - "SMSG_SELL_ITEM": "0x1A1", - "CMSG_BUY_ITEM": "0x1A2", - "CMSG_BUYBACK_ITEM": "0x1A6", - "SMSG_BUY_FAILED": "0x1A5", - "CMSG_TRAINER_LIST": "0x1B0", - "SMSG_TRAINER_LIST": "0x1B1", - "CMSG_TRAINER_BUY_SPELL": "0x1B2", - "SMSG_TRAINER_BUY_FAILED": "0x1B4", - "CMSG_ITEM_QUERY_SINGLE": "0x056", - "SMSG_ITEM_QUERY_SINGLE_RESPONSE": "0x058", - "CMSG_USE_ITEM": "0x0AB", - "CMSG_AUTOEQUIP_ITEM": "0x10A", - "CMSG_SWAP_ITEM": "0x10C", - "CMSG_SWAP_INV_ITEM": "0x10D", - "SMSG_INVENTORY_CHANGE_FAILURE": "0x112", - "CMSG_INSPECT": "0x114", - "SMSG_INSPECT_RESULTS_UPDATE": "0x115", - "CMSG_REPOP_REQUEST": "0x15A", - "SMSG_RESURRECT_REQUEST": "0x15B", - "CMSG_RESURRECT_RESPONSE": "0x15C", - "CMSG_SPIRIT_HEALER_ACTIVATE": "0x21C", - "SMSG_SPIRIT_HEALER_CONFIRM": "0x222", - "MSG_MOVE_TELEPORT_ACK": "0x0C7", - "SMSG_TRANSFER_PENDING": "0x03F", - "SMSG_NEW_WORLD": "0x03E", - "MSG_MOVE_WORLDPORT_ACK": "0x0DC", - "SMSG_TRANSFER_ABORTED": "0x040", - "SMSG_FORCE_RUN_SPEED_CHANGE": "0x0E2", - "SMSG_CLIENT_CONTROL_UPDATE": "0x159", - "CMSG_FORCE_RUN_SPEED_CHANGE_ACK": "0x0E3", - "SMSG_SHOWTAXINODES": "0x1A9", - "SMSG_ACTIVATETAXIREPLY": "0x1AE", - "SMSG_NEW_TAXI_PATH": "0x1AF", - "CMSG_ACTIVATETAXIEXPRESS": "0x312", - "CMSG_TAXINODE_STATUS_QUERY": "0x1AA", - "SMSG_TAXINODE_STATUS": "0x1AB", - "SMSG_TRAINER_BUY_SUCCEEDED": "0x1B3", - "SMSG_BINDPOINTUPDATE": "0x155", - "SMSG_SET_PROFICIENCY": "0x127", - "SMSG_ACTION_BUTTONS": "0x129", - "SMSG_LEVELUP_INFO": "0x1D4", - "SMSG_PLAY_SOUND": "0x2D2", - "CMSG_UPDATE_ACCOUNT_DATA": "0x20B", - "CMSG_BATTLEFIELD_LIST": "0x23C", - "SMSG_BATTLEFIELD_LIST": "0x23D", - "CMSG_BATTLEFIELD_JOIN": "0x23E", - "CMSG_BATTLEFIELD_STATUS": "0x2D3", - "SMSG_BATTLEFIELD_STATUS": "0x2D4", - "CMSG_BATTLEFIELD_PORT": "0x2D5", - "CMSG_BATTLEMASTER_HELLO": "0x2D7", - "SMSG_SPELL_FAILED_OTHER": "0x2A6", - "MSG_PVP_LOG_DATA": "0x2E0", - "CMSG_LEAVE_BATTLEFIELD": "0x2E1", - "SMSG_GROUP_JOINED_BATTLEGROUND": "0x2E8", - "MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9", - "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", - "SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", - "CMSG_BATTLEMASTER_JOIN": "0x2EE", - "SMSG_ADDON_INFO": "0x2EF", - "CMSG_EMOTE": "0x102", - "SMSG_EMOTE": "0x103", - "CMSG_TEXT_EMOTE": "0x104", - "SMSG_TEXT_EMOTE": "0x105", - "CMSG_JOIN_CHANNEL": "0x097", - "CMSG_LEAVE_CHANNEL": "0x098", - "SMSG_CHANNEL_NOTIFY": "0x099", - "CMSG_CHANNEL_LIST": "0x09A", - "SMSG_CHANNEL_LIST": "0x09B", - "SMSG_INSPECT_TALENT": "0x3F4", - "SMSG_SHOW_MAILBOX": "0x297", - "CMSG_GET_MAIL_LIST": "0x23A", - "SMSG_MAIL_LIST_RESULT": "0x23B", - "CMSG_SEND_MAIL": "0x238", - "SMSG_SEND_MAIL_RESULT": "0x239", - "CMSG_MAIL_TAKE_MONEY": "0x245", - "CMSG_MAIL_TAKE_ITEM": "0x246", - "CMSG_MAIL_DELETE": "0x249", - "CMSG_MAIL_MARK_AS_READ": "0x247", - "SMSG_RECEIVED_MAIL": "0x285", - "MSG_QUERY_NEXT_MAIL_TIME": "0x284", - "CMSG_BANKER_ACTIVATE": "0x1B7", - "SMSG_SHOW_BANK": "0x1B8", - "CMSG_BUY_BANK_SLOT": "0x1B9", - "SMSG_BUY_BANK_SLOT_RESULT": "0x1BA", - "CMSG_AUTOSTORE_BANK_ITEM": "0x282", - "CMSG_AUTOBANK_ITEM": "0x283", - "MSG_AUCTION_HELLO": "0x255", - "CMSG_AUCTION_SELL_ITEM": "0x256", - "CMSG_AUCTION_REMOVE_ITEM": "0x257", - "CMSG_AUCTION_LIST_ITEMS": "0x258", - "CMSG_AUCTION_LIST_OWNER_ITEMS": "0x259", - "CMSG_AUCTION_PLACE_BID": "0x25A", - "SMSG_AUCTION_COMMAND_RESULT": "0x25B", - "SMSG_AUCTION_LIST_RESULT": "0x25C", - "SMSG_AUCTION_OWNER_LIST_RESULT": "0x25D", - "SMSG_AUCTION_OWNER_NOTIFICATION": "0x25E", - "SMSG_AUCTION_BIDDER_NOTIFICATION": "0x260", - "CMSG_AUCTION_LIST_BIDDER_ITEMS": "0x264", - "SMSG_AUCTION_BIDDER_LIST_RESULT": "0x265", - "MSG_MOVE_TIME_SKIPPED": "0x319", - "SMSG_CANCEL_AUTO_REPEAT": "0x29C", - "SMSG_WEATHER": "0x2F4", - "SMSG_QUESTUPDATE_ADD_ITEM": "0x19A", - "CMSG_GUILD_DISBAND": "0x08F", - "CMSG_GUILD_LEADER": "0x090", - "CMSG_GUILD_SET_PUBLIC_NOTE": "0x234", - "CMSG_GUILD_SET_OFFICER_NOTE": "0x235" + "_extends": "../classic/opcodes.json", + "_remove": [ + "MSG_SET_DUNGEON_DIFFICULTY" + ] } diff --git a/Data/opcodes/aliases.json b/Data/opcodes/aliases.json index e3a67348..4677cd5d 100644 --- a/Data/opcodes/aliases.json +++ b/Data/opcodes/aliases.json @@ -41,7 +41,6 @@ "SMSG_SPLINE_MOVE_SET_RUN_BACK_SPEED": "SMSG_SPLINE_SET_RUN_BACK_SPEED", "SMSG_SPLINE_MOVE_SET_RUN_SPEED": "SMSG_SPLINE_SET_RUN_SPEED", "SMSG_SPLINE_MOVE_SET_SWIM_SPEED": "SMSG_SPLINE_SET_SWIM_SPEED", - "SMSG_UPDATE_AURA_DURATION": "SMSG_EQUIPMENT_SET_SAVED", "SMSG_VICTIMSTATEUPDATE_OBSOLETE": "SMSG_BATTLEFIELD_PORT_DENIED" } } diff --git a/include/game/opcode_aliases_generated.inc b/include/game/opcode_aliases_generated.inc index ad488110..ed20d098 100644 --- a/include/game/opcode_aliases_generated.inc +++ b/include/game/opcode_aliases_generated.inc @@ -41,5 +41,4 @@ {"SMSG_SPLINE_MOVE_SET_RUN_BACK_SPEED", "SMSG_SPLINE_SET_RUN_BACK_SPEED"}, {"SMSG_SPLINE_MOVE_SET_RUN_SPEED", "SMSG_SPLINE_SET_RUN_SPEED"}, {"SMSG_SPLINE_MOVE_SET_SWIM_SPEED", "SMSG_SPLINE_SET_SWIM_SPEED"}, - {"SMSG_UPDATE_AURA_DURATION", "SMSG_EQUIPMENT_SET_SAVED"}, {"SMSG_VICTIMSTATEUPDATE_OBSOLETE", "SMSG_BATTLEFIELD_PORT_DENIED"}, diff --git a/include/game/opcode_table.hpp b/include/game/opcode_table.hpp index aaecc837..966542b9 100644 --- a/include/game/opcode_table.hpp +++ b/include/game/opcode_table.hpp @@ -33,7 +33,10 @@ class OpcodeTable { public: /** * Load opcode mappings from a JSON file. - * Format: { "CMSG_PING": "0x1DC", "SMSG_AUTH_CHALLENGE": "0x1EC", ... } + * Format: + * { "CMSG_PING": "0x1DC", "SMSG_AUTH_CHALLENGE": "0x1EC", ... } + * or a delta file with: + * { "_extends": "../classic/opcodes.json", "_remove": ["MSG_FOO"], ...overrides } */ bool loadFromJson(const std::string& path); diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 4446deba..fe033101 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -439,14 +439,16 @@ public: }; /** - * Turtle WoW (build 7234) packet parsers. + * Turtle WoW packet parsers. * - * Turtle WoW is a heavily modified vanilla server that sends TBC-style - * movement blocks (moveFlags2, transport timestamps, 8 speeds including flight) - * while keeping all other Classic packet formats. + * Turtle is Classic-based but not wire-identical to vanilla MaNGOS. It keeps + * most Classic packet formats, while overriding the movement-bearing paths that + * have proven to vary in live traffic: + * - update-object movement blocks use a Turtle-specific hybrid layout + * - update-object parsing falls back through Classic/TBC/WotLK movement layouts + * - monster-move parsing falls back through Vanilla, TBC, and guarded WotLK layouts * - * Inherits all Classic overrides (charEnum, chat, gossip, mail, items, etc.) - * but delegates movement block parsing to TBC format. + * Everything else inherits the Classic parser behavior. */ class TurtlePacketParsers : public ClassicPacketParsers { public: diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0bd11890..c6c8cc56 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4261,8 +4261,11 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_ACTION_BUTTONS: { - // packed: bits 0-23 = actionId, bits 24-31 = type - // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) + // Slot encoding differs by expansion: + // Classic/Turtle: uint16 actionId + uint8 type + uint8 misc + // type: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint32 packed = actionId | (type << 24) + // type: 0x00=spell, 0x80=item, 0x40=macro // Format differences: // Classic 1.12: no mode byte, 120 slots (480 bytes) // TBC 2.4.3: no mode byte, 132 slots (528 bytes) @@ -4292,12 +4295,20 @@ void GameHandler::handlePacket(network::Packet& packet) { // so we don't wipe hardcoded fallbacks when the server sends zeros. continue; } - uint8_t type = static_cast((packed >> 24) & 0xFF); - uint32_t id = packed & 0x00FFFFFFu; + uint8_t type = 0; + uint32_t id = 0; + if (isClassicLikeExpansion()) { + id = packed & 0x0000FFFFu; + type = static_cast((packed >> 16) & 0xFF); + } else { + type = static_cast((packed >> 24) & 0xFF); + id = packed & 0x00FFFFFFu; + } if (id == 0) continue; ActionBarSlot slot; switch (type) { case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; + case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; default: continue; // macro or unknown — leave as-is } diff --git a/src/game/opcode_table.cpp b/src/game/opcode_table.cpp index 8178f0f5..ad9b639f 100644 --- a/src/game/opcode_table.cpp +++ b/src/game/opcode_table.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include namespace wowee { namespace game { @@ -47,6 +49,155 @@ static std::string_view canonicalOpcodeName(std::string_view name) { return name; } +static std::optional resolveLogicalOpcodeIndex(std::string_view name) { + const std::string_view canonical = canonicalOpcodeName(name); + for (size_t i = 0; i < kOpcodeNameCount; ++i) { + if (canonical == kOpcodeNames[i].name) { + return static_cast(kOpcodeNames[i].op); + } + } + return std::nullopt; +} + +static std::optional parseStringField(const std::string& json, const char* fieldName) { + const std::string needle = std::string("\"") + fieldName + "\""; + size_t keyPos = json.find(needle); + if (keyPos == std::string::npos) return std::nullopt; + + size_t colon = json.find(':', keyPos + needle.size()); + if (colon == std::string::npos) return std::nullopt; + + size_t valueStart = json.find('"', colon + 1); + if (valueStart == std::string::npos) return std::nullopt; + size_t valueEnd = json.find('"', valueStart + 1); + if (valueEnd == std::string::npos) return std::nullopt; + return json.substr(valueStart + 1, valueEnd - valueStart - 1); +} + +static std::vector parseStringArrayField(const std::string& json, const char* fieldName) { + std::vector values; + const std::string needle = std::string("\"") + fieldName + "\""; + size_t keyPos = json.find(needle); + if (keyPos == std::string::npos) return values; + + size_t colon = json.find(':', keyPos + needle.size()); + if (colon == std::string::npos) return values; + + size_t arrayStart = json.find('[', colon + 1); + if (arrayStart == std::string::npos) return values; + size_t arrayEnd = json.find(']', arrayStart + 1); + if (arrayEnd == std::string::npos) return values; + + size_t pos = arrayStart + 1; + while (pos < arrayEnd) { + size_t valueStart = json.find('"', pos); + if (valueStart == std::string::npos || valueStart >= arrayEnd) break; + size_t valueEnd = json.find('"', valueStart + 1); + if (valueEnd == std::string::npos || valueEnd > arrayEnd) break; + values.push_back(json.substr(valueStart + 1, valueEnd - valueStart - 1)); + pos = valueEnd + 1; + } + return values; +} + +static bool loadOpcodeJsonRecursive(const std::filesystem::path& path, + std::unordered_map& logicalToWire, + std::unordered_map& wireToLogical, + std::unordered_set& loadingStack) { + const std::filesystem::path canonicalPath = std::filesystem::weakly_canonical(path); + const std::string canonicalKey = canonicalPath.string(); + if (!loadingStack.insert(canonicalKey).second) { + LOG_WARNING("OpcodeTable: inheritance cycle at ", canonicalKey); + return false; + } + + std::ifstream f(canonicalPath); + if (!f.is_open()) { + LOG_WARNING("OpcodeTable: cannot open ", canonicalPath.string()); + loadingStack.erase(canonicalKey); + return false; + } + + std::string json((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + bool ok = true; + + if (auto extends = parseStringField(json, "_extends")) { + ok = loadOpcodeJsonRecursive(canonicalPath.parent_path() / *extends, + logicalToWire, wireToLogical, loadingStack) && ok; + } + + for (const std::string& removeName : parseStringArrayField(json, "_remove")) { + auto logical = resolveLogicalOpcodeIndex(removeName); + if (!logical) continue; + auto it = logicalToWire.find(*logical); + if (it != logicalToWire.end()) { + const uint16_t oldWire = it->second; + logicalToWire.erase(it); + auto wireIt = wireToLogical.find(oldWire); + if (wireIt != wireToLogical.end() && wireIt->second == *logical) { + wireToLogical.erase(wireIt); + } + } + } + + size_t pos = 0; + while (pos < json.size()) { + size_t keyStart = json.find('"', pos); + if (keyStart == std::string::npos) break; + size_t keyEnd = json.find('"', keyStart + 1); + if (keyEnd == std::string::npos) break; + std::string key = json.substr(keyStart + 1, keyEnd - keyStart - 1); + + size_t colon = json.find(':', keyEnd); + if (colon == std::string::npos) break; + + size_t valStart = colon + 1; + while (valStart < json.size() && (json[valStart] == ' ' || json[valStart] == '\t' || + json[valStart] == '\r' || json[valStart] == '\n' || json[valStart] == '"')) + ++valStart; + + size_t valEnd = json.find_first_of(",}\"\r\n", valStart); + if (valEnd == std::string::npos) valEnd = json.size(); + std::string valStr = json.substr(valStart, valEnd - valStart); + + uint16_t wire = 0; + try { + if (valStr.size() > 2 && (valStr[0] == '0' && (valStr[1] == 'x' || valStr[1] == 'X'))) { + wire = static_cast(std::stoul(valStr, nullptr, 16)); + } else { + wire = static_cast(std::stoul(valStr)); + } + } catch (...) { + pos = valEnd + 1; + continue; + } + + auto logical = resolveLogicalOpcodeIndex(key); + if (logical) { + auto oldLogicalIt = logicalToWire.find(*logical); + if (oldLogicalIt != logicalToWire.end()) { + const uint16_t oldWire = oldLogicalIt->second; + auto oldWireIt = wireToLogical.find(oldWire); + if (oldWireIt != wireToLogical.end() && oldWireIt->second == *logical) { + wireToLogical.erase(oldWireIt); + } + } + auto oldWireIt = wireToLogical.find(wire); + if (oldWireIt != wireToLogical.end() && oldWireIt->second != *logical) { + logicalToWire.erase(oldWireIt->second); + wireToLogical.erase(oldWireIt); + } + logicalToWire[*logical] = wire; + wireToLogical[wire] = *logical; + } + + pos = valEnd + 1; + } + + loadingStack.erase(canonicalKey); + return ok; +} + std::optional OpcodeTable::nameToLogical(const std::string& name) { const std::string_view canonical = canonicalOpcodeName(name); for (size_t i = 0; i < kOpcodeNameCount; ++i) { @@ -64,73 +215,18 @@ const char* OpcodeTable::logicalToName(LogicalOpcode op) { } bool OpcodeTable::loadFromJson(const std::string& path) { - std::ifstream f(path); - if (!f.is_open()) { - LOG_WARNING("OpcodeTable: cannot open ", path, ", using defaults"); - return false; - } - - std::string json((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - - // Start fresh — JSON is the single source of truth for opcode mappings. + // Start fresh — resolved JSON inheritance is the single source of truth for opcode mappings. logicalToWire_.clear(); wireToLogical_.clear(); - - // Parse simple JSON: { "NAME": "0xHEX", ... } or { "NAME": 123, ... } - size_t pos = 0; - size_t loaded = 0; - while (pos < json.size()) { - // Find next quoted key - size_t keyStart = json.find('"', pos); - if (keyStart == std::string::npos) break; - size_t keyEnd = json.find('"', keyStart + 1); - if (keyEnd == std::string::npos) break; - std::string key = json.substr(keyStart + 1, keyEnd - keyStart - 1); - - // Find colon then value - size_t colon = json.find(':', keyEnd); - if (colon == std::string::npos) break; - - // Skip whitespace - size_t valStart = colon + 1; - while (valStart < json.size() && (json[valStart] == ' ' || json[valStart] == '\t' || - json[valStart] == '\r' || json[valStart] == '\n' || json[valStart] == '"')) - ++valStart; - - size_t valEnd = json.find_first_of(",}\"\r\n", valStart); - if (valEnd == std::string::npos) valEnd = json.size(); - std::string valStr = json.substr(valStart, valEnd - valStart); - - // Parse hex or decimal value - uint16_t wire = 0; - try { - if (valStr.size() > 2 && (valStr[0] == '0' && (valStr[1] == 'x' || valStr[1] == 'X'))) { - wire = static_cast(std::stoul(valStr, nullptr, 16)); - } else { - wire = static_cast(std::stoul(valStr)); - } - } catch (...) { - pos = valEnd + 1; - continue; - } - - auto logOp = nameToLogical(key); - if (logOp) { - uint16_t logIdx = static_cast(*logOp); - logicalToWire_[logIdx] = wire; - wireToLogical_[wire] = logIdx; - ++loaded; - } - - pos = valEnd + 1; - } - - if (loaded == 0) { + std::unordered_set loadingStack; + if (!loadOpcodeJsonRecursive(std::filesystem::path(path), + logicalToWire_, wireToLogical_, loadingStack) || + logicalToWire_.empty()) { LOG_WARNING("OpcodeTable: no opcodes loaded from ", path); return false; } - LOG_INFO("OpcodeTable: loaded ", loaded, " opcodes from ", path); + LOG_INFO("OpcodeTable: loaded ", logicalToWire_.size(), " opcodes from ", path); return true; } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 663bafd3..59c2d0f8 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -2007,13 +2007,20 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec } bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { - // Turtle realms can emit both vanilla-like and WotLK-like monster move bodies. - // Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout. + // Turtle realms can emit vanilla-like, TBC-like, and WotLK-like monster move + // bodies. Try the lower-expansion layouts first before the WotLK parser that + // expects an extra unk byte after the packed GUID. size_t start = packet.getReadPos(); if (MonsterMoveParser::parseVanilla(packet, data)) { return true; } + packet.setReadPos(start); + if (TbcPacketParsers::parseMonsterMove(packet, data)) { + LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via TBC fallback layout"); + return true; + } + auto looksLikeWotlkMonsterMove = [&](network::Packet& probe) -> bool { const size_t probeStart = probe.getReadPos(); uint64_t guid = UpdateObjectParser::readPackedGuid(probe); diff --git a/tools/diff_classic_turtle_opcodes.py b/tools/diff_classic_turtle_opcodes.py new file mode 100644 index 00000000..0e548b47 --- /dev/null +++ b/tools/diff_classic_turtle_opcodes.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Report the semantic opcode diff between the Classic and Turtle expansion maps. + +The report normalizes: +- hex formatting differences (0x67 vs 0x067) +- alias names that collapse to the same canonical opcode + +It highlights: +- true wire differences for the same canonical opcode +- canonical opcodes present only in Classic or only in Turtle +- name-only differences where the wire matches after aliasing +""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + +from opcode_map_utils import load_opcode_map + + +RE_OPCODE_NAME = re.compile(r"^(?:CMSG|SMSG|MSG)_[A-Z0-9_]+$") + + +def read_aliases(path: Path) -> Dict[str, str]: + data = json.loads(path.read_text()) + aliases = data.get("aliases", {}) + out: Dict[str, str] = {} + for key, value in aliases.items(): + if isinstance(key, str) and isinstance(value, str): + out[key] = value + return out + + +def canonicalize(name: str, aliases: Dict[str, str]) -> str: + seen = set() + current = name + while current in aliases and current not in seen: + seen.add(current) + current = aliases[current] + return current + + +def load_map(path: Path) -> Dict[str, int]: + data = load_opcode_map(path) + out: Dict[str, int] = {} + for key, value in data.items(): + if not isinstance(key, str) or not RE_OPCODE_NAME.match(key): + continue + if not isinstance(value, str) or not value.lower().startswith("0x"): + continue + out[key] = int(value, 16) + return out + + +@dataclass(frozen=True) +class CanonicalEntry: + canonical_name: str + raw_value: int + raw_names: Tuple[str, ...] + + +def build_canonical_entries( + raw_map: Dict[str, int], aliases: Dict[str, str] +) -> Dict[str, CanonicalEntry]: + grouped: Dict[str, List[Tuple[str, int]]] = {} + for raw_name, raw_value in raw_map.items(): + canonical_name = canonicalize(raw_name, aliases) + grouped.setdefault(canonical_name, []).append((raw_name, raw_value)) + + out: Dict[str, CanonicalEntry] = {} + for canonical_name, entries in grouped.items(): + raw_values = {raw_value for _, raw_value in entries} + if len(raw_values) != 1: + formatted = ", ".join( + f"{name}=0x{raw_value:03X}" for name, raw_value in sorted(entries) + ) + raise ValueError( + f"Expansion map contains multiple wires for canonical opcode " + f"{canonical_name}: {formatted}" + ) + raw_value = next(iter(raw_values)) + raw_names = tuple(sorted(name for name, _ in entries)) + out[canonical_name] = CanonicalEntry(canonical_name, raw_value, raw_names) + return out + + +def format_hex(raw_value: int) -> str: + return f"0x{raw_value:03X}" + + +def emit_section(title: str, rows: Iterable[str], limit: int | None) -> None: + rows = list(rows) + print(f"{title}: {len(rows)}") + if not rows: + return + shown = rows if limit is None else rows[:limit] + for row in shown: + print(f" {row}") + if limit is not None and len(rows) > limit: + print(f" ... {len(rows) - limit} more") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument( + "--limit", + type=int, + default=80, + help="Maximum rows to print per section; use -1 for no limit.", + ) + args = parser.parse_args() + + root = Path(args.root).resolve() + aliases = read_aliases(root / "Data/opcodes/aliases.json") + classic_raw = load_map(root / "Data/expansions/classic/opcodes.json") + turtle_raw = load_map(root / "Data/expansions/turtle/opcodes.json") + + classic = build_canonical_entries(classic_raw, aliases) + turtle = build_canonical_entries(turtle_raw, aliases) + + classic_names = set(classic) + turtle_names = set(turtle) + shared_names = classic_names & turtle_names + + different_wire = [] + same_wire_name_only = [] + for canonical_name in sorted(shared_names): + c = classic[canonical_name] + t = turtle[canonical_name] + if c.raw_value != t.raw_value: + different_wire.append( + f"{canonical_name}: classic={format_hex(c.raw_value)} " + f"turtle={format_hex(t.raw_value)}" + ) + elif c.raw_names != t.raw_names: + same_wire_name_only.append( + f"{canonical_name}: wire={format_hex(c.raw_value)} " + f"classic_names={list(c.raw_names)} turtle_names={list(t.raw_names)}" + ) + + classic_only = [ + f"{name}: {format_hex(classic[name].raw_value)} names={list(classic[name].raw_names)}" + for name in sorted(classic_names - turtle_names) + ] + turtle_only = [ + f"{name}: {format_hex(turtle[name].raw_value)} names={list(turtle[name].raw_names)}" + for name in sorted(turtle_names - classic_names) + ] + + limit = None if args.limit < 0 else args.limit + + print(f"classic canonical entries: {len(classic)}") + print(f"turtle canonical entries: {len(turtle)}") + print(f"shared canonical entries: {len(shared_names)}") + print() + emit_section("Different wire", different_wire, limit) + print() + emit_section("Classic only", classic_only, limit) + print() + emit_section("Turtle only", turtle_only, limit) + print() + emit_section("Same wire, name-only differences", same_wire_name_only, limit) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/opcode_map_utils.py b/tools/opcode_map_utils.py new file mode 100644 index 00000000..c3566057 --- /dev/null +++ b/tools/opcode_map_utils.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Dict, Set + + +RE_OPCODE_NAME = re.compile(r"^(?:CMSG|SMSG|MSG)_[A-Z0-9_]+$") + + +def load_opcode_map(path: Path, _seen: Set[Path] | None = None) -> Dict[str, str]: + if _seen is None: + _seen = set() + + path = path.resolve() + if path in _seen: + chain = " -> ".join(str(p) for p in list(_seen) + [path]) + raise ValueError(f"Opcode map inheritance cycle: {chain}") + _seen.add(path) + + data = json.loads(path.read_text()) + merged: Dict[str, str] = {} + + extends = data.get("_extends") + if isinstance(extends, str) and extends: + merged.update(load_opcode_map(path.parent / extends, _seen)) + + remove = data.get("_remove", []) + if isinstance(remove, list): + for name in remove: + if isinstance(name, str): + merged.pop(name, None) + + for key, value in data.items(): + if not isinstance(key, str) or not RE_OPCODE_NAME.match(key): + continue + if isinstance(value, str): + merged[key] = value + elif isinstance(value, int): + merged[key] = str(value) + + _seen.remove(path) + return merged diff --git a/tools/validate_opcode_maps.py b/tools/validate_opcode_maps.py index a562439b..7acb62ed 100644 --- a/tools/validate_opcode_maps.py +++ b/tools/validate_opcode_maps.py @@ -17,6 +17,8 @@ import re from pathlib import Path from typing import Dict, Iterable, List, Set +from opcode_map_utils import load_opcode_map + RE_OPCODE_NAME = re.compile(r"^(?:CMSG|SMSG|MSG)_[A-Z0-9_]+$") RE_CODE_REF = re.compile(r"\bOpcode::((?:CMSG|SMSG|MSG)_[A-Z0-9_]+)\b") @@ -53,12 +55,8 @@ def iter_expansion_files(expansions_dir: Path) -> Iterable[Path]: def load_expansion_names(path: Path) -> Dict[str, str]: - data = json.loads(path.read_text()) - out: Dict[str, str] = {} - for k, v in data.items(): - if RE_OPCODE_NAME.match(k): - out[k] = str(v) - return out + data = load_opcode_map(path) + return {k: str(v) for k, v in data.items() if RE_OPCODE_NAME.match(k)} def collect_code_refs(root: Path) -> Set[str]: From 43ebae217c48cb64f9302d334a60660c09657575 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 15 Mar 2026 03:40:58 -0700 Subject: [PATCH 38/38] fix: align turtle world packet parsing --- src/game/game_handler.cpp | 170 +++++++++++++++++++++++----- src/game/packet_parsers_classic.cpp | 109 +++++++++++------- src/game/packet_parsers_tbc.cpp | 10 +- src/game/world_packets.cpp | 40 ++++++- 4 files changed, 252 insertions(+), 77 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c6c8cc56..fbdaf5c8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16506,12 +16506,48 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { } void GameHandler::handleCompressedMoves(network::Packet& packet) { - // Vanilla/Classic SMSG_COMPRESSED_MOVES: raw concatenated sub-packets, NOT zlib. - // Evidence: observed 1-byte "00" packets which are not valid zlib streams. - // Each sub-packet: uint8 size (of opcode[2]+payload), uint16 opcode, uint8[] payload. - // size=0 → invalid/empty, signals end of batch. - const auto& data = packet.getData(); - size_t dataLen = data.size(); + // Vanilla-family SMSG_COMPRESSED_MOVES carries concatenated movement sub-packets. + // Turtle can additionally wrap the batch in the same uint32 decompressedSize + zlib + // envelope used by other compressed world packets. + // + // Within the decompressed stream, some realms encode the leading uint8 size as: + // - opcode(2) + payload bytes + // - payload bytes only + // Try both framing modes and use the one that cleanly consumes the batch. + std::vector decompressedStorage; + const std::vector* dataPtr = &packet.getData(); + + const auto& rawData = packet.getData(); + const bool hasCompressedWrapper = + rawData.size() >= 6 && + rawData[4] == 0x78 && + (rawData[5] == 0x01 || rawData[5] == 0x9C || + rawData[5] == 0xDA || rawData[5] == 0x5E); + if (hasCompressedWrapper) { + uint32_t decompressedSize = static_cast(rawData[0]) | + (static_cast(rawData[1]) << 8) | + (static_cast(rawData[2]) << 16) | + (static_cast(rawData[3]) << 24); + if (decompressedSize == 0 || decompressedSize > 65536) { + LOG_WARNING("SMSG_COMPRESSED_MOVES: bad decompressedSize=", decompressedSize); + return; + } + + decompressedStorage.resize(decompressedSize); + uLongf destLen = decompressedSize; + int ret = uncompress(decompressedStorage.data(), &destLen, + rawData.data() + 4, rawData.size() - 4); + if (ret != Z_OK) { + LOG_WARNING("SMSG_COMPRESSED_MOVES: zlib error ", ret); + return; + } + + decompressedStorage.resize(destLen); + dataPtr = &decompressedStorage; + } + + const auto& data = *dataPtr; + const size_t dataLen = data.size(); // Wire opcodes for sub-packet routing uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); @@ -16551,43 +16587,117 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_UNROOT), }; + struct CompressedMoveSubPacket { + uint16_t opcode = 0; + std::vector payload; + }; + struct DecodeResult { + bool ok = false; + bool overrun = false; + bool usedPayloadOnlySize = false; + size_t endPos = 0; + size_t recognizedCount = 0; + size_t subPacketCount = 0; + std::vector packets; + }; + + auto isRecognizedSubOpcode = [&](uint16_t subOpcode) { + return subOpcode == monsterMoveWire || + subOpcode == monsterMoveTransportWire || + std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end(); + }; + + auto decodeSubPackets = [&](bool payloadOnlySize) -> DecodeResult { + DecodeResult result; + result.usedPayloadOnlySize = payloadOnlySize; + size_t pos = 0; + while (pos < dataLen) { + if (pos + 1 > dataLen) break; + uint8_t subSize = data[pos]; + if (subSize == 0) { + result.ok = true; + result.endPos = pos + 1; + return result; + } + + const size_t payloadLen = payloadOnlySize + ? static_cast(subSize) + : (subSize >= 2 ? static_cast(subSize) - 2 : 0); + if (!payloadOnlySize && subSize < 2) { + result.endPos = pos; + return result; + } + + const size_t packetLen = 1 + 2 + payloadLen; + if (pos + packetLen > dataLen) { + result.overrun = true; + result.endPos = pos; + return result; + } + + uint16_t subOpcode = static_cast(data[pos + 1]) | + (static_cast(data[pos + 2]) << 8); + size_t payloadStart = pos + 3; + + CompressedMoveSubPacket subPacket; + subPacket.opcode = subOpcode; + subPacket.payload.assign(data.begin() + payloadStart, + data.begin() + payloadStart + payloadLen); + result.packets.push_back(std::move(subPacket)); + ++result.subPacketCount; + if (isRecognizedSubOpcode(subOpcode)) { + ++result.recognizedCount; + } + + pos += packetLen; + } + result.ok = (result.endPos == 0 || result.endPos == dataLen); + result.endPos = dataLen; + return result; + }; + + DecodeResult decoded = decodeSubPackets(false); + if (!decoded.ok || decoded.overrun) { + DecodeResult payloadOnlyDecoded = decodeSubPackets(true); + const bool preferPayloadOnly = + payloadOnlyDecoded.ok && + (!decoded.ok || decoded.overrun || payloadOnlyDecoded.recognizedCount > decoded.recognizedCount); + if (preferPayloadOnly) { + decoded = std::move(payloadOnlyDecoded); + static uint32_t payloadOnlyFallbackCount = 0; + ++payloadOnlyFallbackCount; + if (payloadOnlyFallbackCount <= 10 || (payloadOnlyFallbackCount % 100) == 0) { + LOG_WARNING("SMSG_COMPRESSED_MOVES decoded via payload-only size fallback", + " (occurrence=", payloadOnlyFallbackCount, ")"); + } + } + } + + if (!decoded.ok || decoded.overrun) { + LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", decoded.endPos); + return; + } + // Track unhandled sub-opcodes once per compressed packet (avoid log spam) std::unordered_set unhandledSeen; - size_t pos = 0; - while (pos < dataLen) { - if (pos + 1 > dataLen) break; - uint8_t subSize = data[pos]; - if (subSize < 2) break; // size=0 or 1 → empty/end-of-batch sentinel - if (pos + 1 + subSize > dataLen) { - LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", pos); - break; - } - uint16_t subOpcode = static_cast(data[pos + 1]) | - (static_cast(data[pos + 2]) << 8); - size_t payloadLen = subSize - 2; - size_t payloadStart = pos + 3; + for (const auto& entry : decoded.packets) { + network::Packet subPacket(entry.opcode, entry.payload); - std::vector subPayload(data.begin() + payloadStart, - data.begin() + payloadStart + payloadLen); - network::Packet subPacket(subOpcode, subPayload); - - if (subOpcode == monsterMoveWire) { + if (entry.opcode == monsterMoveWire) { handleMonsterMove(subPacket); - } else if (subOpcode == monsterMoveTransportWire) { + } else if (entry.opcode == monsterMoveTransportWire) { handleMonsterMoveTransport(subPacket); } else if (state == WorldState::IN_WORLD && - std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end()) { + std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), entry.opcode) != kMoveOpcodes.end()) { // Player/NPC movement update packed in SMSG_MULTIPLE_MOVES handleOtherPlayerMovement(subPacket); } else { - if (unhandledSeen.insert(subOpcode).second) { + if (unhandledSeen.insert(entry.opcode).second) { LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x", - std::hex, subOpcode, std::dec, " payloadLen=", payloadLen); + std::hex, entry.opcode, std::dec, " payloadLen=", entry.payload.size()); } } - - pos = payloadStart + payloadLen; } } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 59c2d0f8..4cccc4a5 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -22,6 +22,18 @@ bool hasFullPackedGuid(const network::Packet& packet) { return packet.getSize() - packet.getReadPos() >= guidBytes; } +const char* updateTypeName(UpdateType type) { + switch (type) { + case UpdateType::VALUES: return "VALUES"; + case UpdateType::MOVEMENT: return "MOVEMENT"; + case UpdateType::CREATE_OBJECT: return "CREATE_OBJECT"; + case UpdateType::CREATE_OBJECT2: return "CREATE_OBJECT2"; + case UpdateType::OUT_OF_RANGE_OBJECTS: return "OUT_OF_RANGE_OBJECTS"; + case UpdateType::NEAR_OBJECTS: return "NEAR_OBJECTS"; + default: return "UNKNOWN"; + } +} + } // namespace // ============================================================================ @@ -63,12 +75,12 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo LOG_DEBUG(" [Classic] UpdateFlags: 0x", std::hex, (int)updateFlags, std::dec); - const uint8_t UPDATEFLAG_LIVING = 0x20; - const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; - const uint8_t UPDATEFLAG_HAS_TARGET = 0x04; - const uint8_t UPDATEFLAG_TRANSPORT = 0x02; - const uint8_t UPDATEFLAG_LOWGUID = 0x08; - const uint8_t UPDATEFLAG_HIGHGUID = 0x10; + const uint8_t UPDATEFLAG_TRANSPORT = 0x02; + const uint8_t UPDATEFLAG_MELEE_ATTACKING = 0x04; + const uint8_t UPDATEFLAG_HIGHGUID = 0x08; + const uint8_t UPDATEFLAG_ALL = 0x10; + const uint8_t UPDATEFLAG_LIVING = 0x20; + const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; if (updateFlags & UPDATEFLAG_LIVING) { // Movement flags (u32 only — NO extra flags byte in Classic) @@ -183,26 +195,26 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo LOG_DEBUG(" [Classic] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")"); } - // Target GUID - if (updateFlags & UPDATEFLAG_HAS_TARGET) { - /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); - } - - // Transport time - if (updateFlags & UPDATEFLAG_TRANSPORT) { - /*uint32_t transportTime =*/ packet.readUInt32(); - } - - // Low GUID - if (updateFlags & UPDATEFLAG_LOWGUID) { - /*uint32_t lowGuid =*/ packet.readUInt32(); - } - // High GUID if (updateFlags & UPDATEFLAG_HIGHGUID) { /*uint32_t highGuid =*/ packet.readUInt32(); } + // ALL/SELF extra uint32 + if (updateFlags & UPDATEFLAG_ALL) { + /*uint32_t unkAll =*/ packet.readUInt32(); + } + + // Current melee target as packed guid + if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) { + /*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + } + + // Transport progress / world time + if (updateFlags & UPDATEFLAG_TRANSPORT) { + /*uint32_t transportTime =*/ packet.readUInt32(); + } + return true; } @@ -1690,12 +1702,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc LOG_DEBUG(" [Turtle] UpdateFlags: 0x", std::hex, (int)updateFlags, std::dec); - const uint8_t UPDATEFLAG_LIVING = 0x20; - const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; - const uint8_t UPDATEFLAG_HAS_TARGET = 0x04; - const uint8_t UPDATEFLAG_TRANSPORT = 0x02; - const uint8_t UPDATEFLAG_LOWGUID = 0x08; - const uint8_t UPDATEFLAG_HIGHGUID = 0x10; + const uint8_t UPDATEFLAG_TRANSPORT = 0x02; + const uint8_t UPDATEFLAG_MELEE_ATTACKING = 0x04; + const uint8_t UPDATEFLAG_HIGHGUID = 0x08; + const uint8_t UPDATEFLAG_ALL = 0x10; + const uint8_t UPDATEFLAG_LIVING = 0x20; + const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; if (updateFlags & UPDATEFLAG_LIVING) { size_t livingStart = packet.getReadPos(); @@ -1810,26 +1822,23 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc LOG_DEBUG(" [Turtle] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")"); } - // Target GUID - if (updateFlags & UPDATEFLAG_HAS_TARGET) { - /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); - } - - // Transport time - if (updateFlags & UPDATEFLAG_TRANSPORT) { - /*uint32_t transportTime =*/ packet.readUInt32(); - } - - // Low GUID — Classic-style: 1×u32 (NOT TBC's 2×u32) - if (updateFlags & UPDATEFLAG_LOWGUID) { - /*uint32_t lowGuid =*/ packet.readUInt32(); - } - // High GUID — 1×u32 if (updateFlags & UPDATEFLAG_HIGHGUID) { /*uint32_t highGuid =*/ packet.readUInt32(); } + if (updateFlags & UPDATEFLAG_ALL) { + /*uint32_t unkAll =*/ packet.readUInt32(); + } + + if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) { + /*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + } + + if (updateFlags & UPDATEFLAG_TRANSPORT) { + /*uint32_t transportTime =*/ packet.readUInt32(); + } + return true; } @@ -1855,9 +1864,16 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec /*uint8_t hasTransport =*/ packet.readUInt8(); } + uint32_t remainingBlockCount = out.blockCount; + if (packet.getReadPos() + 1 <= packet.getSize()) { uint8_t firstByte = packet.readUInt8(); if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (remainingBlockCount == 0) { + packet.setReadPos(start); + return false; + } + --remainingBlockCount; if (packet.getReadPos() + 4 > packet.getSize()) { packet.setReadPos(start); return false; @@ -1879,6 +1895,7 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec } } + out.blockCount = remainingBlockCount; out.blocks.reserve(out.blockCount); for (uint32_t i = 0; i < out.blockCount; ++i) { if (packet.getReadPos() >= packet.getSize()) { @@ -1905,7 +1922,7 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec switch (updateType) { case UpdateType::MOVEMENT: - block.guid = UpdateObjectParser::readPackedGuid(packet); + block.guid = packet.readUInt64(); if (!movementParser(packet, block)) return false; LOG_DEBUG("[Turtle] Parsed MOVEMENT block via ", layoutName, " layout"); return true; @@ -1964,6 +1981,12 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec } if (!ok) { + LOG_WARNING("[Turtle] SMSG_UPDATE_OBJECT block parse failed", + " blockIndex=", i, + " updateType=", updateTypeName(updateType), + " readPos=", packet.getReadPos(), + " blockStart=", blockStart, + " packetSize=", packet.getSize()); packet.setReadPos(start); return false; } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 308873f1..30dd8e05 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -425,9 +425,16 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa /*uint8_t hasTransport =*/ packet.readUInt8(); } + uint32_t remainingBlockCount = out.blockCount; + if (packet.getReadPos() + 1 <= packet.getSize()) { uint8_t firstByte = packet.readUInt8(); if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (remainingBlockCount == 0) { + packet.setReadPos(start); + return false; + } + --remainingBlockCount; if (packet.getReadPos() + 4 > packet.getSize()) { packet.setReadPos(start); return false; @@ -450,6 +457,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa } } + out.blockCount = remainingBlockCount; out.blocks.reserve(out.blockCount); for (uint32_t i = 0; i < out.blockCount; ++i) { if (packet.getReadPos() >= packet.getSize()) { @@ -473,7 +481,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa break; } case UpdateType::MOVEMENT: { - block.guid = UpdateObjectParser::readPackedGuid(packet); + block.guid = packet.readUInt64(); ok = this->parseMovementBlock(packet, block); break; } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 9af7c692..2f8d5deb 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -35,6 +35,19 @@ namespace { } return packet.getSize() - packet.getReadPos() >= guidBytes; } + + const char* updateTypeName(wowee::game::UpdateType type) { + using wowee::game::UpdateType; + switch (type) { + case UpdateType::VALUES: return "VALUES"; + case UpdateType::MOVEMENT: return "MOVEMENT"; + case UpdateType::CREATE_OBJECT: return "CREATE_OBJECT"; + case UpdateType::CREATE_OBJECT2: return "CREATE_OBJECT2"; + case UpdateType::OUT_OF_RANGE_OBJECTS: return "OUT_OF_RANGE_OBJECTS"; + case UpdateType::NEAR_OBJECTS: return "NEAR_OBJECTS"; + default: return "UNKNOWN"; + } + } } namespace wowee { @@ -1225,7 +1238,13 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& for (int i = 0; i < blockCount; ++i) { // Validate 4 bytes available before each block read if (packet.getReadPos() + 4 > packet.getSize()) { - LOG_WARNING("UpdateObjectParser: truncated update mask at block ", i); + LOG_WARNING("UpdateObjectParser: truncated update mask at block ", i, + " type=", updateTypeName(block.updateType), + " objectType=", static_cast(block.objectType), + " guid=0x", std::hex, block.guid, std::dec, + " readPos=", packet.getReadPos(), + " size=", packet.getSize(), + " maskBlockCount=", static_cast(blockCount)); return false; } updateMask[i] = packet.readUInt32(); @@ -1254,7 +1273,14 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& } // Validate 4 bytes available before reading field value if (packet.getReadPos() + 4 > packet.getSize()) { - LOG_WARNING("UpdateObjectParser: truncated field value at field ", fieldIndex); + LOG_WARNING("UpdateObjectParser: truncated field value at field ", fieldIndex, + " type=", updateTypeName(block.updateType), + " objectType=", static_cast(block.objectType), + " guid=0x", std::hex, block.guid, std::dec, + " readPos=", packet.getReadPos(), + " size=", packet.getSize(), + " maskBlockIndex=", blockIdx, + " maskBlock=0x", std::hex, updateMask[blockIdx], std::dec); return false; } uint32_t value = packet.readUInt32(); @@ -1298,7 +1324,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& case UpdateType::MOVEMENT: { // Movement update - block.guid = readPackedGuid(packet); + block.guid = packet.readUInt64(); LOG_DEBUG(" MOVEMENT update for GUID: 0x", std::hex, block.guid, std::dec); return parseMovementBlock(packet, block); @@ -1361,11 +1387,18 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) LOG_DEBUG(" objectCount = ", data.blockCount); LOG_DEBUG(" packetSize = ", packet.getSize()); + uint32_t remainingBlockCount = data.blockCount; + // Check for out-of-range objects first if (packet.getReadPos() + 1 <= packet.getSize()) { uint8_t firstByte = packet.readUInt8(); if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (remainingBlockCount == 0) { + LOG_ERROR("SMSG_UPDATE_OBJECT rejected: OUT_OF_RANGE_OBJECTS with zero blockCount"); + return false; + } + --remainingBlockCount; // Read out-of-range GUID count uint32_t count = packet.readUInt32(); if (count > kMaxReasonableOutOfRangeGuids) { @@ -1389,6 +1422,7 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) } // Parse update blocks + data.blockCount = remainingBlockCount; data.blocks.reserve(data.blockCount); for (uint32_t i = 0; i < data.blockCount; ++i) {