From d4ea416dd67ba6ca294fabb8b5e42adea09baf83 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:24:09 -0700 Subject: [PATCH 01/71] Fix spell cast audio to use correct magic school from Spell.dbc Previously all player spell casts played ARCANE school sounds regardless of the actual spell school. Now loadSpellNameCache() reads SchoolMask (bitmask, TBC/WotLK) or SchoolEnum (Vanilla/Classic) from Spell.dbc and stores it in SpellNameEntry. handleSpellStart/handleSpellGo look up the spell's school and select the correct MagicSchool for cast sounds. DBC field indices: WotLK SchoolMask=225 (verified), TBC=215, Classic/Turtle SchoolEnum=1 (Vanilla enum 0-6 converted to bitmask). --- Data/expansions/classic/dbc_layouts.json | 2 +- Data/expansions/tbc/dbc_layouts.json | 2 +- Data/expansions/turtle/dbc_layouts.json | 2 +- Data/expansions/wotlk/dbc_layouts.json | 2 +- include/game/game_handler.hpp | 2 +- src/game/game_handler.cpp | 50 +++++++++++++++++++++--- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 1b9bf00f..4ec229d5 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index af1bf479..d40a5766 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136 + "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index a3499be5..4e86338a 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 5cbcf2eb..859f7542 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 4, "IconID": 133, - "Name": 136, "Tooltip": 139, "Rank": 153 + "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index aba5a344..d98af042 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1971,7 +1971,7 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; }; + struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; std::vector trainerTabs_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f1402b57..610048eb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12087,6 +12087,16 @@ void GameHandler::handleCastFailed(network::Packet& packet) { addLocalChatMessage(msg); } +static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) { + if (mask & 0x04) return audio::SpellSoundManager::MagicSchool::FIRE; + if (mask & 0x10) return audio::SpellSoundManager::MagicSchool::FROST; + if (mask & 0x02) return audio::SpellSoundManager::MagicSchool::HOLY; + if (mask & 0x08) return audio::SpellSoundManager::MagicSchool::NATURE; + if (mask & 0x20) return audio::SpellSoundManager::MagicSchool::SHADOW; + if (mask & 0x40) return audio::SpellSoundManager::MagicSchool::ARCANE; + return audio::SpellSoundManager::MagicSchool::ARCANE; +} + void GameHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; if (!SpellStartParser::parse(packet, data)) return; @@ -12098,10 +12108,15 @@ void GameHandler::handleSpellStart(network::Packet& packet) { castTimeTotal = data.castTime / 1000.0f; castTimeRemaining = castTimeTotal; - // Play precast (channeling) sound + // Play precast (channeling) sound with correct magic school if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { - ssm->playPrecast(audio::SpellSoundManager::MagicSchool::ARCANE, audio::SpellSoundManager::SpellPower::MEDIUM); + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); } } } @@ -12113,10 +12128,15 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Cast completed if (data.casterUnit == playerGuid) { - // Play cast-complete sound before clearing state + // Play cast-complete sound with correct magic school if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { - ssm->playCast(audio::SpellSoundManager::MagicSchool::ARCANE); + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playCast(school); } } @@ -14269,6 +14289,17 @@ void GameHandler::loadSpellNameCache() { } const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + + // Determine school field (bitmask for TBC/WotLK, enum for Classic/Vanilla) + uint32_t schoolMaskField = 0, schoolEnumField = 0; + bool hasSchoolMask = false, hasSchoolEnum = false; + if (spellL) { + uint32_t f = spellL->field("SchoolMask"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolMaskField = f; hasSchoolMask = true; } + f = spellL->field("SchoolEnum"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; } + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -14276,7 +14307,16 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - spellNameCache_[id] = {std::move(name), std::move(rank)}; + SpellNameEntry entry{std::move(name), std::move(rank), 0}; + if (hasSchoolMask) { + entry.schoolMask = dbc->getUInt32(i, schoolMaskField); + } else if (hasSchoolEnum) { + // Classic/Vanilla enum: 0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane + static const uint32_t enumToBitmask[] = {0x01,0x02,0x04,0x08,0x10,0x20,0x40}; + uint32_t e = dbc->getUInt32(i, schoolEnumField); + entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0; + } + spellNameCache_[id] = std::move(entry); } } LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc"); From e12e399c0ab43edf8bc73d7d45cbe8fc6520e3e8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:30:18 -0700 Subject: [PATCH 02/71] Implement SMSG_TAXINODE_STATUS parsing and NPC route status cache Parse the flight-master POI status packet (guid + uint8 status) and cache it per-NPC in taxiNpcHasRoutes_. Exposes taxiNpcHasRoutes(guid) accessor for future nameplate/interaction indicators. Previously this packet was silently consumed without any state tracking. --- include/game/game_handler.hpp | 5 +++++ src/game/game_handler.cpp | 11 ++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d98af042..e3b297a7 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1016,6 +1016,10 @@ public: }; const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } uint32_t getTaxiCostTo(uint32_t destNodeId) const; + bool taxiNpcHasRoutes(uint64_t guid) const { + auto it = taxiNpcHasRoutes_.find(guid); + return it != taxiNpcHasRoutes_.end() && it->second; + } // Vendor void openVendor(uint64_t npcGuid); @@ -1878,6 +1882,7 @@ private: } // Taxi / Flight Paths + std::unordered_map taxiNpcHasRoutes_; // guid -> has new/available routes std::unordered_map taxiNodes_; std::vector taxiPathEdges_; std::unordered_map> taxiPathNodes_; // pathId -> ordered waypoints diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 610048eb..bf90d0d9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4282,10 +4282,15 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_TAXINODE_STATUS: - // Node status cache not implemented yet. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_TAXINODE_STATUS: { + // guid(8) + status(1): status 1 = NPC has available/new routes for this player + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t npcGuid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + taxiNpcHasRoutes_[npcGuid] = (status != 0); + } break; + } case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE: case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: // Extra aura metadata (icons/durations) not yet consumed by aura UI. From 63c8dfa3041de7db965202c52cf2d84106ae0cba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:34:33 -0700 Subject: [PATCH 03/71] Show achievement names from Achievement.dbc in chat notifications Previously "Achievement earned! (ID 1234)" was the only message. Now loadAchievementNameCache() lazily loads Achievement.dbc (field 4 = Title, verified against WotLK 3.3.5a binary) on first earned event and shows "Achievement earned: Level 10" or "Player has earned the achievement: ..." Falls back to ID if DBC is unavailable or entry is missing. --- Data/expansions/wotlk/dbc_layouts.json | 1 + include/game/game_handler.hpp | 5 +++ src/game/game_handler.cpp | 50 ++++++++++++++++++++++---- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 859f7542..5b500741 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -28,6 +28,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, + "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, "AreaTable": { "ID": 0, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e3b297a7..a20f8568 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1979,6 +1979,11 @@ private: struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; + + // Achievement name cache (lazy-loaded from Achievement.dbc on first earned event) + std::unordered_map achievementNameCache_; + bool achievementNameCacheLoaded_ = false; + void loadAchievementNameCache(); std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index bf90d0d9..e97ff85b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17094,6 +17094,30 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT // PackedTime date — uint32 bitfield (seconds since epoch) // uint32 realmFirst — how many on realm also got it (0 = realm first) // --------------------------------------------------------------------------- +void GameHandler::loadAchievementNameCache() { + if (achievementNameCacheLoaded_) return; + achievementNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Achievement.dbc"); + if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 22) return; + + const auto* achL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr; + uint32_t titleField = achL ? achL->field("Title") : 4; + if (titleField == 0xFFFFFFFF) titleField = 4; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + if (id == 0) continue; + std::string title = dbc->getString(i, titleField); + if (!title.empty()) achievementNameCache_[id] = std::move(title); + } + LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc"); +} + void GameHandler::handleAchievementEarned(network::Packet& packet) { size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 16) return; // guid(8) + id(4) + date(4) @@ -17102,12 +17126,20 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { uint32_t achievementId = packet.readUInt32(); /*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed + loadAchievementNameCache(); + auto nameIt = achievementNameCache_.find(achievementId); + const std::string& achName = (nameIt != achievementNameCache_.end()) + ? nameIt->second : std::string(); + // Show chat notification bool isSelf = (guid == playerGuid); if (isSelf) { - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Achievement earned! (ID %u)", achievementId); + char buf[256]; + if (!achName.empty()) { + std::snprintf(buf, sizeof(buf), "Achievement earned: %s", achName.c_str()); + } else { + std::snprintf(buf, sizeof(buf), "Achievement earned! (ID %u)", achievementId); + } addSystemChatMessage(buf); if (achievementEarnedCallback_) { @@ -17127,13 +17159,19 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { senderName = tmp; } char buf[256]; - std::snprintf(buf, sizeof(buf), - "%s has earned an achievement! (ID %u)", senderName.c_str(), achievementId); + if (!achName.empty()) { + std::snprintf(buf, sizeof(buf), "%s has earned the achievement: %s", + senderName.c_str(), achName.c_str()); + } else { + std::snprintf(buf, sizeof(buf), "%s has earned an achievement! (ID %u)", + senderName.c_str(), achievementId); + } addSystemChatMessage(buf); } LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec, - " achievementId=", achievementId, " self=", isSelf); + " achievementId=", achievementId, " self=", isSelf, + achName.empty() ? "" : " name=", achName); } // --------------------------------------------------------------------------- From e56d3ca7deed4cb74d151e9dd0170df22f53ba31 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:36:58 -0700 Subject: [PATCH 04/71] Add spell impact sounds for player-targeted spells and improve achievement messages - Play SpellSoundManager::playImpact() with correct school when the player is hit by another unit's spell (SMSG_SPELL_GO hitTargets check) - Show achievement name in SMSG_SERVER_FIRST_ACHIEVEMENT notifications using the already-loaded achievementNameCache_ - playImpact was fully implemented but never called; now wired up --- src/game/game_handler.cpp | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e97ff85b..3c4d3bf9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4545,10 +4545,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 12) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t achievementId = packet.readUInt32(); - char buf[192]; - std::snprintf(buf, sizeof(buf), - "%s is the first on the realm to earn achievement #%u!", - charName.c_str(), achievementId); + loadAchievementNameCache(); + auto nit = achievementNameCache_.find(achievementId); + char buf[256]; + if (nit != achievementNameCache_.end() && !nit->second.empty()) { + std::snprintf(buf, sizeof(buf), + "%s is the first on the realm to earn: %s!", + charName.c_str(), nit->second.c_str()); + } else { + std::snprintf(buf, sizeof(buf), + "%s is the first on the realm to earn achievement #%u!", + charName.c_str(), achievementId); + } addSystemChatMessage(buf); } } @@ -12169,6 +12177,24 @@ void GameHandler::handleSpellGo(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; } + + // Play impact sound when player is hit by any spell (from self or others) + bool playerIsHit = false; + for (const auto& tgt : data.hitTargets) { + if (tgt == playerGuid) { playerIsHit = true; break; } + } + if (playerIsHit && data.casterUnit != playerGuid) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playImpact(school, audio::SpellSoundManager::SpellPower::MEDIUM); + } + } + } } void GameHandler::handleSpellCooldown(network::Packet& packet) { From 1d33ebbfe41992c6f42f46f548c333c1663e21ec Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:42:27 -0700 Subject: [PATCH 05/71] Wire SMSG_MULTIPLE_MOVES to handleCompressedMoves and parse SMSG_PROCRESIST - SMSG_MULTIPLE_MOVES uses the same uint8-size+uint16-opcode format as SMSG_COMPRESSED_MOVES; route it to handleCompressedMoves() so bundled monster movement updates are processed instead of dropped - SMSG_PROCRESIST: parse caster/victim GUIDs and show MISS combat text when the player's proc was resisted by an enemy spell --- src/game/game_handler.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3c4d3bf9..4a87b120 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1771,10 +1771,17 @@ void GameHandler::handlePacket(network::Packet& packet) { break; // ---- Spell proc resist log ---- - case Opcode::SMSG_PROCRESIST: - // guid(8) + guid(8) + uint32 spellId + uint8 logSchoolMask — just consume - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PROCRESIST: { + // casterGuid(8) + victimGuid(8) + uint32 spellId + uint8 logSchoolMask + if (packet.getSize() - packet.getReadPos() >= 17) { + /*uint64_t caster =*/ packet.readUInt64(); + uint64_t victim = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + if (victim == playerGuid) + addCombatText(CombatTextEntry::MISS, 0, spellId, false); + } break; + } // ---- Loot start roll (Need/Greed popup trigger) ---- case Opcode::SMSG_LOOT_START_ROLL: { @@ -4802,7 +4809,8 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: - packet.setReadPos(packet.getSize()); + // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] + handleCompressedMoves(packet); break; case Opcode::SMSG_MULTIPLE_PACKETS: { From 9f340ef45610dca9cec8527f971062eddc2dec4a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:46:52 -0700 Subject: [PATCH 06/71] Fix SMSG_SPELL_DELAYED/EQUIPMENT_SET_SAVED incorrectly sharing PERIODICAURALOG handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two opcodes were accidentally falling through to the PERIODICAURALOG handler which expects packed_guid+packed_guid+uint32+uint32 — wrong format. Now: - SMSG_SPELL_DELAYED: parse caster guid + delayMs, extend castTimeRemaining on player cast pushback (spell cast bar stays accurate under pushback) - SMSG_EQUIPMENT_SET_SAVED: simple acknowledge log (no payload needed) --- src/game/game_handler.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4a87b120..01afd4c9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3028,8 +3028,22 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: - case Opcode::SMSG_SPELL_DELAYED: + case Opcode::SMSG_SPELL_DELAYED: { + // packed_guid (caster) + uint32 delayMs — spell cast was pushed back + // Adjust cast bar if it's the player's spell + if (casting && packet.getSize() - packet.getReadPos() >= 1) { + uint64_t caster = UpdateObjectParser::readPackedGuid(packet); + if (caster == playerGuid && packet.getSize() - packet.getReadPos() >= 4) { + uint32_t delayMs = packet.readUInt32(); + castTimeRemaining += delayMs / 1000.0f; // Extend cast bar by delay + } + } + break; + } case Opcode::SMSG_EQUIPMENT_SET_SAVED: + // uint32 setIndex + uint64 guid — equipment set was successfully saved + LOG_DEBUG("Equipment set saved"); + break; case Opcode::SMSG_PERIODICAURALOG: { // packed_guid victim, packed_guid caster, uint32 spellId, uint32 count, then per-effect if (packet.getSize() - packet.getReadPos() < 2) break; From b6dfa8b747d9e60bb1666ca7d0604f49cf3071fe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 19:54:32 -0700 Subject: [PATCH 07/71] Implement SMSG_RESUME_CAST_BAR, SMSG_THREAT_UPDATE, SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT - SMSG_RESUME_CAST_BAR: parse packed_guid caster/target + spellId + remainingMs + totalMs; restores cast bar state when server re-syncs a cast in progress - SMSG_THREAT_UPDATE: properly consume packed_guid host/target + threat entries to suppress unhandled packet warnings - SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: track up to 5 boss encounter unit guids per slot; expose via getEncounterUnitGuid(slot); clear on world transfer These guids identify active boss units for raid/boss frame display. --- include/game/game_handler.hpp | 8 +++++ src/game/game_handler.cpp | 59 +++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a20f8568..d276bf8a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -772,6 +772,10 @@ public: bool extended = false; }; const std::vector& getInstanceLockouts() const { return instanceLockouts_; } + // Returns boss unit guid for the given encounter slot (0 if none) + uint64_t getEncounterUnitGuid(uint32_t slot) const { + return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0; + } // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { @@ -1738,6 +1742,10 @@ private: // Instance / raid lockouts std::vector instanceLockouts_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) + static constexpr uint32_t kMaxEncounterSlots = 5; + std::array encounterUnitGuids_ = {}; // 0 = empty slot + // LFG / Dungeon Finder state LfgState lfgState_ = LfgState::None; uint32_t lfgDungeonId_ = 0; // current dungeon entry diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 01afd4c9..f4dd5f8a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4547,11 +4547,61 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: - case Opcode::SMSG_RESUME_CAST_BAR: - case Opcode::SMSG_THREAT_UPDATE: + case Opcode::SMSG_RESUME_CAST_BAR: { + // packed_guid caster + packed_guid target + uint32 spellId + // + uint32 remainingMs + uint32 totalMs + uint8 schoolMask + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 1) break; + uint64_t caster = UpdateObjectParser::readPackedGuid(packet); + if (remaining() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); // target + if (remaining() < 12) break; + uint32_t spellId = packet.readUInt32(); + uint32_t remainMs = packet.readUInt32(); + uint32_t totalMs = packet.readUInt32(); + if (caster == playerGuid && totalMs > 0) { + casting = true; + currentCastSpellId = spellId; + castTimeTotal = totalMs / 1000.0f; + castTimeRemaining = remainMs / 1000.0f; + LOG_DEBUG("SMSG_RESUME_CAST_BAR: spell=", spellId, + " remaining=", remainMs, "ms total=", totalMs, "ms"); + } + break; + } + case Opcode::SMSG_THREAT_UPDATE: { + // packed_guid (unit) + packed_guid (target) + uint32 count + // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t cnt = packet.readUInt32(); + for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) { + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() >= 4) + packet.readUInt32(); + } + break; + } + case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: { + // uint32 slot + packed_guid unit (0 packed = clear slot) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); + break; + } + uint32_t slot = packet.readUInt32(); + uint64_t unit = UpdateObjectParser::readPackedGuid(packet); + if (slot < kMaxEncounterSlots) { + encounterUnitGuids_[slot] = unit; + LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot, + " guid=0x", std::hex, unit, std::dec); + } + break; + } case Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP: case Opcode::SMSG_UPDATE_LAST_INSTANCE: - case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: case Opcode::SMSG_SEND_ALL_COMBAT_LOG: case Opcode::SMSG_SET_PROJECTILE_POSITION: case Opcode::SMSG_AUCTION_LIST_PENDING_SALES: @@ -5443,6 +5493,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { mountCallback_(0); } + // Clear boss encounter unit slots on world transfer + encounterUnitGuids_.fill(0); + // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. activeAreaTriggers_.clear(); From 1c1cdf0f23fb0c8b989be76ddd4589856ad99385 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:05:09 -0700 Subject: [PATCH 08/71] Fix Windows socket WSAENOTCONN disconnect; add boss encounter frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socket fixes (fixes Windows-only connection failure): - WorldSocket::connect() now waits for non-blocking connect to complete with select() before returning, preventing WSAENOTCONN on the first recv() call on Windows (Linux handles this implicitly but Windows requires writability poll after non-blocking connect) - Add net::isConnectionClosed() helper: treats WSAENOTCONN/WSAECONNRESET/ WSAESHUTDOWN/WSAECONNABORTED as graceful peer-close rather than recv errors - Apply isConnectionClosed() in both WorldSocket and TCPSocket recv loops UI: - Add renderBossFrames(): displays boss unit health bars in top-right corner when SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT has active slots; supports click-to-target and color-coded health bars (red→orange→yellow as HP drops) --- include/game/game_handler.hpp | 4 +- include/network/net_platform.hpp | 14 ++++++ include/ui/game_screen.hpp | 1 + src/network/tcp_socket.cpp | 5 +++ src/network/world_socket.cpp | 38 ++++++++++++++++ src/ui/game_screen.cpp | 74 ++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d276bf8a..3c34882e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -772,6 +772,9 @@ public: bool extended = false; }; const std::vector& getInstanceLockouts() const { return instanceLockouts_; } + + // Boss encounter unit tracking (SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) + static constexpr uint32_t kMaxEncounterSlots = 5; // Returns boss unit guid for the given encounter slot (0 if none) uint64_t getEncounterUnitGuid(uint32_t slot) const { return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0; @@ -1743,7 +1746,6 @@ private: std::vector instanceLockouts_; // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) - static constexpr uint32_t kMaxEncounterSlots = 5; std::array encounterUnitGuids_ = {}; // 0 = empty slot // LFG / Dungeon Finder state diff --git a/include/network/net_platform.hpp b/include/network/net_platform.hpp index 29eaf2c8..0cc38e1a 100644 --- a/include/network/net_platform.hpp +++ b/include/network/net_platform.hpp @@ -91,6 +91,20 @@ inline bool isWouldBlock(int err) { #endif } +// Returns true for errors that mean the peer closed the connection cleanly. +// On Windows, WSAENOTCONN / WSAECONNRESET / WSAESHUTDOWN can be returned by +// recv() when the server closes the connection, rather than returning 0. +inline bool isConnectionClosed(int err) { +#ifdef _WIN32 + return err == WSAENOTCONN || // socket not connected (server closed) + err == WSAECONNRESET || // connection reset by peer + err == WSAESHUTDOWN || // socket shut down + err == WSAECONNABORTED; // connection aborted +#else + return err == ENOTCONN || err == ECONNRESET; +#endif +} + inline bool isInProgress(int err) { #ifdef _WIN32 return err == WSAEWOULDBLOCK || err == WSAEALREADY; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cd944b47..49d429b6 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -210,6 +210,7 @@ private: void renderMirrorTimers(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); + void renderBossFrames(game::GameHandler& gameHandler); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); diff --git a/src/network/tcp_socket.cpp b/src/network/tcp_socket.cpp index 38a1cf6a..2dbf1b57 100644 --- a/src/network/tcp_socket.cpp +++ b/src/network/tcp_socket.cpp @@ -153,6 +153,11 @@ void TCPSocket::update() { if (net::isWouldBlock(err)) { break; } + if (net::isConnectionClosed(err)) { + // Peer closed the connection — treat the same as recv() returning 0 + sawClose = true; + break; + } LOG_ERROR("Receive failed: ", net::errorString(err)); disconnect(); diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index ab29a271..78c90c8e 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -128,6 +128,39 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { sockfd = INVALID_SOCK; return false; } + + // Non-blocking connect in progress — wait up to 10s for completion. + // On Windows, calling recv() before the connect completes returns + // WSAENOTCONN; we must poll writability before declaring connected. + fd_set writefds, errfds; + FD_ZERO(&writefds); + FD_ZERO(&errfds); + FD_SET(sockfd, &writefds); + FD_SET(sockfd, &errfds); + + struct timeval tv; + tv.tv_sec = 10; + tv.tv_usec = 0; + + int sel = ::select(static_cast(sockfd) + 1, nullptr, &writefds, &errfds, &tv); + if (sel <= 0) { + LOG_ERROR("World server connection timed out (", host, ":", port, ")"); + net::closeSocket(sockfd); + sockfd = INVALID_SOCK; + return false; + } + + // Verify the socket error code — writeable doesn't guarantee success on all platforms + int sockErr = 0; + socklen_t errLen = sizeof(sockErr); + getsockopt(sockfd, SOL_SOCKET, SO_ERROR, + reinterpret_cast(&sockErr), &errLen); + if (sockErr != 0) { + LOG_ERROR("Failed to connect to world server: ", net::errorString(sockErr)); + net::closeSocket(sockfd); + sockfd = INVALID_SOCK; + return false; + } } connected = true; @@ -369,6 +402,11 @@ void WorldSocket::update() { if (net::isWouldBlock(err)) { break; } + if (net::isConnectionClosed(err)) { + // Peer closed the connection — treat the same as recv() returning 0 + sawClose = true; + break; + } LOG_ERROR("Receive failed: ", net::errorString(err)); disconnect(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 06e6b4b5..504b609e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -403,6 +403,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (showNameplates_) renderNameplates(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); + renderBossFrames(gameHandler); renderGroupInvitePopup(gameHandler); renderDuelRequestPopup(gameHandler); renderLootRollPopup(gameHandler); @@ -4893,6 +4894,79 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Boss Encounter Frames +// ============================================================ + +void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { + // Collect active boss unit slots + struct BossSlot { uint32_t slot; uint64_t guid; }; + std::vector active; + for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) { + uint64_t g = gameHandler.getEncounterUnitGuid(s); + if (g != 0) active.push_back({s, g}); + } + if (active.empty()) return; + + const float frameW = 200.0f; + const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f; + float frameY = 120.0f; + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f)); + + ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + if (ImGui::Begin("##BossFrames", nullptr, flags)) { + for (const auto& bs : active) { + ImGui::PushID(static_cast(bs.guid)); + + // Try to resolve name and health from entity manager + std::string name = "Boss"; + uint32_t hp = 0, maxHp = 0; + auto entity = gameHandler.getEntityManager().getEntity(bs.guid); + if (entity && (entity->getType() == game::ObjectType::UNIT || + entity->getType() == game::ObjectType::PLAYER)) { + auto unit = std::static_pointer_cast(entity); + const auto& n = unit->getName(); + if (!n.empty()) name = n; + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + + // Clickable name to target + if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) { + gameHandler.setTarget(bs.guid); + } + + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + // Boss health bar in red shades + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.8f, 0.2f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) : + ImVec4(1.0f, 0.8f, 0.1f, 1.0f)); + char label[32]; + std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), label); + ImGui::PopStyleColor(); + } + + ImGui::PopID(); + ImGui::Spacing(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + // ============================================================ // Group Invite Popup (Phase 4) // ============================================================ From 151303a20a6869f4aefea8edb6e2aa36294bbe9e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:15:34 -0700 Subject: [PATCH 09/71] Implement SMSG_SPELLDAMAGESHIELD, SMSG_SPELLORDAMAGE_IMMUNE; route MSG_MOVE in SMSG_MULTIPLE_MOVES - SMSG_SPELLDAMAGESHIELD: parse victim/caster/damage fields and show SPELL_DAMAGE combat text for player-relevant events (damage shields like Thorns) - SMSG_SPELLORDAMAGE_IMMUNE: parse packed caster/victim guids and show new IMMUNE combat text type when player is involved in an immunity event - Add CombatTextEntry::IMMUNE type to spell_defines.hpp and render it as white "Immune!" in the combat text overlay - handleCompressedMoves: add MSG_MOVE_* routing so SMSG_MULTIPLE_MOVES sub-packets (player movement batches) are dispatched to handleOtherPlayerMovement instead of logged as unhandled; fix runtime-opcode lookup (non-static array) --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 65 ++++++++++++++++++++++++++++++++-- src/ui/game_screen.cpp | 4 +++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 3d1e871f..041b44f6 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,7 +51,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN + ENERGIZE, XP_GAIN, IMMUNE }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f4dd5f8a..b5ad4772 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4529,11 +4529,48 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell combat logs (consume) ---- case Opcode::SMSG_AURACASTLOG: case Opcode::SMSG_SPELLBREAKLOG: - case Opcode::SMSG_SPELLDAMAGESHIELD: + case Opcode::SMSG_SPELLDAMAGESHIELD: { + // victimGuid(8) + casterGuid(8) + spellId(4) + damage(4) + schoolMask(4) + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = packet.readUInt64(); + uint64_t casterGuid = packet.readUInt64(); + /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t damage = packet.readUInt32(); + /*uint32_t school =*/ packet.readUInt32(); + // Show combat text: damage shield reflect + if (casterGuid == playerGuid) { + // We have a damage shield that reflected damage + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, true); + } else if (victimGuid == playerGuid) { + // A damage shield hit us (e.g. target's Thorns) + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, false); + } + break; + } + case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { + // casterGuid(packed) + victimGuid(packed) + uint32 spellId + uint8 saveType + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint32_t spellId =*/ packet.readUInt32(); + /*uint8_t saveType =*/ packet.readUInt8(); + // Show IMMUNE text when the player is the caster (we hit an immune target) + // or the victim (we are immune) + if (casterGuid == playerGuid || victimGuid == playerGuid) { + addCombatText(CombatTextEntry::IMMUNE, 0, 0, + casterGuid == playerGuid); + } + break; + } case Opcode::SMSG_SPELLDISPELLOG: case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLLOGEXECUTE: - case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: case Opcode::SMSG_SPELLSTEALLOG: case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: @@ -11496,6 +11533,26 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT); + // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) + // Not static — wireOpcode() depends on runtime active opcode table. + const std::array kMoveOpcodes = { + wireOpcode(Opcode::MSG_MOVE_START_FORWARD), + wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), + wireOpcode(Opcode::MSG_MOVE_STOP), + wireOpcode(Opcode::MSG_MOVE_START_STRAFE_LEFT), + wireOpcode(Opcode::MSG_MOVE_START_STRAFE_RIGHT), + wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE), + wireOpcode(Opcode::MSG_MOVE_JUMP), + wireOpcode(Opcode::MSG_MOVE_START_TURN_LEFT), + wireOpcode(Opcode::MSG_MOVE_START_TURN_RIGHT), + wireOpcode(Opcode::MSG_MOVE_STOP_TURN), + wireOpcode(Opcode::MSG_MOVE_SET_FACING), + wireOpcode(Opcode::MSG_MOVE_FALL_LAND), + wireOpcode(Opcode::MSG_MOVE_HEARTBEAT), + wireOpcode(Opcode::MSG_MOVE_START_SWIM), + wireOpcode(Opcode::MSG_MOVE_STOP_SWIM), + }; + // Track unhandled sub-opcodes once per compressed packet (avoid log spam) std::unordered_set unhandledSeen; @@ -11521,6 +11578,10 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { handleMonsterMove(subPacket); } else if (subOpcode == monsterMoveTransportWire) { handleMonsterMoveTransport(subPacket); + } else if (state == WorldState::IN_WORLD && + std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end()) { + // Player/NPC movement update packed in SMSG_MULTIPLE_MOVES + handleOtherPlayerMovement(subPacket); } else { if (unhandledSeen.insert(subOpcode).second) { LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x", diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 504b609e..f5ab928e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4671,6 +4671,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "+%d XP", entry.amount); color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); From 926bcbb50e694891da2dbba7cbae9429be479551 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:20:24 -0700 Subject: [PATCH 10/71] Implement SMSG_SPELLDISPELLOG dispel feedback and show dispel/steal notifications - SMSG_SPELLDISPELLOG: parse packed caster/victim + dispel spell + isStolen + dispelled spell list; show system message when player dispels or has a buff dispelled/stolen (e.g. "Shadow Word: Pain was dispelled." / "You dispelled Renew.") - SMSG_SPELLSTEALLOG: separated from SPELLDISPELLOG consume group with comment explaining the relationship (same wire format, player-facing covered by SPELLDISPELLOG) --- src/game/game_handler.cpp | 48 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b5ad4772..894afc4d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4568,10 +4568,54 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_SPELLDISPELLOG: + case Opcode::SMSG_SPELLDISPELLOG: { + // packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 9) break; + /*uint32_t dispelSpell =*/ packet.readUInt32(); + uint8_t isStolen = packet.readUInt8(); + uint32_t count = packet.readUInt32(); + // Show system message if player was victim or caster + if (victimGuid == playerGuid || casterGuid == playerGuid) { + const char* verb = isStolen ? "stolen" : "dispelled"; + // Collect first dispelled spell name for the message + std::string firstSpellName; + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { + uint32_t dispelledId = packet.readUInt32(); + /*uint32_t unk =*/ packet.readUInt32(); + if (i == 0) { + const std::string& nm = getSpellName(dispelledId); + firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + } + } + if (!firstSpellName.empty()) { + char buf[256]; + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); + addSystemChatMessage(buf); + } + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELLSTEALLOG: { + // Similar to SPELLDISPELLOG but always isStolen=true; same wire format + // Just consume — SPELLDISPELLOG handles the player-facing case above + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLLOGEXECUTE: - case Opcode::SMSG_SPELLSTEALLOG: case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: From 5024e8cb32c78ba83c3d6b64eec705fb1fdde0a5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:23:38 -0700 Subject: [PATCH 11/71] Implement SMSG_SET_PROFICIENCY: track weapon/armor proficiency bitmasks - Parse uint8 itemClass + uint32 subClassMask from SMSG_SET_PROFICIENCY - Store weaponProficiency_ (itemClass=2) and armorProficiency_ (itemClass=4) - Expose getWeaponProficiency(), getArmorProficiency(), canUseWeaponSubclass(n), canUseArmorSubclass(n) on GameHandler for use by equipment UI - Enables future equipment slot validation (grey out non-proficient items) --- include/game/game_handler.hpp | 11 +++++++++++ src/game/game_handler.cpp | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3c34882e..53029471 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -457,6 +457,15 @@ public: bool hasPet() const { return petGuid_ != 0; } uint64_t getPetGuid() const { return petGuid_; } const std::unordered_set& getKnownSpells() const { return knownSpells; } + + // Player proficiency bitmasks (from SMSG_SET_PROFICIENCY) + // itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing) + // itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield) + uint32_t getWeaponProficiency() const { return weaponProficiency_; } + uint32_t getArmorProficiency() const { return armorProficiency_; } + bool canUseWeaponSubclass(uint32_t subClass) const { return (weaponProficiency_ >> subClass) & 1u; } + bool canUseArmorSubclass(uint32_t subClass) const { return (armorProficiency_ >> subClass) & 1u; } + bool isCasting() const { return casting; } bool isGameObjectInteractionCasting() const { return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0; @@ -1687,6 +1696,8 @@ private: std::unique_ptr transportManager_; // Transport movement manager std::unordered_set knownSpells; std::unordered_map spellCooldowns; // spellId -> remaining seconds + uint32_t weaponProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=2 + uint32_t armorProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=4 uint8_t castCount = 0; bool casting = false; uint32_t currentCastSpellId = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 894afc4d..1c4f0e70 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3118,9 +3118,20 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } - case Opcode::SMSG_SET_PROFICIENCY: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SET_PROFICIENCY: { + // uint8 itemClass + uint32 itemSubClassMask + if (packet.getSize() - packet.getReadPos() < 5) break; + uint8_t itemClass = packet.readUInt8(); + uint32_t mask = packet.readUInt32(); + if (itemClass == 2) { // Weapon + weaponProficiency_ = mask; + LOG_DEBUG("SMSG_SET_PROFICIENCY: weapon mask=0x", std::hex, mask, std::dec); + } else if (itemClass == 4) { // Armor + armorProficiency_ = mask; + LOG_DEBUG("SMSG_SET_PROFICIENCY: armor mask=0x", std::hex, mask, std::dec); + } break; + } case Opcode::SMSG_ACTION_BUTTONS: { // uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons From cc61732106b9ef1f2baeeee36a320f4d3e3e4212 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:27:02 -0700 Subject: [PATCH 12/71] Implement SMSG_PARTYKILLLOG: show kill credit messages in chat - Parse uint64 killerGuid + uint64 victimGuid - Resolve names from playerNameCache (players) and entity manager (NPCs) - Show "[Killer] killed [Victim]." as system chat when both names are known --- src/game/game_handler.cpp | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1c4f0e70..cc849662 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2733,11 +2733,35 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DUEL_COUNTDOWN: // Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update. break; - case Opcode::SMSG_PARTYKILLLOG: - // Classic-era packet: killer GUID + victim GUID. - // XP and combat state are handled by other packets; consume to avoid warning spam. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PARTYKILLLOG: { + // uint64 killerGuid + uint64 victimGuid + if (packet.getSize() - packet.getReadPos() < 16) break; + uint64_t killerGuid = packet.readUInt64(); + uint64_t victimGuid = packet.readUInt64(); + // Show kill message in party chat style + auto nameForGuid = [&](uint64_t g) -> std::string { + // Check player name cache first + auto nit = playerNameCache.find(g); + if (nit != playerNameCache.end()) return nit->second; + // Fall back to entity name (NPCs) + auto ent = entityManager.getEntity(g); + if (ent && (ent->getType() == game::ObjectType::UNIT || + ent->getType() == game::ObjectType::PLAYER)) { + auto unit = std::static_pointer_cast(ent); + return unit->getName(); + } + return {}; + }; + std::string killerName = nameForGuid(killerGuid); + std::string victimName = nameForGuid(victimGuid); + if (!killerName.empty() && !victimName.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s killed %s.", + killerName.c_str(), victimName.c_str()); + addSystemChatMessage(buf); + } break; + } // ---- Guild ---- case Opcode::SMSG_GUILD_INFO: From 0562139868152d362aadd23bfe23d565e46ef427 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:31:57 -0700 Subject: [PATCH 13/71] Implement SMSG_LOOT_ITEM_NOTIFY: show party loot notifications in chat Parse loot item notify packets to display messages like "Thrall loots [Item Name]." in system chat when a group member loots an item. --- src/game/game_handler.cpp | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index cc849662..0dec7072 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2173,10 +2173,35 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRollActive_ = false; break; } - case Opcode::SMSG_LOOT_ITEM_NOTIFY: - // uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count — consume - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_ITEM_NOTIFY: { + // uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t looterGuid = packet.readUInt64(); + /*uint64_t lootGuid =*/ packet.readUInt64(); + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + // Show loot message for party members (not the player — SMSG_ITEM_PUSH_RESULT covers that) + if (isInGroup() && looterGuid != playerGuid) { + auto nit = playerNameCache.find(looterGuid); + std::string looterName = (nit != playerNameCache.end()) ? nit->second : ""; + if (!looterName.empty()) { + queryItemInfo(itemId, 0); + std::string itemName = "item #" + std::to_string(itemId); + if (const ItemQueryResponseData* info = getItemInfo(itemId)) { + if (!info->name.empty()) itemName = info->name; + } + char buf[256]; + if (count > 1) + std::snprintf(buf, sizeof(buf), "%s loots %s x%u.", looterName.c_str(), itemName.c_str(), count); + else + std::snprintf(buf, sizeof(buf), "%s loots %s.", looterName.c_str(), itemName.c_str()); + addSystemChatMessage(buf); + } + } break; + } case Opcode::SMSG_LOOT_SLOT_CHANGED: // uint64 objectGuid + uint32 slot + ... — consume packet.setReadPos(packet.getSize()); From 95e8fcb88ea78ed391d2fc9b3e479ace95f34038 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:36:20 -0700 Subject: [PATCH 14/71] Implement minimap ping: parse MSG_MINIMAP_PING and render animated ping circles Parse party member minimap pings (packed GUID + posX + posY), store with 5s lifetime, and render as expanding concentric circles on the minimap. --- include/game/game_handler.hpp | 19 +++++++++++++++++++ src/game/game_handler.cpp | 18 +++++++++++++++--- src/ui/game_screen.cpp | 19 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 53029471..58c49af3 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -466,6 +466,24 @@ public: bool canUseWeaponSubclass(uint32_t subClass) const { return (weaponProficiency_ >> subClass) & 1u; } bool canUseArmorSubclass(uint32_t subClass) const { return (armorProficiency_ >> subClass) & 1u; } + // Minimap pings from party members + struct MinimapPing { + uint64_t senderGuid = 0; + float wowX = 0.0f; // canonical WoW X (north) + float wowY = 0.0f; // canonical WoW Y (west) + float age = 0.0f; // seconds since received + static constexpr float LIFETIME = 5.0f; + bool isExpired() const { return age >= LIFETIME; } + }; + const std::vector& getMinimapPings() const { return minimapPings_; } + void tickMinimapPings(float dt) { + for (auto& p : minimapPings_) p.age += dt; + minimapPings_.erase( + std::remove_if(minimapPings_.begin(), minimapPings_.end(), + [](const MinimapPing& p){ return p.isExpired(); }), + minimapPings_.end()); + } + bool isCasting() const { return casting; } bool isGameObjectInteractionCasting() const { return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0; @@ -1698,6 +1716,7 @@ private: std::unordered_map spellCooldowns; // spellId -> remaining seconds uint32_t weaponProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=2 uint32_t armorProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=4 + std::vector minimapPings_; uint8_t castCount = 0; bool casting = false; uint32_t currentCastSpellId = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0dec7072..ab206173 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -779,6 +779,7 @@ void GameHandler::update(float deltaTime) { // Update combat text (Phase 2) updateCombatText(deltaTime); + tickMinimapPings(deltaTime); // Update taxi landing cooldown if (taxiLandingCooldown_ > 0.0f) { @@ -2588,10 +2589,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FISH_ESCAPED: addSystemChatMessage("Your fish escaped!"); break; - case Opcode::MSG_MINIMAP_PING: - // Minimap ping from a party member — consume; no visual support yet. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_MINIMAP_PING: { + // SMSG: packed_guid + float posX (canonical WoW Y=west) + float posY (canonical WoW X=north) + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t senderGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + float pingX = packet.readFloat(); // server sends map-coord X (east-west) + float pingY = packet.readFloat(); // server sends map-coord Y (north-south) + MinimapPing ping; + ping.senderGuid = senderGuid; + ping.wowX = pingY; // canonical WoW X = north = server's posY + ping.wowY = pingX; // canonical WoW Y = west = server's posX + ping.age = 0.0f; + minimapPings_.push_back(ping); break; + } case Opcode::SMSG_ZONE_UNDER_ATTACK: { // uint32 areaId if (packet.getSize() - packet.getReadPos() >= 4) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f5ab928e..84d8cb1c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8040,6 +8040,25 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Minimap pings from party members + for (const auto& ping : gameHandler.getMinimapPings()) { + glm::vec3 pingRender = core::coords::canonicalToRender(glm::vec3(ping.wowX, ping.wowY, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(pingRender, sx, sy)) continue; + + float t = ping.age / game::GameHandler::MinimapPing::LIFETIME; + float alpha = 1.0f - t; + float pulse = 1.0f + 1.5f * t; // expands outward as it fades + + ImU32 col = IM_COL32(255, 220, 0, static_cast(alpha * 200)); + ImU32 col2 = IM_COL32(255, 150, 0, static_cast(alpha * 100)); + float r1 = 4.0f * pulse; + float r2 = 8.0f * pulse; + drawList->AddCircle(ImVec2(sx, sy), r1, col, 16, 2.0f); + drawList->AddCircle(ImVec2(sx, sy), r2, col2, 16, 1.0f); + drawList->AddCircleFilled(ImVec2(sx, sy), 2.5f, col); + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; From 3f64f81ec0e57c43891138c9a2b9857a30d926d0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:38:44 -0700 Subject: [PATCH 15/71] Implement MSG_RAID_READY_CHECK_CONFIRM and MSG_RAID_READY_CHECK_FINISHED Track individual member ready/not-ready responses in chat and summarize results when all members have responded to the ready check. --- include/game/game_handler.hpp | 4 +++- src/game/game_handler.cpp | 38 +++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 58c49af3..7ce933bf 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1786,7 +1786,9 @@ private: uint32_t lfgTimeInQueueMs_= 0; // ms already in queue // Ready check state - bool pendingReadyCheck_ = false; + bool pendingReadyCheck_ = false; + uint32_t readyCheckReadyCount_ = 0; + uint32_t readyCheckNotReadyCount_ = 0; std::string readyCheckInitiator_; // Faction standings (factionId → absolute standing value) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ab206173..5276d6f7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2724,7 +2724,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_RAID_READY_CHECK: { // Server is broadcasting a ready check (someone in the raid initiated it). // Payload: empty body, or optional uint64 initiator GUID in some builds. - pendingReadyCheck_ = true; + pendingReadyCheck_ = true; + readyCheckReadyCount_ = 0; + readyCheckNotReadyCount_ = 0; readyCheckInitiator_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); @@ -2745,10 +2747,38 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_); break; } - case Opcode::MSG_RAID_READY_CHECK_CONFIRM: - // Another member responded to the ready check — consume. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_RAID_READY_CHECK_CONFIRM: { + // guid (8) + uint8 isReady (0=not ready, 1=ready) + if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } + uint64_t respGuid = packet.readUInt64(); + uint8_t isReady = packet.readUInt8(); + if (isReady) ++readyCheckReadyCount_; + else ++readyCheckNotReadyCount_; + auto nit = playerNameCache.find(respGuid); + std::string rname; + if (nit != playerNameCache.end()) rname = nit->second; + else { + auto ent = entityManager.getEntity(respGuid); + if (ent) rname = std::static_pointer_cast(ent)->getName(); + } + if (!rname.empty()) { + char rbuf[128]; + std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); + addSystemChatMessage(rbuf); + } break; + } + case Opcode::MSG_RAID_READY_CHECK_FINISHED: { + // Ready check complete — summarize results + char fbuf[128]; + std::snprintf(fbuf, sizeof(fbuf), "Ready check complete: %u ready, %u not ready.", + readyCheckReadyCount_, readyCheckNotReadyCount_); + addSystemChatMessage(fbuf); + pendingReadyCheck_ = false; + readyCheckReadyCount_ = 0; + readyCheckNotReadyCount_ = 0; + break; + } case Opcode::SMSG_RAID_INSTANCE_INFO: handleRaidInstanceInfo(packet); break; From a49c013c8949e1acda9d9dd41f1de55695140cc2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:40:58 -0700 Subject: [PATCH 16/71] Fix SMSG_RESUME_CAST_BAR: separate from unrelated opcodes in fallthrough group SMSG_LOOT_LIST, SMSG_COMPLAIN_RESULT, SMSG_ITEM_REFUND_INFO_RESPONSE, and SMSG_ITEM_ENCHANT_TIME_UPDATE were incorrectly falling through to the SMSG_RESUME_CAST_BAR handler, causing those packets to be parsed as cast bar resume data with a completely different wire format. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5276d6f7..5146e98d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4730,6 +4730,10 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: + // Consume — not yet processed + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_RESUME_CAST_BAR: { // packed_guid caster + packed_guid target + uint32 spellId // + uint32 remainingMs + uint32 totalMs + uint8 schoolMask From e0d47040d32837d7453caa7b0495af1c0b3967cf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 20:58:49 -0700 Subject: [PATCH 17/71] Fix main-thread hang from terrain finalization; two-pass M2 rendering; tile streaming improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hang/GPU device lost fix: - M2_INSTANCES and WMO_INSTANCES finalization phases now create instances incrementally (32 per step / 4 per step) instead of all at once, eliminating the >1s main-thread stalls that caused GPU fence timeouts and device loss M2 two-pass transparent rendering: - Opaque/alpha-test batches render in pass 1, transparent/additive in pass 2 (back-to-front sorted) to fix wing transparency showing terrain instead of trees — adds hasTransparentBatches flag to skip models with no transparency Tile streaming improvements: - Sort new load queue entries nearest-first so critical tiles load before distant ones during fast taxi flight - Increase taxi load radius 6→8 tiles, unload 9→12 for better coverage Water refraction gated on FSR: - Disable water refraction when FSR is not active (bugged without upscaling) - Auto-disable refraction if FSR is turned off while refraction was on --- include/rendering/m2_renderer.hpp | 1 + include/rendering/terrain_manager.hpp | 8 +- src/core/application.cpp | 4 +- src/rendering/m2_renderer.cpp | 174 +++++++++++++++++++++++++- src/rendering/terrain_manager.cpp | 69 +++++----- src/ui/game_screen.cpp | 16 ++- 6 files changed, 234 insertions(+), 38 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index a37c4a2d..e26583b5 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -122,6 +122,7 @@ struct M2ModelGPU { bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed) bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback) bool hasTextureAnimation = false; // True if any batch has UV animation + bool hasTransparentBatches = false; // True if any batch uses alpha-blend or additive (blendMode >= 2) uint8_t availableLODs = 0; // Bitmask: bit N set if any batch has submeshLevel==N // Particle emitter data (kept from M2Model) diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 2a746d3e..290c45eb 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -152,9 +152,11 @@ struct FinalizingTile { FinalizationPhase phase = FinalizationPhase::TERRAIN; // Progress indices within current phase - size_t m2ModelIndex = 0; // Next M2 model to upload - size_t wmoModelIndex = 0; // Next WMO model to upload - size_t wmoDoodadIndex = 0; // Next WMO doodad to upload + size_t m2ModelIndex = 0; // Next M2 model to upload + size_t m2InstanceIndex = 0; // Next M2 placement to instantiate + size_t wmoModelIndex = 0; // Next WMO model to upload + size_t wmoInstanceIndex = 0; // Next WMO placement to instantiate + size_t wmoDoodadIndex = 0; // Next WMO doodad to upload // Incremental terrain upload state (splits TERRAIN phase across frames) bool terrainPreloaded = false; // True after preloaded textures uploaded diff --git a/src/core/application.cpp b/src/core/application.cpp index be239cfc..f26e9865 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1108,8 +1108,8 @@ void Application::update(float deltaTime) { // Taxi flights move fast (32 u/s) — load further ahead so terrain is ready // before the camera arrives. Keep updates frequent to spot new tiles early. renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.033f : 0.033f); - renderer->getTerrainManager()->setLoadRadius(onTaxi ? 6 : 4); - renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 9 : 7); + renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4); + renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7); renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi); } lastTaxiFlight_ = onTaxi; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index a28e49a6..4f7e2d0c 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1357,6 +1357,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { if (batch.materialIndex < model.materials.size()) { bgpu.blendMode = model.materials[batch.materialIndex].blendMode; bgpu.materialFlags = model.materials[batch.materialIndex].flags; + if (bgpu.blendMode >= 2) gpuModel.hasTransparentBatches = true; } // Copy LOD level from batch @@ -2349,7 +2350,11 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); } - // Sort by modelId to minimize vertex/index buffer rebinds + // Two-pass rendering: opaque/alpha-test first (depth write ON), then transparent/additive + // (depth write OFF, sorted back-to-front) so transparent geometry composites correctly + // against all opaque geometry rather than only against what was rendered before it. + + // Pass 1: sort by modelId for minimum buffer rebinds (opaque batches) std::sort(sortedVisible_.begin(), sortedVisible_.end(), [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); @@ -2377,6 +2382,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Start with opaque pipeline vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, opaquePipeline_); currentPipeline = opaquePipeline_; + bool opaquePass = true; // Pass 1 = opaque, pass 2 = transparent (set below for second pass) for (const auto& entry : sortedVisible_) { if (entry.index >= instances.size()) continue; @@ -2475,6 +2481,15 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; if (batch.batchOpacity < 0.01f) continue; + // Two-pass gate: pass 1 = opaque/cutout only, pass 2 = transparent/additive only. + // Alpha-test (blendMode==1) and spell effects that force-additive are handled + // by their effective blend mode below; gate on raw blendMode here. + { + const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; + if (opaquePass && rawTransparent) continue; // skip transparent in opaque pass + if (!opaquePass && !rawTransparent) continue; // skip opaque in transparent pass + } + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; const bool smallCardLikeBatch = (batch.glowSize <= 1.35f) || @@ -2628,6 +2643,163 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } + // Pass 2: transparent/additive batches — sort back-to-front by distance so + // overlapping transparent geometry composites in the correct painter's order. + opaquePass = false; + std::sort(sortedVisible_.begin(), sortedVisible_.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.distSq > b.distSq; }); + + currentModelId = UINT32_MAX; + currentModel = nullptr; + // Reset pipeline to opaque so the first transparent bind always sets explicitly + currentPipeline = opaquePipeline_; + + for (const auto& entry : sortedVisible_) { + if (entry.index >= instances.size()) continue; + auto& instance = instances[entry.index]; + + // Quick skip: if model has no transparent batches at all, skip it entirely + if (entry.modelId != currentModelId) { + auto mdlIt = models.find(entry.modelId); + if (mdlIt == models.end()) continue; + if (!mdlIt->second.hasTransparentBatches && !mdlIt->second.isSpellEffect) continue; + } + + // Reuse the same rendering logic as pass 1 (via fallthrough — the batch gate + // `!opaquePass && !rawTransparent → continue` handles opaque skipping) + if (entry.modelId != currentModelId) { + currentModelId = entry.modelId; + auto mdlIt = models.find(currentModelId); + if (mdlIt == models.end()) continue; + currentModel = &mdlIt->second; + if (!currentModel->vertexBuffer) continue; + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); + } + + const M2ModelGPU& model = *currentModel; + + // Distance-based fade alpha (same as pass 1) + float fadeAlpha = 1.0f; + float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction; + float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; + if (entry.distSq > fadeStartDistSq) { + fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / + (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); + } + float instanceFadeAlpha = fadeAlpha; + if (model.isGroundDetail) instanceFadeAlpha *= 0.82f; + if (model.isInstancePortal) instanceFadeAlpha *= 0.12f; + + bool modelNeedsAnimation = model.hasAnimation && !model.disableAnimation; + if (modelNeedsAnimation && instance.boneMatrices.empty()) continue; + bool needsBones = modelNeedsAnimation && !instance.boneMatrices.empty(); + if (needsBones && (!instance.boneBuffer[frameIndex] || !instance.boneSet[frameIndex])) continue; + bool useBones = needsBones; + if (useBones && instance.boneSet[frameIndex]) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &instance.boneSet[frameIndex], 0, nullptr); + } + + uint16_t desiredLOD = 0; + if (entry.distSq > 150.0f * 150.0f) desiredLOD = 3; + else if (entry.distSq > 80.0f * 80.0f) desiredLOD = 2; + else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; + uint16_t targetLOD = desiredLOD; + if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0; + + const bool foliageLikeModel = model.isFoliageLike; + const bool particleDominantEffect = model.isSpellEffect && + !model.particleEmitters.empty() && model.batches.size() <= 2; + + for (const auto& batch : model.batches) { + if (batch.indexCount == 0) continue; + if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; + if (batch.batchOpacity < 0.01f) continue; + + // Pass 2 gate: only transparent/additive batches + { + const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; + if (!rawTransparent) continue; + } + + // Skip glow sprites (handled after loop) + const bool batchUnlit = (batch.materialFlags & 0x01) != 0; + const bool shouldUseGlowSprite = + !batch.colorKeyBlack && + (model.isElvenLike || model.isLanternLike) && + !model.isSpellEffect && + (batch.glowSize <= 1.35f || (batch.lanternGlowHint && batch.glowSize <= 6.0f)) && + (batch.lanternGlowHint || (batch.blendMode >= 3) || + (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); + if (shouldUseGlowSprite) { + const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; + if ((batch.glowCardLike && model.isLanternLike) || (cardLikeSkipMesh && !model.isLanternLike)) + continue; + } + + glm::vec2 uvOffset(0.0f, 0.0f); + if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { + uint16_t lookupIdx = batch.textureAnimIndex; + if (lookupIdx < model.textureTransformLookup.size()) { + uint16_t transformIdx = model.textureTransformLookup[lookupIdx]; + if (transformIdx < model.textureTransforms.size()) { + const auto& tt = model.textureTransforms[transformIdx]; + glm::vec3 trans = interpVec3(tt.translation, + instance.currentSequenceIndex, instance.animTime, + glm::vec3(0.0f), model.globalSequenceDurations); + uvOffset = glm::vec2(trans.x, trans.y); + } + } + } + if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { + static auto startTime2 = std::chrono::steady_clock::now(); + float t = std::chrono::duration(std::chrono::steady_clock::now() - startTime2).count(); + uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); + } + + uint8_t effectiveBlendMode = batch.blendMode; + if (model.isSpellEffect) { + if (effectiveBlendMode <= 1) effectiveBlendMode = 3; + else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) effectiveBlendMode = 3; + } + + VkPipeline desiredPipeline; + switch (effectiveBlendMode) { + case 2: desiredPipeline = alphaPipeline_; break; + default: desiredPipeline = additivePipeline_; break; + } + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; + } + + if (batch.materialUBOMapped) { + auto* mat = static_cast(batch.materialUBOMapped); + mat->interiorDarken = insideInterior ? 1.0f : 0.0f; + if (batch.colorKeyBlack) + mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f; + } + + if (!batch.materialSet) continue; + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); + + M2PushConstants pc; + pc.model = instance.modelMatrix; + pc.uvOffset = uvOffset; + pc.texCoordSet = static_cast(batch.textureUnit); + pc.useBones = useBones ? 1 : 0; + pc.isFoliage = model.shadowWindFoliage ? 1 : 0; + pc.fadeAlpha = instanceFadeAlpha; + if (particleDominantEffect) continue; // emission-only mesh + vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); + lastDrawCallCount++; + } + } + // Render glow sprites as billboarded additive point lights if (!glowSprites_.empty() && particleAdditivePipeline_ && glowVB_ && glowTexDescSet_) { vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particleAdditivePipeline_); diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index e186ed96..579a909a 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -885,13 +885,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { } case FinalizationPhase::M2_INSTANCES: { - // Create all M2 instances (lightweight struct allocation, no GPU work) - if (m2Renderer) { - int loadedDoodads = 0; - int skippedDedup = 0; - for (const auto& p : pending->m2Placements) { + // Create M2 instances incrementally to avoid main-thread stalls. + // createInstance includes an O(n) bone-sibling scan that becomes expensive + // on dense tiles with many placements and a large existing instance list. + if (m2Renderer && ft.m2InstanceIndex < pending->m2Placements.size()) { + constexpr size_t kInstancesPerStep = 32; + size_t created = 0; + while (ft.m2InstanceIndex < pending->m2Placements.size() && created < kInstancesPerStep) { + const auto& p = pending->m2Placements[ft.m2InstanceIndex++]; if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) { - skippedDedup++; continue; } uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale); @@ -901,12 +903,14 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { placedDoodadIds.insert(p.uniqueId); ft.tileUniqueIds.push_back(p.uniqueId); } - loadedDoodads++; + created++; } } + if (ft.m2InstanceIndex < pending->m2Placements.size()) { + return false; // More instances to create — yield + } LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ", - loadedDoodads, " instances (", ft.uploadedM2ModelIds.size(), " new models, ", - skippedDedup, " dedup skipped)"); + ft.m2InstanceIds.size(), " instances (", ft.uploadedM2ModelIds.size(), " new models)"); } ft.phase = FinalizationPhase::WMO_MODELS; return false; @@ -948,17 +952,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { } case FinalizationPhase::WMO_INSTANCES: { - // Create all WMO instances + load WMO liquids - if (wmoRenderer) { - int loadedWMOs = 0; - int loadedLiquids = 0; - int skippedWmoDedup = 0; - for (auto& wmoReady : pending->wmoModels) { + // Create WMO instances incrementally to avoid stalls on tiles with many WMOs. + if (wmoRenderer && ft.wmoInstanceIndex < pending->wmoModels.size()) { + constexpr size_t kWmoInstancesPerStep = 4; + size_t created = 0; + while (ft.wmoInstanceIndex < pending->wmoModels.size() && created < kWmoInstancesPerStep) { + auto& wmoReady = pending->wmoModels[ft.wmoInstanceIndex++]; if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { - skippedWmoDedup++; continue; } - uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation); if (wmoInstId) { ft.wmoInstanceIds.push_back(wmoInstId); @@ -966,8 +968,6 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { placedWmoIds.insert(wmoReady.uniqueId); ft.tileWmoUniqueIds.push_back(wmoReady.uniqueId); } - loadedWMOs++; - // Load WMO liquids (canals, pools, etc.) if (waterRenderer) { glm::mat4 modelMatrix = glm::mat4(1.0f); @@ -977,25 +977,21 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); for (const auto& group : wmoReady.model.groups) { if (!group.liquid.hasLiquid()) continue; - // Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava) if (group.flags & 0x2000) { uint16_t lt = group.liquid.materialId; uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4); if (basicType < 2) continue; } waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId); - loadedLiquids++; } } + created++; } } - if (loadedWMOs > 0 || skippedWmoDedup > 0) { - LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", - loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped"); - } - if (loadedLiquids > 0) { - LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids); + if (ft.wmoInstanceIndex < pending->wmoModels.size()) { + return false; // More WMO instances to create — yield } + LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", ft.wmoInstanceIds.size(), " instances"); } ft.phase = FinalizationPhase::WMO_DOODADS; return false; @@ -2213,10 +2209,16 @@ void TerrainManager::streamTiles() { return false; }; - // Enqueue tiles in radius around current tile for async loading + // Enqueue tiles in radius around current tile for async loading. + // Collect all newly-needed tiles, then sort by distance so the closest + // (most visible) tiles get loaded first. This is critical during taxi + // flight where new tiles enter the radius faster than they can load. { std::lock_guard lock(queueMutex); + struct PendingEntry { TileCoord coord; int distSq; }; + std::vector newTiles; + for (int dy = -loadRadius; dy <= loadRadius; dy++) { for (int dx = -loadRadius; dx <= loadRadius; dx++) { int tileX = currentTile.x + dx; @@ -2240,10 +2242,19 @@ void TerrainManager::streamTiles() { if (failedTiles.find(coord) != failedTiles.end()) continue; if (shouldSkipMissingAdt(coord)) continue; - loadQueue.push_back(coord); + newTiles.push_back({coord, dx*dx + dy*dy}); pendingTiles[coord] = true; } } + + // Sort nearest tiles first so workers service the most visible tiles + std::sort(newTiles.begin(), newTiles.end(), + [](const PendingEntry& a, const PendingEntry& b) { return a.distSq < b.distSq; }); + + // Insert at front so new close tiles preempt any distant tiles already queued + for (auto it = newTiles.rbegin(); it != newTiles.rend(); ++it) { + loadQueue.push_front(it->coord); + } } // Notify workers that there's work diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 84d8cb1c..dbfacd04 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7184,9 +7184,19 @@ void GameScreen::renderSettingsWindow() { saveSettings(); } } - if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { - if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); - saveSettings(); + { + bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled()); + if (!fsrActive && pendingWaterRefraction) { + // FSR was disabled while refraction was on — auto-disable + pendingWaterRefraction = false; + if (renderer) renderer->setWaterRefractionEnabled(false); + } + if (!fsrActive) ImGui::BeginDisabled(); + if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) { + if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + saveSettings(); + } + if (!fsrActive) ImGui::EndDisabled(); } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; From 9d1616a11bb1f68ec3551be5c04436b0787e9460 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:04:24 -0700 Subject: [PATCH 18/71] audio: stop precast sound on spell completion, failure, or interrupt Add AudioEngine::playSound2DStoppable() + stopSound() so callers can hold a handle and cancel playback early. SpellSoundManager::playPrecast() now stores the handle in activePrecastId_; stopPrecast() cuts the sound. playCast() calls stopPrecast() before playing the release sound, so the channeling audio never bleeds past cast time. SMSG_SPELL_FAILURE and SMSG_CAST_FAILED both call stopPrecast() so interrupted casts silence immediately. --- include/audio/audio_engine.hpp | 7 +++ include/audio/spell_sound_manager.hpp | 2 + src/audio/audio_engine.cpp | 68 ++++++++++++++++++++++++++- src/audio/spell_sound_manager.cpp | 14 +++++- src/game/game_handler.cpp | 12 +++++ 5 files changed, 100 insertions(+), 3 deletions(-) diff --git a/include/audio/audio_engine.hpp b/include/audio/audio_engine.hpp index 20015330..c6d5e723 100644 --- a/include/audio/audio_engine.hpp +++ b/include/audio/audio_engine.hpp @@ -45,6 +45,11 @@ public: bool playSound2D(const std::vector& wavData, float volume = 1.0f, float pitch = 1.0f); bool playSound2D(const std::string& mpqPath, float volume = 1.0f, float pitch = 1.0f); + // Stoppable 2D sound — returns a non-zero handle, or 0 on failure + uint32_t playSound2DStoppable(const std::vector& wavData, float volume = 1.0f); + // Stop a sound started with playSound2DStoppable (no-op if already finished) + void stopSound(uint32_t id); + // 3D positional sound playback bool playSound3D(const std::vector& wavData, const glm::vec3& position, float volume = 1.0f, float pitch = 1.0f, float maxDistance = 100.0f); @@ -70,8 +75,10 @@ private: ma_sound* sound; void* buffer; // ma_audio_buffer* - Keep audio buffer alive std::shared_ptr> pcmDataRef; // Keep decoded PCM alive + uint32_t id = 0; // 0 = anonymous (not stoppable) }; std::vector activeSounds_; + uint32_t nextSoundId_ = 1; // Music track state ma_sound* musicSound_ = nullptr; diff --git a/include/audio/spell_sound_manager.hpp b/include/audio/spell_sound_manager.hpp index 1933a7aa..d0273c82 100644 --- a/include/audio/spell_sound_manager.hpp +++ b/include/audio/spell_sound_manager.hpp @@ -45,6 +45,7 @@ public: // Spell casting sounds void playPrecast(MagicSchool school, SpellPower power); // Channeling/preparation + void stopPrecast(); // Stop precast sound early void playCast(MagicSchool school); // When spell fires void playImpact(MagicSchool school, SpellPower power); // When spell hits target @@ -96,6 +97,7 @@ private: // State tracking float volumeScale_ = 1.0f; bool initialized_ = false; + uint32_t activePrecastId_ = 0; // Handle from AudioEngine::playSound2DStoppable() // Helper methods bool loadSound(const std::string& path, SpellSample& sample, pipeline::AssetManager* assets); diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index 7fdcb952..f15b161a 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp @@ -288,11 +288,77 @@ bool AudioEngine::playSound2D(const std::vector& wavData, float volume, } // Track this sound for cleanup (decoded PCM shared across plays) - activeSounds_.push_back({sound, audioBuffer, decoded.pcmData}); + activeSounds_.push_back({sound, audioBuffer, decoded.pcmData, 0u}); return true; } +uint32_t AudioEngine::playSound2DStoppable(const std::vector& wavData, float volume) { + if (!initialized_ || !engine_ || wavData.empty()) return 0; + if (masterVolume_ <= 0.0f) return 0; + + DecodedWavCacheEntry decoded; + if (!decodeWavCached(wavData, decoded) || !decoded.pcmData || decoded.frames == 0) return 0; + + ma_audio_buffer_config bufferConfig = ma_audio_buffer_config_init( + decoded.format, decoded.channels, decoded.frames, decoded.pcmData->data(), nullptr); + bufferConfig.sampleRate = decoded.sampleRate; + + ma_audio_buffer* audioBuffer = static_cast(std::malloc(sizeof(ma_audio_buffer))); + if (!audioBuffer) return 0; + if (ma_audio_buffer_init(&bufferConfig, audioBuffer) != MA_SUCCESS) { + std::free(audioBuffer); + return 0; + } + + ma_sound* sound = static_cast(std::malloc(sizeof(ma_sound))); + if (!sound) { + ma_audio_buffer_uninit(audioBuffer); + std::free(audioBuffer); + return 0; + } + ma_result result = ma_sound_init_from_data_source( + engine_, audioBuffer, + MA_SOUND_FLAG_DECODE | MA_SOUND_FLAG_ASYNC | MA_SOUND_FLAG_NO_PITCH | MA_SOUND_FLAG_NO_SPATIALIZATION, + nullptr, sound); + if (result != MA_SUCCESS) { + ma_audio_buffer_uninit(audioBuffer); + std::free(audioBuffer); + std::free(sound); + return 0; + } + + ma_sound_set_volume(sound, volume); + if (ma_sound_start(sound) != MA_SUCCESS) { + ma_sound_uninit(sound); + ma_audio_buffer_uninit(audioBuffer); + std::free(audioBuffer); + std::free(sound); + return 0; + } + + uint32_t id = nextSoundId_++; + if (nextSoundId_ == 0) nextSoundId_ = 1; // Skip 0 (sentinel) + activeSounds_.push_back({sound, audioBuffer, decoded.pcmData, id}); + return id; +} + +void AudioEngine::stopSound(uint32_t id) { + if (id == 0) return; + for (auto it = activeSounds_.begin(); it != activeSounds_.end(); ++it) { + if (it->id == id) { + ma_sound_stop(it->sound); + ma_sound_uninit(it->sound); + std::free(it->sound); + ma_audio_buffer* buffer = static_cast(it->buffer); + ma_audio_buffer_uninit(buffer); + std::free(buffer); + activeSounds_.erase(it); + return; + } + } +} + bool AudioEngine::playSound2D(const std::string& mpqPath, float volume, float pitch) { if (!assetManager_) { LOG_WARNING("AudioEngine::playSound2D(path): no AssetManager set"); diff --git a/src/audio/spell_sound_manager.cpp b/src/audio/spell_sound_manager.cpp index 4c024b88..c72f6d7c 100644 --- a/src/audio/spell_sound_manager.cpp +++ b/src/audio/spell_sound_manager.cpp @@ -220,12 +220,22 @@ void SpellSoundManager::playPrecast(MagicSchool school, SpellPower power) { return; } - if (library) { - playSound(*library); + if (library && !library->empty() && (*library)[0].loaded) { + stopPrecast(); // Stop any previous precast still playing + float volume = 0.75f * volumeScale_; + activePrecastId_ = AudioEngine::instance().playSound2DStoppable((*library)[0].data, volume); + } +} + +void SpellSoundManager::stopPrecast() { + if (activePrecastId_ != 0) { + AudioEngine::instance().stopSound(activePrecastId_); + activePrecastId_ = 0; } } void SpellSoundManager::playCast(MagicSchool school) { + stopPrecast(); // Ensure precast doesn't overlap the cast sound switch (school) { case MagicSchool::FIRE: playSound(castFireSounds_); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5146e98d..7d63d4db 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2516,6 +2516,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // Spell failed mid-cast casting = false; currentCastSpellId = 0; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + ssm->stopPrecast(); + } + } break; case Opcode::SMSG_SPELL_COOLDOWN: handleSpellCooldown(packet); @@ -12368,6 +12373,13 @@ void GameHandler::handleCastFailed(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; + // Stop precast sound — spell failed before completing + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + ssm->stopPrecast(); + } + } + // Add system message about failed cast with readable reason int powerType = -1; auto playerEntity = entityManager.getEntity(playerGuid); From 38333df2604a97e9e19588ee1e744d744fed1af4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:14:06 -0700 Subject: [PATCH 19/71] tbc: fix spell cast format and NPC movement parsing for TBC 2.4.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMSG_CAST_SPELL: WotLK adds a castFlags(u8) byte after spellId that TBC 2.4.3 does not have. Add TbcPacketParsers::buildCastSpell() to omit it, preventing every spell cast from being rejected by TBC servers. CMSG_USE_ITEM: WotLK adds a glyphIndex(u32) field between itemGuid and castFlags that TBC 2.4.3 does not have. Add buildUseItem() override. SMSG_MONSTER_MOVE: WotLK adds a uint8 unk byte after the packed GUID (MOVEMENTFLAG2_UNK7 toggle) that TBC 2.4.3 does not have. Add parseMonsterMove() override to fix NPC movement parsing — without this, all NPC positions, durations, and waypoints parse from the wrong byte offset, making all NPC movement appear broken on TBC servers. --- include/game/packet_parsers.hpp | 6 ++ src/game/packet_parsers_tbc.cpp | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index f1466a1d..fea1f88a 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -287,6 +287,12 @@ public: bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override; bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override; network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; + // TBC 2.4.3 CMSG_CAST_SPELL has no castFlags byte (WotLK added it) + network::Packet buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) override; + // TBC 2.4.3 CMSG_USE_ITEM has no glyphIndex field (WotLK added it) + network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) override; + // TBC 2.4.3 SMSG_MONSTER_MOVE has no unk byte after packed GUID (WotLK added it) + bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index e4275640..956c5af0 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -497,6 +497,152 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa return false; } +// ============================================================================ +// TBC 2.4.3 SMSG_MONSTER_MOVE +// Identical to WotLK except WotLK added a uint8 unk byte immediately after the +// packed GUID (toggles MOVEMENTFLAG2_UNK7). TBC does NOT have this byte. +// Without this override, all NPC movement positions/durations are offset by 1 +// byte and parse as garbage. +// ============================================================================ +bool TbcPacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { + data.guid = UpdateObjectParser::readPackedGuid(packet); + if (data.guid == 0) return false; + // No unk byte here in TBC 2.4.3 + + if (packet.getReadPos() + 12 > packet.getSize()) return false; + data.x = packet.readFloat(); + data.y = packet.readFloat(); + data.z = packet.readFloat(); + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + packet.readUInt32(); // splineId + + if (packet.getReadPos() >= packet.getSize()) return false; + data.moveType = packet.readUInt8(); + + if (data.moveType == 1) { + data.destX = data.x; + data.destY = data.y; + data.destZ = data.z; + data.hasDest = false; + return true; + } + + if (data.moveType == 2) { + if (packet.getReadPos() + 12 > packet.getSize()) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } else if (data.moveType == 3) { + if (packet.getReadPos() + 8 > packet.getSize()) return false; + data.facingTarget = packet.readUInt64(); + } else if (data.moveType == 4) { + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.facingAngle = packet.readFloat(); + } + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.splineFlags = packet.readUInt32(); + + // TBC 2.4.3 SplineFlags animation bit is same as WotLK: 0x00400000 + if (data.splineFlags & 0x00400000) { + if (packet.getReadPos() + 5 > packet.getSize()) return false; + packet.readUInt8(); // animationType + packet.readUInt32(); // effectStartTime + } + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.duration = packet.readUInt32(); + + if (data.splineFlags & 0x00000800) { + if (packet.getReadPos() + 8 > packet.getSize()) return false; + packet.readFloat(); // verticalAcceleration + packet.readUInt32(); // effectStartTime + } + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + uint32_t pointCount = packet.readUInt32(); + if (pointCount == 0) return true; + if (pointCount > 16384) return false; + + bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; + if (uncompressed) { + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (packet.getReadPos() + 12 > packet.getSize()) return true; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } + if (packet.getReadPos() + 12 > packet.getSize()) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } else { + if (packet.getReadPos() + 12 > packet.getSize()) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } + + LOG_DEBUG("[TBC] MonsterMove: guid=0x", std::hex, data.guid, std::dec, + " type=", (int)data.moveType, " dur=", data.duration, "ms", + " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); + return true; +} + +// ============================================================================ +// TBC 2.4.3 CMSG_CAST_SPELL +// Format: castCount(u8) + spellId(u32) + SpellCastTargets +// WotLK 3.3.5a adds castFlags(u8) between spellId and targets — TBC does NOT. +// ============================================================================ +network::Packet TbcPacketParsers::buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) { + network::Packet packet(wireOpcode(LogicalOpcode::CMSG_CAST_SPELL)); + packet.writeUInt8(castCount); + packet.writeUInt32(spellId); + // No castFlags byte in TBC 2.4.3 + + if (targetGuid != 0) { + packet.writeUInt32(0x02); // TARGET_FLAG_UNIT + // Write packed GUID + uint8_t mask = 0; + uint8_t bytes[8]; + int byteCount = 0; + uint64_t g = targetGuid; + for (int i = 0; i < 8; ++i) { + uint8_t b = g & 0xFF; + if (b != 0) { + mask |= (1 << i); + bytes[byteCount++] = b; + } + g >>= 8; + } + packet.writeUInt8(mask); + for (int i = 0; i < byteCount; ++i) + packet.writeUInt8(bytes[i]); + } else { + packet.writeUInt32(0x00); // TARGET_FLAG_SELF + } + + return packet; +} + +// ============================================================================ +// TBC 2.4.3 CMSG_USE_ITEM +// Format: bag(u8) + slot(u8) + castCount(u8) + spellId(u32) + itemGuid(u64) + +// castFlags(u8) + SpellCastTargets +// WotLK 3.3.5a adds glyphIndex(u32) between itemGuid and castFlags — TBC does NOT. +// ============================================================================ +network::Packet TbcPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) { + network::Packet packet(wireOpcode(LogicalOpcode::CMSG_USE_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + packet.writeUInt8(0); // cast count + packet.writeUInt32(spellId); // on-use spell id + packet.writeUInt64(itemGuid); // full 8-byte GUID + // No glyph index field in TBC 2.4.3 + packet.writeUInt8(0); // cast flags + packet.writeUInt32(0x00); // SpellCastTargets: TARGET_FLAG_SELF + return packet; +} + network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) { network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST)); packet.writeUInt64(npcGuid); From 8561d5c58cfea1400018cdde44e678f3f92e6d4e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:20:37 -0700 Subject: [PATCH 20/71] tbc: fix gossip message quest parsing for TBC 2.4.3 SMSG_GOSSIP_MESSAGE quest entries in TBC 2.4.3 do not include questFlags(u32) or isRepeatable(u8) that WotLK 3.3.5a added. The WotLK default parser reads these 5 bytes, causing all quest titles in gossip dialogs to be shifted/corrupted on TBC servers. Add TbcPacketParsers::parseGossipMessage() which parses quest entries without those fields, fixing NPC quest list display. --- include/game/packet_parsers.hpp | 2 ++ src/game/packet_parsers_tbc.cpp | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index fea1f88a..35e90f6b 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -293,6 +293,8 @@ public: network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) override; // TBC 2.4.3 SMSG_MONSTER_MOVE has no unk byte after packed GUID (WotLK added it) bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; + // TBC 2.4.3 SMSG_GOSSIP_MESSAGE quests lack questFlags(u32)+isRepeatable(u8) (WotLK added them) + bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 956c5af0..72f2bbf1 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -497,6 +497,53 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa return false; } +// ============================================================================ +// TBC 2.4.3 SMSG_GOSSIP_MESSAGE +// Identical to WotLK except each quest entry lacks questFlags(u32) and +// isRepeatable(u8) that WotLK added. Without this override the WotLK parser +// reads those 5 bytes as part of the quest title, corrupting all gossip quests. +// ============================================================================ +bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessageData& data) { + if (packet.getSize() - packet.getReadPos() < 16) return false; + + data.npcGuid = packet.readUInt64(); + data.menuId = packet.readUInt32(); // TBC added menuId (Classic doesn't have it) + data.titleTextId = packet.readUInt32(); + uint32_t optionCount = packet.readUInt32(); + + data.options.clear(); + data.options.reserve(optionCount); + for (uint32_t i = 0; i < optionCount; ++i) { + GossipOption opt; + opt.id = packet.readUInt32(); + opt.icon = packet.readUInt8(); + opt.isCoded = (packet.readUInt8() != 0); + opt.boxMoney = packet.readUInt32(); + opt.text = packet.readString(); + opt.boxText = packet.readString(); + data.options.push_back(opt); + } + + uint32_t questCount = packet.readUInt32(); + data.quests.clear(); + data.quests.reserve(questCount); + for (uint32_t i = 0; i < questCount; ++i) { + GossipQuestItem quest; + quest.questId = packet.readUInt32(); + quest.questIcon = packet.readUInt32(); + quest.questLevel = static_cast(packet.readUInt32()); + // TBC 2.4.3: NO questFlags(u32) and NO isRepeatable(u8) here + // WotLK adds these 5 bytes — reading them from TBC garbles the quest title + quest.questFlags = 0; + quest.isRepeatable = 0; + quest.title = normalizeWowTextTokens(packet.readString()); + data.quests.push_back(quest); + } + + LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests"); + return true; +} + // ============================================================================ // TBC 2.4.3 SMSG_MONSTER_MOVE // Identical to WotLK except WotLK added a uint8 unk byte immediately after the From 4d1be18c18ab27e75103cd32a966d6c5eabd2d78 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:27:01 -0700 Subject: [PATCH 21/71] wmo: apply MOHD ambient color to interior group lighting Read the ambient color from the MOHD chunk (BGRA uint32) and store it on WMOModel as a normalized RGB vec3. Pass it through ModelData into the per-batch WMOMaterialUBO (replacing the unused pad[3] bytes, keeping the struct at 64 bytes). The GLSL interior branch now floors vertex colors against the WMO ambient instead of a hardcoded 0.5, so dungeon interiors respect the artist-specified ambient tint from the WMO root rather than always clamping to grey. --- assets/shaders/wmo.frag.glsl | 11 ++++++++++- assets/shaders/wmo.frag.spv | Bin 12456 -> 21268 bytes include/pipeline/wmo_loader.hpp | 1 + include/rendering/wmo_renderer.hpp | 5 ++++- src/pipeline/wmo_loader.cpp | 6 +++++- src/rendering/wmo_renderer.cpp | 4 ++++ 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index a4bae057..c2b3b1cd 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -29,6 +29,9 @@ layout(set = 1, binding = 1) uniform WMOMaterial { float heightMapVariance; float normalMapStrength; int isLava; + float wmoAmbientR; + float wmoAmbientG; + float wmoAmbientB; }; layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; @@ -185,7 +188,13 @@ void main() { } else if (unlit != 0) { result = texColor.rgb * shadow; } else if (isInterior != 0) { - vec3 mocv = max(VertColor.rgb, vec3(0.5)); + // WMO interior: vertex colors (MOCV) are pre-baked lighting from the artist. + // The MOHD ambient color tints/floors the vertex colors so dark spots don't + // go completely black, matching the WoW client's interior shading. + vec3 wmoAmbient = vec3(wmoAmbientR, wmoAmbientG, wmoAmbientB); + // Clamp ambient to at least 0.3 to avoid total darkness when MOHD color is zero + wmoAmbient = max(wmoAmbient, vec3(0.3)); + vec3 mocv = max(VertColor.rgb, wmoAmbient); result = texColor.rgb * mocv * shadow; } else { vec3 ldir = normalize(-lightDir.xyz); diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index 524dbd1ea425698858608248b93668dbc5ee5e21..3507f3c9915f392474e058da739b368c11de9e60 100644 GIT binary patch literal 21268 zcmZ{r2bf+}wT2HQlORnx33UR9G!Y0rKqeuOAPEU1fQl0TB>yA>{}iW$fS?l)v7m?w z3fMr!h6PYTL}XCGf`SdaR;;L?U;)K?-}n6In;hr4_dK(n{jG1Uz4qE`@3YT2lgXrw z_ncZ38x@-sTNKMC73H;cu`x^mH>GUnFI>9tz@gz)2OfIZK{{+%v>o+%HvJ~kD!q-K zKAPfmWP6O$k)x1fkpZM{}wR}S~AZR)p4&9A3#Wou+r-Jt`IdOjV+v&ge3h8n#Ct>)4- zjaB{UEjj+geTJ4Ea`3FR-7{xlv(Fa#^fm@)N_|V(mHqvLtA^C47Tc71@4(1#b7B9g z6B{ds`v>@dWcR7|5CSu5@+ zri0h^G|!vYGq`MNY40d@qHSe>lAB!YN;_0$-i|Q?eO+mrR2)Eieua&(3WKp-hh@!m z$2$ce%%tLl+Hvk+ZO;M^H`g^=^O}7_J;R28pIr3Np0TQb_>@xbD28bpeXCo|_LNH> z$L?*cb2#*la{MzFEnd_ZZVvV|T11~xa@yB4hDc+0WRSHB&vF-y*1(#^vgVNSr&KZA z3nP84o;KHx;cn|0I;qdl{e${$QpR?ThX$G}M_P?Rt(io|=qUT0+0(~*1L-%l>^BA6 z>}#xOHBadu>}|AIrt!SolWp6Q#f!>5?Ho1%5A^pgUD;?ciw&`!)#evbigI_cRmSt% z6uhR{vwF=iBb?EoE`2NQocuQf_a%d+!-LJf)x&E_d%K>SgL{S+Hr6)m)6T;^dR}k; zoZb~Z&A#E&#(DSZ27dlH@4nx_&!x(fidVpSxz?k!y*+*7>v$=8-$?KHWL(>;;98CI zn}hS3119Lba1GqbkwJ1EURX1g@vp_U1hMapwCt0`-Q7b?*VHj@p{yS|4h%Ne8rXauLvM7m zlg8)LQ9J>+qS@U)*ld&bdj{^jGH^SN2jO6IXr#p?j57tjyRj;_M9FOp_uu#Jlww=B zwar0pvv#tC#dmUfK8EpL^sHRnT7KeSf8Q{-Sx?{UwqK{?^z=1aWvwO``=Af^^j3A~ zDE32N(i}W-u+iqckL0{OS8Uxoa?%5X{pWa&_H$CZ59Ph*T5i*&1C5nUZsEZmZ}0Jb z-hZW?L#obu|CRjl{jK(WIkk+}0pDnE(8^-0c3$3l=C!1MXgt36TzCKKnr}N_?>YD- z9IL(U`>Gw^d#-&Jj+=qq`5=u_hP$u>tMV);o%s=p*gNv`2H9&#`dzdM!Y&-;Xav-*C>g`?TPC`&aU$ zRqrU@KhEG@Oc9;u1^8^37v~}Rs-Eud&0m!q#~w7R_&?0+S?&!G5!w(MU% z(#kRKMrRLKPtJB;-+?QiePv#giyxxb$M`w=DLjAMXCY^pF_>&$vsI4yB>IM&JBp{# z2b1K6kLc(cVKH<-1=0$auMn`NZ*^WH*HLiUmY2Upl~hB3|Lx zA|>Y;6V5*Rc`k%w*pl;E8O}cXZB^quljOFmah^qT(`uY&k=!md&hg|rYn*40++H=# z=e^uMHO^bD>tbzvuJ3_ZyXZ4& zy3fqIU0r+n`_As#q`%K3^WPd-K>R#ctfg~_Za!Xh&N{a1-|lD3dOvmhbs^~|H?g0& z)%&U2Zxl&Cxefi=c};@T?`|Z1*%uGixa^BZYFzfklQk~;!r~-e_Qh5;F8iVr&h^Rj z)%EcCvoYWRY4;qhkEgCZ{(g@c z&syp4w^`zE%dWtTzh7wWJ+cGH+H@k*`KGca>c&%Y1@DZ%HPWw>_}0ctZbx+Q7yBDu zyL(Mv`5DaG`$W6_w3&;0QO^F}%VP7i|Ms-@msj7R()F2v=Z=+4KjUfNA3yDx_x9+! zqjw@3mF?sb8&_U`+dYu~a7Eu2UH|RKW2hE1+>n=c;>0@S(JGh??S|N)c>N&CU0K07bEWb=+}eYC;Hz&Yb?jh zJU$FAimopA;pafwHj=7c(cOo4!s}z{X2Hv%z0P+^5O!KCoBx zZ-Toj-M&*9eCEtsk8`~3!2Fc^+VRvqYvr_0tFZe*&^LW}&ts#{M8D#mkz2&}W`ODG0H|mnHuWI{_kNp93&yUz|Zri)m-762a_3^nrjGnz? z&Od3}%elGtA4fNTbNzjVUCYFqj1hHwy#3Mpx0y6vn}gAJyzSH@oxAs#=e$?)JGs>F z9?$=5bl*3$JAMP*ca7+)(0$KH{~q*fues*91E{#`dJei=#Jqda&2bDd4|Sg}^}Me} z=bD>wu0!|Pmbjlrx7Lx2{~LJDwDG<8TlC#8J-3{X^?V$?C`P;DzcIz`?(QD9Z-Gw! z-@%uNr{(k>PH$T6B zqkI33PS9Un)9vqfZ~WD7nxOm58@uuEnV|c<8@v7e-i@xm-@DPZ`<)xz{QSm^uHA3k z==S#;H@bGeXY0D(vvu9?*}CqxY+d)eHM;RfYr66MZjD{t@7C!0``udC{cf%6cTdp$ zW{ur=ezQh5p5Lr>-EY?D`uojV*ZpRVZhya5>-t7)^62*W8?~T(FlpYug)gEo)4D0l(F?a7%b;IM|=(e*L@B}m-9Vj3H_Y&VPN}ev!C4IVCTQS z@;L%5r*CQ_XB{&iTl>5AM}vL0$=c5X^HZ+9He+~J%BRn9VkCWLgZU}@XtR&!seJm( z0lU`Px@gs7I|1w%(N6^1Kl*&IeNL_Fw*YJ(uW%<-oOHMsR2;Y3ICnCf@x9`=u;Qe{ zEvmSB-&5es#VdY`D^5Dxl8UqMlIos170$fP)4al;1~w;iaLn*a!H#EqR|EJShL-+rgVEOF-7lP%o|6dIDa{p_45hCaQ7aRKq>@NWu`z%^x zyPsbQmcIiZ@2%xvxw8@XUEdv)A+lzRw`|fT3G2-`uS>>hIOIgb1DVmSHi&r85^*`Kcgd%3>aE=AsexV~cJ_5j;{lo z&zaO|4E^KMi|ZfuuNu<7GiW5jwR*#7#C(yC{UZvs0)#4?yf1={?K-^;T|W2B)nK`+5a*USwz&u24t5;p zq`zZ$FI_|HWghyx1CcWi@jr`=N-XF2?(27gw?(YQb+qcK#k;`PBKMK&{cbq<)Z#s0 zx$Ft&^j>uLUgmT?SZ)Q9bKnNBeYKhU`)R$*UEBK*Idd1AljqotU~~EaZEE;Iuzb$3 z4}s;*Myz4t*rtXb20M|N{rU;8 zKJqzpJ_(jrylgyk-o)Hoi(4S{xf#jx}KtpMuw4yK8q7tz6di(_p!rRi6R# zQ=V1Y(nsDtb7^l!=2g1+d=_ksoK<&#^^s59&w=?V<7zkVZM5>n-4ENH;OR)-IX@4U zv)*5z^|IdbcOi1tTb%ja4K{AxImf_q8Bg7{PG8&XnJ<MMUxNo~`lI0F<5<5zw@=Q4--7j#UtgUOzXKabn`8WnR?gb~ zp7sxjwS63M+Kw z80}N&j;)WmK1M5N9%I=53YN>7{|zjsc-c6XbK8OT83_IB&(FW3%jcf?2Usrh+Ny5< zgfqT&b9tIpE;acVSZ;Ckj`VM^oZ@BUx_H+A2Sxi;NWSq*R9$hc^xFtdUu~Jg#$dCxhmp)U$8OJ%gzczuBzl;1lm!Ace^Q`m! z-3;vI{i|(LM9%wHoYybFN$zulCam-DB$1smQX}zq6K6@Z?<{?fVdx0nB@f`4+8Ix zXxp6g!F%Ztu=n0Ri1vdK??Zoosx3Jj3bqzGR}Mp$Q@o6kx*cBmcEM%+j)0Soek9m2 zlJ8MqedKdC91V6HuW+*}&hc_q9s{SZHshH8v9$6>RnNcU!2TPH|5l?-AKw?`-9M?j zZE~Cq)+g^1UFbfOe4p?Pi{Biuw!DMQ1)HDmHLihi^vRgo9rJrQxz@JU&fgs#kIi?y zJ>ZP*x5_*?`FyLK0G7*l+kCK>`&rwG$RfnpV&jZqKM6ca+&rgVfG($aVWP)|=k`Jr zeNINqeF3eUxjUxu9VfY;0yg(Nv(0@moP2U$0+vher-8l9UE8V1nTWB)$$crfp8GO% zImIivXH8CrckS{Hat6A62-Pd@uY&i&!Kx`wW$wQn)t zE5M!w*&kz!b0M63^1KKvr+6jLy!*crUZ1@CzY466@BZ4%;{sawtkrsO{aJ7^x;}Xp zTmsfdK4<)^!NzqxwfhcX>`}0`%;_~?=X5TT+Fu4IpEhvK(^=eo6j zJ-U75GtW1G?dLqTo6DuN@`>|Cu=9%kCa^y1tGHKy^^s59H-i&byKyh4l~0_vfa~>p zE4n_Z-`l|Y$ftf+fsJeZv|GQ}-d@>ug46G6B=_e#z~1K{l8$;fF zucVdBn!FS2np{U~j4@)o3oM^Cc{f;2@iJFo{NIBz3K{?RqRS~>@z4EoJ-jjXOWrqt zy^n=f%X!xF3L%&$u^&Cg8Pu<@+q*KQqLvl^=II%rTbIgPb0?i{<{rq{M6@mu$+A|wp{(3dr76A zM(ep}TYu)yM^7#908czKJ_jeC{O<(IDPCFkwgbeTl6o2?VEScFM;)uPpmJ4|F2m0!fT7)eKkM7g)_!i;I(Cp zuY&C>@3*{X!Pmf!tu614Uk5u+e}AXlv&MHw=X*E0_SF7O1pXPfc?ARQJm|)@Aw{^ zy#C*zRoCBl;D^ATn>q7*_j(vkKJQ-N2g~K%>jz*j-@UZ`4=hn%PC%tFUL95MiJQF5?<#HZOuKIbO^}zeLcKX=YzuD2A9H)Tm_wrP9eex{Y1gwv| z{rwvsxvb%)VAt?E#xdp?V{Qf~pEcYZET?!mU#E~UwuINmzX8k{vJKe3?~CLN*%r?J z&dq-Q9iYDE=HDA>&)l{Hn{V{(!TP9Mw;jOl$?V~2=yLuIVe*&`mX9Qlo#2v(;}~1t z)JS{A-39DEh`uYj`_KEt=k0Fj`p9P=?+!M;`&hei{rf5T#F+uEp9OoM>y!PqCs-f( z?6$w$$iwuv}_%1bAYNj)ap>jgA7#DP9w6bTqs^#!iin0n5)q zGUsE#^%@Dmm85{WrVv*XCKEZr(muyTB8lt8?JwKZ4JG_{`NGd7Om(9pyZ* zm(Nvg$0PF*pR3~3=mfC$U(T%)(VesRw)2^aqK|p`yw~nKrSXkr%*1*D*ymSbos6DX z3&1BK`XrY2dMsnA=lO4&dY*!-`Mcv?!BZ=} zlXk`g{eVh$Tz%c|V*U0&tjnIX#=hAAowQjzDdBzyy)@q;lek$kR zm$*07_d<+iU$NtwPsV*-WwUkM=hGg9cscIDwDz^lxQBp^k#T2&^sqf2k5cBLp9MAFRBJ$aP$Aj(b6>c6_&UxgS=QBrNZN`Z2 ziC}FTBQIhE$MBuVn#pU=*bBh!C3DvABt%aC>@zvn(Dig3Y*VWjfUVUi{oP9^BXX`` z_K2MQvqxM{^}T70XPbBn!LCi>Edt9Wo;ErAXRo_2GoMqy&L`htOTcoAk$=-iy?&qi zcgtS>UpRBwme%`md)lSQ)SCXnn(kVjRq2lHdY^%ujyzE5XI7kbFqV3JUj)|o!OHi= zU^#sqM?JnT0oy16?2aJWp4E-Cy4O#?;4roToNp zxks9aT+Z-ru>HKZS0bwreXO^()Y}^EjCk#ccotgcX%*g#c6x=a?@owovI}j_$R2Rk zM4#cB-U(heK_8u;IsZ<(OqUeoozqo#L)@2%ve&X!IL9pxX-VkfoFXN1W9VdJ1Jg~X#k7#qewTL;o_S&86 zMYOL(UV&VQ7|-+QRfxR)?hW(TX-x&HOzqf()Nq*`X|7vi3{I{da z<;;Eu*zx4;e-*8q*!y!g#8{isUWd59^W1-D#mRe_W9IcPaC>jH|9$-3mEH4Bn|mwI z`1gRdE9SKw?Ryd9Yje-tK>I$#J$F5lao%6?c{lg~`g;+1=ir$lmlz)ePmJ*)IQe|@ zegrJ{VZ<0W(#koexqTEdZgRK@tdGAtaesXbk<0u(UUBm2^9ith;`2$coZ`AW&drE5 z^?r0rn%@RCjyA`*g;p+Od=@;dvfWOr zuI*0RFCceSy1Czt=##&Hy$7t%-ALY@z66$^gk=AJ8SH-4W)64I%8Bo#y$|tyy$qc!;kVvL*FPy5k6P;uX+^(VL=bC)w z9n1Rv0CD}I{}AkYSZD1&Lge(bw%X+McRhcM)YtPT==x+me_Hv-XFVSQ%h%WQXXviy zBS_X$E;ac%_^O)z3$Qg?PoCEBmx!Ex_SYuoyj^eWU~4?r_*aPQly}-+SKPj|*+X*m zJ!Jh-r{92`XY}8Kt)ux^x8EW9$Q$p`ipzW+M|X{*{~m14+24Nv>mzUf$7tpBx9(3O z^}7EN-7!-4KUF^Rsr#S7^7Xp^1>L%9OD<1=>$yCQuAh8-{t9+IQvbh!^^-T>Curs3 z|97x+i2jcW`ajX_lllJ(tdD%g_%~R7C6edTf567p=D5$$$~lhfun`AieVsN&w@%4p z61shI-c3fAQ*yp`fcYt(`^GbdevYR>d*W>Z?!h$_(Kkc4b7F4}woh{10xVZb^(@#Dou4vS;~7Ig-|e*d-v7T@pJk^2 literal 12456 zcmaKy3ACQm6^4I8Lc|b65>_Mq`P_ za*gF1XD-#Kp6-n$VH$9qt9I4C8~G6ETe9E%*jWTUYiU00&N{tVn03(~(2g1NDMW9i0EXnh8BG);~W6=&c0*+;)+ zYMj~0b*pjqkz1+8EnT=(Yh35T^{R2^Y2P(#oa4#$t8vQ~ZeWe;TDbLV+;W8*Qsd0k zcoujp^|1!d&H9{1+aGcM(bul&kABClu08!-3+tQyu8TQzN46*aU=2Lh!@5Q{AD=pB zEjnu0(a)~+e(LsXMbc01yZy|q-cQ|rZAkjb{kLC7UY+3ddmM>h*7n63m$hA3YOZcPB~S1g z_*)}u*N^zt#z(Fvx_jRKj<4?dQg^Hk(8bB82YO%heu(k4tM^C#%@ci4v9C&$!Nt!w zTOy4{+X&;dfcmNO*iXF`aa{8pN9!2oGqUL3JsH<{6Of+sU)nr*PDJne>h&X|d%;~j ztM^vXPeyMv+FJT>6MAONy)MT5K3zUGbNeZ}c^bpq)IIldj%AF~5o@jg8MNBuW1j|g zk42vic1`r3L#w}ICih#wjYey$YyL1uo3*Gv(OvTe81ysVBgIM1eAOQfA`Iut7pbL(kK3VR{G;D#+w8tRI@hf z-l1|ssG#d|B)W5sB+d+Yo-vunW$51Pu`fjD{%G!rx6u2ZHMy#r^L(f3-_{!c#pqYh zn$^5tzDA!kX;Ra_GeyGQ)R(FB-c5a3i`ZywN#0%2^^tcSGtNrr&po?yvtJ+dMx$-( z*oRhn>*(m4pwD>Xi{?B=SN)%D<~as^`0x{|dTtBmuUSuZ-$8Q8>og8xeP0csYTnDI z!>7jaJp;};M3U1q4|AjS@#xdhM_lyws2>u`vQ}nV$NdV%br}WgIQv0*_rFl|{n6!K zEc$^(zl>h4(;?{kDB&i6^;5cWX7rl~cHgXD`smY*TUgtqV%r!z0hy@4nUi~LH9Gb{ zx|KcG6Kt$)i%mc8yVW7R>*f}H4Y1so%ycbUpT*eZ*F@x;zxd~6tiE95{}VoT+b@`h zxwKF`?bb|R`;JEQy)yt@eUs9fljn0FSpHUg%yAvCoOix?tq1loFKz20a^@u-&79|8 z9}4zfoege9jBo9R!D&0ItjES+Iq%$Xn-tD_cQ^c8=S{)-YMWZP&A_g;Hs_x_Y@NUT zc4YqgSQGutG3&Gi*t+%Wh@5p68{0Y`05^Rm=f5&*B^q&BB&mTs6bKxd}?W4_oF^Sg4e6{@ukuzWMICyJxgcvb@-(^Q5 z?i1f}=HE_?W5Dvea^Bp-Q^0bHkB#?2*5^0~-+|MKssHg{`^aa_P5|>)t(o?j#rG7j zIcawu-!M;~ufY$&{S4hX*zQgr_i!87e&%3&xv6ka%%H=b+D(Vo=J%%Y&j$NgCv9gU za*iQ(eat~!K6lQ!VB>FqB%b$f_SkvY-3NYKW{;f@Cw~rkd$unC%S{7&&&gegZd~_( zc5PG0%{k3L*B*T)*#61sQm}o86<_V6bqDgRP5_`OF8~Pn)^>JK#aEzXA3p_jcNc(dE2Ha|RZG<@sx_=OZZAUYq;) zQCj!0ZSLc(%YE$Leja(X*xV0euo*+!pT1)ojjazp+Fq&I+-I%Wyf?MIR%~~XyL-AF zUAw|M-0jc5H3&`#V^E0x@$Be1fi@w(S4U!SYJZ%s;@M8SgOXXzu#uK3Y`le(QOM zcfr|vA~6?(^-&V@Yj9%j2X+kotnW9pu7PdN#8}4F?@YwLuOs^A{b?KD?eVR?Y2fT< zoYAb4-*Z#pyoWpsc?V2`lh1d?bg*2$GtLJ4RPQ73S;#LCa}?)JI2U{llC?e`EN4FE zY8?C6x30^Zdpl$7gs=UK>u*whllw(rb9Wym_Ze{V$^BxmTynnz>|^fQW+HOtAbl_rc9z`Ft17 z1_f5X4^}9=omU>sTdKH*j&JS?`mNCSu4*a|9ium`k7MZ53z2gSadKM|Y|iH981b>q z7;Axz;kf$S-!b~q`Z$I@eGoav5N96!!H$u6#K$&c3;-L$arL*qW2{4)G4vUT$T@~M zbzcwcTJqPN+xjT-nOpkVX52wwPbB(?V*m3haV^y#|^0|+CfaSL& zPVS@C&>crx*0VQQ-Y4AZg)@h&=NfSOYI8mJE$^PS(4C8RdB<{Ht)q3--UeqatzYU@ zUkmG_?%G=?+w^hWoqK+Rx_9((U9*P=fnC4ph;g#_ZL`LQVRP?$p7ghWz7MvC^F8MG z!6US;k#pDqUR!?e?FhCe{vI@zG4*lGd>7be%*ka;zfgbGs1i+_YzI7ZqRUrro*4c5$(-4yWG?#Cgv_p9ywP zPD7lBcfloa^0{*^1sg*?^|~Bvz5Ms#)aweceCl;2SWfXty<(pQ|9$%`V_bvXF{aTs zV_XX-pBUGH2oW3`Z!nn z=$Bl71FoOR+tB3{pX8b|c{{wEYpAbhGH32iu;c0D{W6i(^JVKD?0!3j+&qWg3;KKZ z>UWWMQhblb*E`EQ&VI(J->crQ@jVK=_wO!<{fv_}x*Po6b8`=zeAeh*u$aj%?I0W45B^nvj@SBKO4UOK6?mWTfW;CfaQ#nzK?;^xBgrCad>U% z+YYv`eEz%Y6JTR%%RBS8U~}}}9khD}eRrl-zk_qU@ksW_lW_8>)l*8TNhj0-Su_M=yfq@;-VQ?Bn}L z+e?U?dr_Rb>ou_Xc+a_Bxx3x~dv|Hi+P+zQp&Qy$_qV~;PkZjNcZ;ugnf7-oTgU&8 z@LsWbhTj6~yAVnJ-v?X&g-GiE0i1m5{~=gT@o^5qynjAI(I@l#7@T=(i{Gch z{=Ylf-z$UVBZ;*NTw*x~W9vJ$ygz$_tzY()_ew7~`?v?Sr`~IVt-tovd#&PYy|vft z-M84Rw|;$))NXCCwVQ@5d#XR2d}=oUET{OGQ@wTr;q`HxtkXJRd25q7t_QXb+LHG$ zu groups; glm::vec3 boundingBoxMin; glm::vec3 boundingBoxMax; + glm::vec3 wmoAmbientColor{0.5f, 0.5f, 0.5f}; // From MOHD, used for interior lighting bool isLowPlatform = false; // Doodad templates (M2 models placed in WMO, stored for instancing) diff --git a/src/pipeline/wmo_loader.cpp b/src/pipeline/wmo_loader.cpp index 22a4df42..076e4579 100644 --- a/src/pipeline/wmo_loader.cpp +++ b/src/pipeline/wmo_loader.cpp @@ -109,7 +109,11 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.nDoodadDefs = read(wmoData, offset); model.nDoodadSets = read(wmoData, offset); - [[maybe_unused]] uint32_t ambColor = read(wmoData, offset); // Ambient color (BGRA) + uint32_t ambColor = read(wmoData, offset); // Ambient color (BGRA) + // Unpack BGRA bytes to normalized [0,1] RGB + model.ambientColor.r = ((ambColor >> 16) & 0xFF) / 255.0f; + model.ambientColor.g = ((ambColor >> 8) & 0xFF) / 255.0f; + model.ambientColor.b = ((ambColor >> 0) & 0xFF) / 255.0f; [[maybe_unused]] uint32_t wmoID = read(wmoData, offset); model.boundingBoxMin.x = read(wmoData, offset); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 51d8c2a2..4d52fd76 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -414,6 +414,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { modelData.id = id; modelData.boundingBoxMin = model.boundingBoxMin; modelData.boundingBoxMax = model.boundingBoxMax; + modelData.wmoAmbientColor = model.ambientColor; { glm::vec3 ext = model.boundingBoxMax - model.boundingBoxMin; float horiz = std::max(ext.x, ext.y); @@ -681,6 +682,9 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { matData.heightMapVariance = mb.heightMapVariance; matData.normalMapStrength = normalMapStrength_; matData.isLava = mb.isLava ? 1 : 0; + matData.wmoAmbientR = modelData.wmoAmbientColor.r; + matData.wmoAmbientG = modelData.wmoAmbientColor.g; + matData.wmoAmbientB = modelData.wmoAmbientColor.b; if (matBuf.info.pMappedData) { memcpy(matBuf.info.pMappedData, &matData, sizeof(matData)); } From 1c967e9628e9d8d563aadf79ea7d6ff4f57d9107 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:30:45 -0700 Subject: [PATCH 22/71] tbc: fix SMSG_MAIL_LIST_RESULT parsing for TBC 2.4.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TBC 2.4.3 differs from WotLK in four ways: - Header: uint8 count only (WotLK: uint32 totalCount + uint8 shownCount), so the WotLK parser was reading 4 garbage bytes before the count - No extra unknown uint32 between itemTextId and stationery in each entry - Attachment item GUID: full uint64 (WotLK uses uint32 low GUID) - Attachment enchants: 7 × uint32 id only (WotLK: 7 × {id+duration+charges}) The resulting mis-parse would scramble subject/money/cod/flags for every mail entry and corrupt all attachment reads. Add TbcPacketParsers::parseMailList with the correct TBC 2.4.3 format. --- include/game/packet_parsers.hpp | 3 ++ src/game/packet_parsers_tbc.cpp | 88 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 35e90f6b..4c7edb11 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -295,6 +295,9 @@ public: bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; // TBC 2.4.3 SMSG_GOSSIP_MESSAGE quests lack questFlags(u32)+isRepeatable(u8) (WotLK added them) bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; + // TBC 2.4.3 SMSG_MAIL_LIST_RESULT: uint8 count (not uint32+uint8), no body field, + // attachment uses uint64 itemGuid (not uint32), enchants are 7×u32 id-only (not 7×{id+dur+charges}) + bool parseMailList(network::Packet& packet, std::vector& inbox) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 72f2bbf1..55ad50fa 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -889,5 +889,93 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery return true; } +// ============================================================================ +// TbcPacketParsers::parseMailList — TBC 2.4.3 SMSG_MAIL_LIST_RESULT +// +// Differences from WotLK 3.3.5a (base implementation): +// - Header: uint8 count only (WotLK: uint32 totalCount + uint8 shownCount) +// - No body field — subject IS the full text (WotLK added body when mailTemplateId==0) +// - Attachment item GUID: full uint64 (WotLK: uint32 low GUID) +// - Attachment enchants: 7 × uint32 id only (WotLK: 7 × {id+duration+charges} = 84 bytes) +// - Header fields: cod + itemTextId + stationery (WotLK has extra unknown uint32 between +// itemTextId and stationery) +// ============================================================================ +bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector& inbox) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 1) return false; + + uint8_t count = packet.readUInt8(); + LOG_INFO("SMSG_MAIL_LIST_RESULT (TBC): count=", (int)count); + + inbox.clear(); + inbox.reserve(count); + + for (uint8_t i = 0; i < count; ++i) { + remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 2) break; + + uint16_t msgSize = packet.readUInt16(); + size_t startPos = packet.getReadPos(); + + MailMessage msg; + if (remaining < static_cast(msgSize) + 2) { + LOG_WARNING("[TBC] Mail entry ", i, " truncated"); + break; + } + + msg.messageId = packet.readUInt32(); + msg.messageType = packet.readUInt8(); + + switch (msg.messageType) { + case 0: msg.senderGuid = packet.readUInt64(); break; + default: msg.senderEntry = packet.readUInt32(); break; + } + + msg.cod = packet.readUInt32(); + packet.readUInt32(); // itemTextId + // NOTE: TBC has NO extra unknown uint32 here (WotLK added one between itemTextId and stationery) + msg.stationeryId = packet.readUInt32(); + msg.money = packet.readUInt32(); + msg.flags = packet.readUInt32(); + msg.expirationTime = packet.readFloat(); + msg.mailTemplateId = packet.readUInt32(); + msg.subject = packet.readString(); + // TBC has no separate body field at all + + uint8_t attachCount = packet.readUInt8(); + msg.attachments.reserve(attachCount); + for (uint8_t j = 0; j < attachCount; ++j) { + MailAttachment att; + att.slot = packet.readUInt8(); + uint64_t itemGuid = packet.readUInt64(); // full 64-bit GUID (TBC) + att.itemGuidLow = static_cast(itemGuid & 0xFFFFFFFF); + att.itemId = packet.readUInt32(); + // TBC: 7 × uint32 enchant ID only (no duration/charges per slot) + for (int e = 0; e < 7; ++e) { + uint32_t enchId = packet.readUInt32(); + if (e == 0) att.enchantId = enchId; + } + att.randomPropertyId = packet.readUInt32(); + att.randomSuffix = packet.readUInt32(); + att.stackCount = packet.readUInt32(); + att.chargesOrDurability = packet.readUInt32(); + att.maxDurability = packet.readUInt32(); + packet.readUInt32(); // current durability (separate from chargesOrDurability) + msg.attachments.push_back(att); + } + + msg.read = (msg.flags & 0x01) != 0; + inbox.push_back(std::move(msg)); + + // Skip any unread bytes within this mail entry + size_t consumed = packet.getReadPos() - startPos; + if (consumed < static_cast(msgSize)) { + packet.setReadPos(startPos + msgSize); + } + } + + return !inbox.empty(); +} + } // namespace game } // namespace wowee From b4f744d0005cce49cee5ed9fdc515fe984424de6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:34:02 -0700 Subject: [PATCH 23/71] tbc: fix combat damage parsing for TBC 2.4.3 TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE and SMSG_SPELLNONMELEEDAMAGELOG send full uint64 GUIDs for attacker/target, while WotLK 3.3.5a uses packed (variable-length) GUIDs. Using the WotLK reader on TBC packets consumes 1-8 bytes where a fixed 8 are expected, shifting all subsequent reads and producing completely wrong damage/absorbed/resisted values. Add TbcPacketParsers overrides that read plain uint64 GUIDs. Also note that TBC SMSG_SPELLNONMELEEDAMAGELOG lacks the WotLK overkill field. --- include/game/packet_parsers.hpp | 4 ++ src/game/packet_parsers_tbc.cpp | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 4c7edb11..6fb9b8ae 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -298,6 +298,10 @@ public: // TBC 2.4.3 SMSG_MAIL_LIST_RESULT: uint8 count (not uint32+uint8), no body field, // attachment uses uint64 itemGuid (not uint32), enchants are 7×u32 id-only (not 7×{id+dur+charges}) bool parseMailList(network::Packet& packet, std::vector& inbox) override; + // TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE uses full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override; + // TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG uses full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 55ad50fa..bef4b411 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -977,5 +977,75 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector(packet.readUInt32()); + data.subDamageCount = packet.readUInt8(); + + for (uint8_t i = 0; i < data.subDamageCount; ++i) { + SubDamage sub; + sub.schoolMask = packet.readUInt32(); + sub.damage = packet.readFloat(); + sub.intDamage = packet.readUInt32(); + sub.absorbed = packet.readUInt32(); + sub.resisted = packet.readUInt32(); + data.subDamages.push_back(sub); + } + + data.victimState = packet.readUInt32(); + data.overkill = static_cast(packet.readUInt32()); + + if (packet.getReadPos() < packet.getSize()) { + data.blocked = packet.readUInt32(); + } + + LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseSpellDamageLog — TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG +// +// 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; + + 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(); + + uint8_t periodicLog = packet.readUInt8(); + (void)periodicLog; + 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; + + LOG_INFO("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); + return true; +} + } // namespace game } // namespace wowee From 63d82003030c435d56ad6239c310cc5fc29aec2e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:36:12 -0700 Subject: [PATCH 24/71] tbc: fix heal log GUID parsing and route combat through virtual dispatch Add TbcPacketParsers::parseSpellHealLog override using full uint64 GUIDs (TBC) instead of packed GUIDs (WotLK). Route handleAttackerStateUpdate, handleSpellDamageLog, and handleSpellHealLog through the virtual packetParsers_ interface so expansion-specific overrides are actually called. Previously the game handler bypassed virtual dispatch with direct static parser calls, making all three TBC overrides dead code. --- include/game/packet_parsers.hpp | 7 +++++++ src/game/game_handler.cpp | 6 +++--- src/game/packet_parsers_tbc.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 6fb9b8ae..4dd25170 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -93,6 +93,11 @@ public: return SpellDamageLogParser::parse(packet, data); } + /** Parse SMSG_SPELLHEALLOG */ + virtual bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { + return SpellHealLogParser::parse(packet, data); + } + // --- Spells --- /** Parse SMSG_INITIAL_SPELLS */ @@ -302,6 +307,8 @@ public: bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override; // TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG uses full uint64 GUIDs (WotLK uses packed GUIDs) bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; + // TBC 2.4.3 SMSG_SPELLHEALLOG uses full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override; }; /** diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7d63d4db..28e78cbf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12079,7 +12079,7 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { AttackerStateUpdateData data; - if (!AttackerStateUpdateParser::parse(packet, data)) return; + if (!packetParsers_->parseAttackerStateUpdate(packet, data)) return; bool isPlayerAttacker = (data.attackerGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); @@ -12141,7 +12141,7 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { void GameHandler::handleSpellDamageLog(network::Packet& packet) { SpellDamageLogData data; - if (!SpellDamageLogParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellDamageLog(packet, data)) return; bool isPlayerSource = (data.attackerGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); @@ -12158,7 +12158,7 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { void GameHandler::handleSpellHealLog(network::Packet& packet) { SpellHealLogData data; - if (!SpellHealLogParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellHealLog(packet, data)) return; bool isPlayerSource = (data.casterGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index bef4b411..b4d803a9 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1047,5 +1047,29 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL return true; } +// ============================================================================ +// TbcPacketParsers::parseSpellHealLog — TBC 2.4.3 SMSG_SPELLHEALLOG +// +// 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; + + 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(); + data.isCrit = (critFlag != 0); + } + + LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); + return true; +} + } // namespace game } // namespace wowee From 6d21f77d328a621f5120ddf5f597a7aec88a7e4b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:38:14 -0700 Subject: [PATCH 25/71] game: route aura/spell-list parsing through virtual packet dispatch AuraUpdateParser and InitialSpellsParser were called as static functions in the game handler, bypassing the expansion-specific overrides added to TbcPacketParsers. Switch them to packetParsers_->parseAuraUpdate() and packetParsers_->parseInitialSpells() so TBC 2.4.3 servers get the correct parser for each. --- src/game/game_handler.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 28e78cbf..0e167f97 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12332,7 +12332,7 @@ float GameHandler::getSpellCooldown(uint32_t spellId) const { void GameHandler::handleInitialSpells(network::Packet& packet) { InitialSpellsData data; - if (!InitialSpellsParser::parse(packet, data)) return; + if (!packetParsers_->parseInitialSpells(packet, data)) return; knownSpells = {data.spellIds.begin(), data.spellIds.end()}; @@ -12525,7 +12525,7 @@ void GameHandler::handleCooldownEvent(network::Packet& packet) { void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { AuraUpdateData data; - if (!AuraUpdateParser::parse(packet, data, isAll)) return; + if (!packetParsers_->parseAuraUpdate(packet, data, isAll)) return; // Determine which aura list to update std::vector* auraList = nullptr; From 1b2c7f595ee69809e0a9518f95e1c8fc14d1d4fa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:44:07 -0700 Subject: [PATCH 26/71] =?UTF-8?q?classic:=20fix=20SMSG=5FCREATURE=5FQUERY?= =?UTF-8?q?=5FRESPONSE=20=E2=80=94=20no=20iconName=20field=20in=201.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE has no iconName CString between subName and typeFlags. The TBC/WotLK parser was reading the typeFlags uint32 bytes as the iconName string, then reading the remaining bytes as typeFlags — producing garbage creature type/family/rank values and corrupting target frame display for all creatures on Classic servers. Add ClassicPacketParsers::parseCreatureQueryResponse without the iconName read, and route the game handler through virtual dispatch so the override is called. --- include/game/packet_parsers.hpp | 9 +++++++ src/game/game_handler.cpp | 2 +- src/game/packet_parsers_classic.cpp | 37 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 4dd25170..03da989d 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -127,6 +127,13 @@ public: return NameQueryResponseParser::parse(packet, data); } + // --- Creature Query --- + + /** Parse SMSG_CREATURE_QUERY_RESPONSE */ + virtual bool parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) { + return CreatureQueryResponseParser::parse(packet, data); + } + // --- Item Query --- /** Build CMSG_ITEM_QUERY_SINGLE */ @@ -339,6 +346,8 @@ public: bool parseCastFailed(network::Packet& packet, CastFailedData& data) override; bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; + // Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName string that TBC/WotLK include + bool parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) override; bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0e167f97..0dfe66b7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -9578,7 +9578,7 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { void GameHandler::handleCreatureQueryResponse(network::Packet& packet) { CreatureQueryResponseData data; - if (!CreatureQueryResponseParser::parse(packet, data)) return; + if (!packetParsers_->parseCreatureQueryResponse(packet, data)) return; pendingCreatureQueries.erase(data.entry); diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 2e335af1..60a282dc 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1309,5 +1309,42 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai return true; } +// ============================================================================ +// ClassicPacketParsers::parseCreatureQueryResponse +// +// Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName CString field +// that TBC 2.4.3 and WotLK 3.3.5a include between subName and typeFlags. +// Without this override, the TBC/WotLK parser reads typeFlags bytes as the +// iconName string, shifting typeFlags/creatureType/family/rank by 1-4 bytes. +// ============================================================================ +bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet, + CreatureQueryResponseData& data) { + data.entry = packet.readUInt32(); + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + data.name = ""; + return true; + } + + data.name = packet.readString(); + packet.readString(); // name2 + packet.readString(); // name3 + packet.readString(); // name4 + data.subName = packet.readString(); + // NOTE: NO iconName field in Classic 1.12 — goes straight to typeFlags + if (packet.getReadPos() + 16 > packet.getSize()) { + LOG_WARNING("[Classic] Creature query: truncated at typeFlags (entry=", data.entry, ")"); + return true; + } + data.typeFlags = packet.readUInt32(); + data.creatureType = packet.readUInt32(); + data.family = packet.readUInt32(); + data.rank = packet.readUInt32(); + + LOG_DEBUG("[Classic] Creature query: ", data.name, " type=", data.creatureType, + " rank=", data.rank); + return true; +} + } // namespace game } // namespace wowee From 921c83df2ed9eba31df14753acf91cf28568e857 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:46:18 -0700 Subject: [PATCH 27/71] =?UTF-8?q?tbc:=20fix=20SMSG=5FCAST=5FRESULT=20?= =?UTF-8?q?=E2=80=94=20no=20castCount=20prefix=20in=20TBC=202.4.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TBC 2.4.3 SMSG_CAST_RESULT sends spellId(u32) + result(u8) = 5 bytes. WotLK 3.3.5a added a castCount(u8) prefix making it 6 bytes. Without this fix the WotLK parser was reading spellId[0] as castCount, then the remaining 3 spellId bytes plus result byte as spellId (wrong), and then whatever follows as result — producing incorrect failure messages and potentially not clearing the cast bar on TBC. Add TbcPacketParsers::parseCastResult override and a virtual base method, then route SMSG_CAST_RESULT through virtual dispatch in the game handler. --- include/game/packet_parsers.hpp | 15 +++++++++++++++ src/game/game_handler.cpp | 20 ++++++++++---------- src/game/packet_parsers_tbc.cpp | 15 +++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 03da989d..8885865e 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -110,6 +110,19 @@ public: return CastFailedParser::parse(packet, data); } + /** Parse SMSG_CAST_RESULT header (spellId + result), expansion-aware. + * WotLK: castCount(u8) + spellId(u32) + result(u8) + * TBC/Classic: spellId(u32) + result(u8) (no castCount prefix) + */ + virtual bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) { + // WotLK default: skip castCount, read spellId + result + if (packet.getSize() - packet.getReadPos() < 6) return false; + packet.readUInt8(); // castCount + spellId = packet.readUInt32(); + result = packet.readUInt8(); + return true; + } + /** Parse SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL */ virtual bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) { return AuraUpdateParser::parse(packet, data, isAll); @@ -307,6 +320,8 @@ public: bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; // TBC 2.4.3 SMSG_GOSSIP_MESSAGE quests lack questFlags(u32)+isRepeatable(u8) (WotLK added them) bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; + // TBC 2.4.3 SMSG_CAST_RESULT: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix + bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) override; // TBC 2.4.3 SMSG_MAIL_LIST_RESULT: uint8 count (not uint32+uint8), no body field, // attachment uses uint64 itemGuid (not uint32), enchants are 7×u32 id-only (not 7×{id+dur+charges}) bool parseMailList(network::Packet& packet, std::vector& inbox) override; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0dfe66b7..86f83351 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1742,28 +1742,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Cast result (WotLK extended cast failed) ---- - case Opcode::SMSG_CAST_RESULT: - // WotLK: uint8 castCount + uint32 spellId + uint8 result [+ optional extra] + case Opcode::SMSG_CAST_RESULT: { + // WotLK: castCount(u8) + spellId(u32) + result(u8) + // TBC/Classic: spellId(u32) + result(u8) (no castCount prefix) // If result == 0, the spell successfully began; otherwise treat like SMSG_CAST_FAILED. - if (packet.getSize() - packet.getReadPos() >= 6) { - /*uint8_t castCount =*/ packet.readUInt8(); - /*uint32_t spellId =*/ packet.readUInt32(); - uint8_t result = packet.readUInt8(); - if (result != 0) { - // Failure — clear cast bar and show message + uint32_t castResultSpellId = 0; + uint8_t castResult = 0; + if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { + if (castResult != 0) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - const char* reason = getSpellCastResultString(result, -1); + const char* reason = getSpellCastResultString(castResult, -1); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = reason ? reason - : ("Spell cast failed (error " + std::to_string(result) + ")"); + : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addLocalChatMessage(msg); } } break; + } // ---- Spell failed on another unit ---- case Opcode::SMSG_SPELL_FAILED_OTHER: diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index b4d803a9..b13ee058 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -977,6 +977,21 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector Date: Mon, 9 Mar 2026 21:48:41 -0700 Subject: [PATCH 28/71] tbc: fix SMSG_SPELL_START and SMSG_SPELL_GO for TBC 2.4.3 TBC 2.4.3 SMSG_SPELL_START and SMSG_SPELL_GO send full uint64 GUIDs for casterGuid/casterUnit and hit targets. WotLK uses packed (variable-length) GUIDs. Using readPackedGuid() on a full uint64 reads the first byte as the bitmask, consuming 1-8 wrong bytes, which shifts all subsequent fields (spellId, castFlags, castTime) and causes: - Cast bar to never show for the player's own spells - Sound effects to use the wrong spell ID - Hit/miss target tracking to be completely wrong Additionally, TBC SMSG_SPELL_GO lacks the WotLK timestamp field after castFlags. Add TbcPacketParsers::parseSpellStart and ::parseSpellGo using full GUIDs, add virtual base methods, and route both handlers through virtual dispatch. --- include/game/packet_parsers.hpp | 14 +++++++ src/game/game_handler.cpp | 4 +- src/game/packet_parsers_tbc.cpp | 67 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 8885865e..27e6dfc8 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -105,6 +105,16 @@ public: return InitialSpellsParser::parse(packet, data); } + /** Parse SMSG_SPELL_START */ + virtual bool parseSpellStart(network::Packet& packet, SpellStartData& data) { + return SpellStartParser::parse(packet, data); + } + + /** Parse SMSG_SPELL_GO */ + virtual bool parseSpellGo(network::Packet& packet, SpellGoData& data) { + return SpellGoParser::parse(packet, data); + } + /** Parse SMSG_CAST_FAILED */ virtual bool parseCastFailed(network::Packet& packet, CastFailedData& data) { return CastFailedParser::parse(packet, data); @@ -322,6 +332,10 @@ public: bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; // TBC 2.4.3 SMSG_CAST_RESULT: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) override; + // TBC 2.4.3 SMSG_SPELL_START: full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; + // TBC 2.4.3 SMSG_SPELL_GO: full uint64 GUIDs, no timestamp field (WotLK added one) + bool parseSpellGo(network::Packet& packet, SpellGoData& data) override; // TBC 2.4.3 SMSG_MAIL_LIST_RESULT: uint8 count (not uint32+uint8), no body field, // attachment uses uint64 itemGuid (not uint32), enchants are 7×u32 id-only (not 7×{id+dur+charges}) bool parseMailList(network::Packet& packet, std::vector& inbox) override; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 86f83351..37c6591f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12410,7 +12410,7 @@ static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t ma void GameHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; - if (!SpellStartParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellStart(packet, data)) return; // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { @@ -12435,7 +12435,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) { void GameHandler::handleSpellGo(network::Packet& packet) { SpellGoData data; - if (!SpellGoParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellGo(packet, data)) return; // Cast completed if (data.casterUnit == playerGuid) { diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index b13ee058..141f9f96 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -977,6 +977,73 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector= packet.getSize()) { + LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " (no hit data)"); + return true; + } + + data.hitCount = packet.readUInt8(); + 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 + } + + if (packet.getReadPos() < packet.getSize()) { + data.missCount = packet.readUInt8(); + } + + LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, + " misses=", (int)data.missCount); + return true; +} + // ============================================================================ // TbcPacketParsers::parseCastResult — TBC 2.4.3 SMSG_CAST_RESULT // From 8f0d2cc4abffdcbf57e26d22b8530cae573b9f90 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 21:57:42 -0700 Subject: [PATCH 29/71] terrain: pre-load bind point tiles during Hearthstone cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the player starts casting Hearthstone (spell IDs 6948/8690), trigger background terrain loading at the bind point so tiles are ready when the teleport fires. - Add HearthstonePreloadCallback to GameHandler, called from handleSpellStart when a Hearthstone cast begins. - Application callback enqueues a 5×5 tile grid around the bind point via precacheTiles() (same-map) or starts a file-cache warm via startWorldPreload() (cross-map) during the ~10 s cast time. - On same-map teleport arrival, call processAllReadyTiles() to GPU-upload any tiles that finished parsing during the cast before the first frame at the new position. Fixes: player landing in unloaded terrain and falling after Hearthstone. --- include/game/game_handler.hpp | 7 +++++ src/core/application.cpp | 49 +++++++++++++++++++++++++++++++++++ src/game/game_handler.cpp | 8 ++++++ 3 files changed, 64 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7ce933bf..8053fb42 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -610,6 +610,12 @@ public: using BindPointCallback = std::function; void setBindPointCallback(BindPointCallback cb) { bindPointCallback_ = std::move(cb); } + // Called when the player starts casting Hearthstone so terrain at the bind + // point can be pre-loaded during the cast time. + // Parameters: mapId and canonical (x, y, z) of the bind location. + using HearthstonePreloadCallback = std::function; + void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); } + // Creature spawn callback (online mode - triggered when creature enters view) // Parameters: guid, displayId, x, y, z (canonical), orientation using CreatureSpawnCallback = std::function; @@ -1683,6 +1689,7 @@ private: UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; BindPointCallback bindPointCallback_; + HearthstonePreloadCallback hearthstonePreloadCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; PlayerSpawnCallback playerSpawnCallback_; diff --git a/src/core/application.cpp b/src/core/application.cpp index f26e9865..b3883e0c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1710,6 +1710,10 @@ void Application::setupUICallbacks() { renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(0.5f); } + // Flush any tiles that finished background parsing during the cast + // (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before + // the first frame at the new position. + renderer->getTerrainManager()->processAllReadyTiles(); return; } @@ -1950,6 +1954,51 @@ void Application::setupUICallbacks() { LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); }); + // Hearthstone preload callback: begin loading terrain at the bind point as soon as + // the player starts casting Hearthstone. The ~10 s cast gives enough time for + // the background streaming workers to bring tiles into the cache so the player + // lands on solid ground instead of falling through un-loaded terrain. + gameHandler->setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) { + if (!renderer || !assetManager) return; + + auto* terrainMgr = renderer->getTerrainManager(); + if (!terrainMgr) return; + + // Resolve map name from the cached Map.dbc table + std::string mapName; + if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) { + mapName = it->second; + } else { + mapName = mapIdToName(mapId); + } + if (mapName.empty()) mapName = "Azeroth"; + + if (mapId == loadedMapId_) { + // Same map: pre-enqueue tiles around the bind point so workers start + // loading them now. Uses render-space coords (canonicalToRender). + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + + std::vector> tiles; + tiles.reserve(25); + for (int dy = -2; dy <= 2; dy++) + for (int dx = -2; dx <= 2; dx++) + tiles.push_back({tileX + dx, tileY + dy}); + + terrainMgr->precacheTiles(tiles); + LOG_INFO("Hearthstone preload: enqueued ", tiles.size(), + " tiles around bind point (same map) tile=[", tileX, ",", tileY, "]"); + } else { + // Different map: warm the file cache so ADT parsing is fast when + // loadOnlineWorldTerrain runs its blocking load loop. + // homeBindPos_ is canonical; startWorldPreload expects server coords. + glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z)); + startWorldPreload(mapId, mapName, server.x, server.y); + LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName, + "' (id=", mapId, ")"); + } + }); + // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 37c6591f..a34ae7bf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12430,6 +12430,14 @@ void GameHandler::handleSpellStart(network::Packet& packet) { ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); } } + + // Hearthstone cast: begin pre-loading terrain at bind point during cast time + // so tiles are ready when the teleport fires (avoids falling through un-loaded terrain). + // Spell IDs: 6948 = Vanilla Hearthstone (rank 1), 8690 = TBC/WotLK Hearthstone + const bool isHearthstone = (data.spellId == 6948 || data.spellId == 8690); + if (isHearthstone && hasHomeBind_ && hearthstonePreloadCallback_) { + hearthstonePreloadCallback_(homeBindMapId_, homeBindPos_.x, homeBindPos_.y, homeBindPos_.z); + } } } From d5de031c2363082a71de04135342471094b932b9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:04:18 -0700 Subject: [PATCH 30/71] tbc: fix quest log stride and CMSG_QUESTGIVER_QUERY_QUEST format TBC 2.4.3 quest log update fields use 4 fields per slot (questId, state, counts, timer) vs WotLK's 5 (extra counts field). The wrong stride (5) caused all quest log reads to use wrong indices beyond the first slot, breaking quest tracking on TBC servers. TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST is guid(8) + questId(4) = 12 bytes. WotLK added a trailing isDialogContinued(u8) byte that TBC servers don't expect; sending it caused quest details to not be sent back on some emulators. --- include/game/packet_parsers.hpp | 6 ++++++ src/game/packet_parsers_tbc.cpp | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 27e6dfc8..933ce955 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -345,6 +345,12 @@ public: bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; // TBC 2.4.3 SMSG_SPELLHEALLOG uses full uint64 GUIDs (WotLK uses packed GUIDs) bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override; + // TBC 2.4.3 quest log has 4 update fields per slot (questId, state, counts, timer) + // WotLK expands this to 5 (splits counts into two fields). + uint8_t questLogStride() const override { return 4; } + // TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST: guid(8) + questId(4) — no trailing + // isDialogContinued byte that WotLK added + network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 141f9f96..f65de114 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -698,6 +698,20 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3 return packet; } +// ============================================================================ +// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST +// +// WotLK adds a trailing uint8 isDialogContinued byte; TBC does not. +// TBC format: guid(8) + questId(4) = 12 bytes. +// ============================================================================ +network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_QUERY_QUEST)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + // No isDialogContinued byte (WotLK-only addition) + return packet; +} + // ============================================================================ // TBC parseAuraUpdate - SMSG_AURA_UPDATE doesn't exist in TBC // TBC uses inline aura update fields + SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE (0x3A3) / From edd7e5e591ce9c8d7bea358f47968f863aa184a0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:14:32 -0700 Subject: [PATCH 31/71] Fix shadow flashing: per-frame shadow depth images and framebuffers Single shadow depth image shared across MAX_FRAMES=2 in-flight GPU frames caused a race: frame N's main pass reads shadow map while frame N+1's shadow pass clears and writes it, producing visible flashing standing still and while moving. Fix: give each in-flight frame its own VkImage, VmaAllocation, VkImageView, and VkFramebuffer for the shadow depth attachment. renderShadowPass() now indexes all shadow resources by getCurrentFrame(), and layout transitions track per-frame state in shadowDepthLayout_[frame]. Cleanup loops over MAX_FRAMES=2. Descriptor sets already written per-frame; updated shadow image view binding to use the matching per-frame view. --- include/rendering/renderer.hpp | 14 +++--- src/rendering/renderer.cpp | 78 +++++++++++++++++++--------------- 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 90432595..93bbed03 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -241,13 +241,17 @@ private: std::unique_ptr zoneManager; // Shadow mapping (Vulkan) static constexpr uint32_t SHADOW_MAP_SIZE = 4096; - VkImage shadowDepthImage = VK_NULL_HANDLE; - VmaAllocation shadowDepthAlloc = VK_NULL_HANDLE; - VkImageView shadowDepthView = VK_NULL_HANDLE; + // Per-frame shadow resources: each in-flight frame has its own depth image and + // framebuffer so that frame N's shadow read and frame N+1's shadow write don't + // race on the same image across concurrent GPU submissions. + // Array size must match MAX_FRAMES (= 2, defined in the private section below). + VkImage shadowDepthImage[2] = {}; + VmaAllocation shadowDepthAlloc[2] = {}; + VkImageView shadowDepthView[2] = {}; VkSampler shadowSampler = VK_NULL_HANDLE; VkRenderPass shadowRenderPass = VK_NULL_HANDLE; - VkFramebuffer shadowFramebuffer = VK_NULL_HANDLE; - VkImageLayout shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; + VkFramebuffer shadowFramebuffer[2] = {}; + VkImageLayout shadowDepthLayout_[2] = {}; glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); glm::vec3 shadowCenter = glm::vec3(0.0f); bool shadowCenterInitialized = false; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 0fd4beb9..6da94182 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -288,7 +288,9 @@ Renderer::~Renderer() = default; bool Renderer::createPerFrameResources() { VkDevice device = vkCtx->getDevice(); - // --- Create shadow depth image --- + // --- Create per-frame shadow depth images (one per in-flight frame) --- + // Each frame slot has its own depth image so that frame N's shadow read and + // frame N+1's shadow write cannot race on the same image. VkImageCreateInfo imgCI{}; imgCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; imgCI.imageType = VK_IMAGE_TYPE_2D; @@ -301,26 +303,30 @@ bool Renderer::createPerFrameResources() { imgCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; VmaAllocationCreateInfo imgAllocCI{}; imgAllocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; - if (vmaCreateImage(vkCtx->getAllocator(), &imgCI, &imgAllocCI, - &shadowDepthImage, &shadowDepthAlloc, nullptr) != VK_SUCCESS) { - LOG_ERROR("Failed to create shadow depth image"); - return false; + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (vmaCreateImage(vkCtx->getAllocator(), &imgCI, &imgAllocCI, + &shadowDepthImage[i], &shadowDepthAlloc[i], nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow depth image [", i, "]"); + return false; + } + shadowDepthLayout_[i] = VK_IMAGE_LAYOUT_UNDEFINED; } - shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; - // --- Create shadow depth image view --- + // --- Create per-frame shadow depth image views --- VkImageViewCreateInfo viewCI{}; viewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; - viewCI.image = shadowDepthImage; viewCI.viewType = VK_IMAGE_VIEW_TYPE_2D; viewCI.format = VK_FORMAT_D32_SFLOAT; viewCI.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; - if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView) != VK_SUCCESS) { - LOG_ERROR("Failed to create shadow depth image view"); - return false; + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + viewCI.image = shadowDepthImage[i]; + if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow depth image view [", i, "]"); + return false; + } } - // --- Create shadow sampler --- + // --- Create shadow sampler (shared — read-only, no per-frame needed) --- VkSamplerCreateInfo sampCI{}; sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; sampCI.magFilter = VK_FILTER_LINEAR; @@ -377,18 +383,20 @@ bool Renderer::createPerFrameResources() { return false; } - // --- Create shadow framebuffer --- + // --- Create per-frame shadow framebuffers --- VkFramebufferCreateInfo fbCI{}; fbCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; fbCI.renderPass = shadowRenderPass; fbCI.attachmentCount = 1; - fbCI.pAttachments = &shadowDepthView; fbCI.width = SHADOW_MAP_SIZE; fbCI.height = SHADOW_MAP_SIZE; fbCI.layers = 1; - if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer) != VK_SUCCESS) { - LOG_ERROR("Failed to create shadow framebuffer"); - return false; + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + fbCI.pAttachments = &shadowDepthView[i]; + if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow framebuffer [", i, "]"); + return false; + } } // --- Create descriptor set layout for set 0 (per-frame UBO + shadow sampler) --- @@ -470,7 +478,7 @@ bool Renderer::createPerFrameResources() { VkDescriptorImageInfo shadowImgInfo{}; shadowImgInfo.sampler = shadowSampler; - shadowImgInfo.imageView = shadowDepthView; + shadowImgInfo.imageView = shadowDepthView[i]; shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; VkWriteDescriptorSet writes[2]{}; @@ -527,7 +535,7 @@ bool Renderer::createPerFrameResources() { VkDescriptorImageInfo shadowImgInfo{}; shadowImgInfo.sampler = shadowSampler; - shadowImgInfo.imageView = shadowDepthView; + shadowImgInfo.imageView = shadowDepthView[0]; // reflection uses frame 0 shadow view shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; VkWriteDescriptorSet writes[2]{}; @@ -576,13 +584,15 @@ void Renderer::destroyPerFrameResources() { perFrameSetLayout = VK_NULL_HANDLE; } - // Destroy shadow resources - if (shadowFramebuffer) { vkDestroyFramebuffer(device, shadowFramebuffer, nullptr); shadowFramebuffer = VK_NULL_HANDLE; } + // Destroy per-frame shadow resources + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (shadowFramebuffer[i]) { vkDestroyFramebuffer(device, shadowFramebuffer[i], nullptr); shadowFramebuffer[i] = VK_NULL_HANDLE; } + if (shadowDepthView[i]) { vkDestroyImageView(device, shadowDepthView[i], nullptr); shadowDepthView[i] = VK_NULL_HANDLE; } + if (shadowDepthImage[i]) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage[i], shadowDepthAlloc[i]); shadowDepthImage[i] = VK_NULL_HANDLE; shadowDepthAlloc[i] = VK_NULL_HANDLE; } + shadowDepthLayout_[i] = VK_IMAGE_LAYOUT_UNDEFINED; + } if (shadowRenderPass) { vkDestroyRenderPass(device, shadowRenderPass, nullptr); shadowRenderPass = VK_NULL_HANDLE; } - if (shadowDepthView) { vkDestroyImageView(device, shadowDepthView, nullptr); shadowDepthView = VK_NULL_HANDLE; } - if (shadowDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage, shadowDepthAlloc); shadowDepthImage = VK_NULL_HANDLE; shadowDepthAlloc = VK_NULL_HANDLE; } if (shadowSampler) { vkDestroySampler(device, shadowSampler, nullptr); shadowSampler = VK_NULL_HANDLE; } - shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; } void Renderer::updatePerFrameUBO() { @@ -1088,7 +1098,7 @@ void Renderer::beginFrame() { } // Shadow pre-pass (before main render pass) - if (shadowsEnabled && shadowDepthImage != VK_NULL_HANDLE) { + if (shadowsEnabled && shadowDepthImage[0] != VK_NULL_HANDLE) { renderShadowPass(); } @@ -5669,7 +5679,7 @@ void Renderer::renderReflectionPass() { void Renderer::renderShadowPass() { static const bool skipShadows = (std::getenv("WOWEE_SKIP_SHADOWS") != nullptr); if (skipShadows) return; - if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return; + if (!shadowsEnabled || shadowDepthImage[0] == VK_NULL_HANDLE) return; if (currentCmd == VK_NULL_HANDLE) return; // Shadows render every frame — throttling causes visible flicker on player/NPCs @@ -5686,21 +5696,21 @@ void Renderer::renderShadowPass() { ubo->shadowParams.y = 0.8f; } - // Barrier 1: transition shadow map into writable depth layout. + // Barrier 1: transition this frame's shadow map into writable depth layout. VkImageMemoryBarrier b1{}; b1.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - b1.oldLayout = shadowDepthLayout_; + b1.oldLayout = shadowDepthLayout_[frame]; b1.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; b1.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; b1.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - b1.srcAccessMask = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + b1.srcAccessMask = (shadowDepthLayout_[frame] == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) ? VK_ACCESS_SHADER_READ_BIT : 0; b1.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; - b1.image = shadowDepthImage; + b1.image = shadowDepthImage[frame]; b1.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; - VkPipelineStageFlags srcStage = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + VkPipelineStageFlags srcStage = (shadowDepthLayout_[frame] == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; vkCmdPipelineBarrier(currentCmd, @@ -5711,7 +5721,7 @@ void Renderer::renderShadowPass() { VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rpInfo.renderPass = shadowRenderPass; - rpInfo.framebuffer = shadowFramebuffer; + rpInfo.framebuffer = shadowFramebuffer[frame]; rpInfo.renderArea = {{0, 0}, {SHADOW_MAP_SIZE, SHADOW_MAP_SIZE}}; VkClearValue clear{}; clear.depthStencil = {1.0f, 0}; @@ -5750,12 +5760,12 @@ void Renderer::renderShadowPass() { b2.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; b2.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; b2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - b2.image = shadowDepthImage; + b2.image = shadowDepthImage[frame]; b2.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; vkCmdPipelineBarrier(currentCmd, VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b2); - shadowDepthLayout_ = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + shadowDepthLayout_[frame] = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; } } // namespace rendering From ede380ec60043a77cb1e7ba1d6c017bb2b0b6e01 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:20:47 -0700 Subject: [PATCH 32/71] tbc: implement SMSG_INIT/SET_EXTRA_AURA_INFO_OBSOLETE for buff tracking TBC 2.4.3 does not have SMSG_AURA_UPDATE (added in WotLK). Instead it uses SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE (0x3A3) for full aura refresh on login/zone and SMSG_SET_EXTRA_AURA_INFO_OBSOLETE (0x3A4) for single- slot updates. Implement handlers for both packets so TBC buff/debuff bars populate correctly. Also implement SMSG_CLEAR_EXTRA_AURA_INFO (0x3A6) to remove individual aura slots when buffs expire or are cancelled server-side. Format parsed: uint64 targetGuid + uint8 count + per-slot {uint8 slot, uint32 spellId, uint8 effectIndex, uint8 flags, uint32 durationMs, uint32 maxDurationMs}. Infinite auras (0xFFFFFFFF) stored as durationMs=-1. --- src/game/game_handler.cpp | 59 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a34ae7bf..40d78b40 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4420,10 +4420,47 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE: - case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: - // Extra aura metadata (icons/durations) not yet consumed by aura UI. + case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: { + // TBC 2.4.3 aura tracking: replaces SMSG_AURA_UPDATE which doesn't exist in TBC. + // Format: uint64 targetGuid + uint8 count + N×{uint8 slot, uint32 spellId, + // uint8 effectIndex, uint8 flags, uint32 durationMs, uint32 maxDurationMs} + const bool isInit = (*logicalOp == Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE); + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 9) { packet.setReadPos(packet.getSize()); break; } + uint64_t auraTargetGuid = packet.readUInt64(); + uint8_t count = packet.readUInt8(); + + std::vector* auraList = nullptr; + if (auraTargetGuid == playerGuid) auraList = &playerAuras; + else if (auraTargetGuid == targetGuid) auraList = &targetAuras; + + if (auraList && isInit) auraList->clear(); + + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (uint8_t i = 0; i < count && remaining() >= 13; i++) { + uint8_t slot = packet.readUInt8(); + uint32_t spellId = packet.readUInt32(); + (void) packet.readUInt8(); // effectIndex (unused for slot display) + uint8_t flags = packet.readUInt8(); + uint32_t durationMs = packet.readUInt32(); + uint32_t maxDurMs = packet.readUInt32(); + + if (auraList) { + while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); + AuraSlot& a = (*auraList)[slot]; + a.spellId = spellId; + a.flags = flags; + a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast(durationMs); + a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast(maxDurMs); + a.receivedAtMs = nowMs; + } + } packet.setReadPos(packet.getSize()); break; + } case Opcode::MSG_MOVE_WORLDPORT_ACK: // Client uses this outbound; treat inbound variant as no-op for robustness. packet.setReadPos(packet.getSize()); @@ -4729,8 +4766,24 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: { + // TBC 2.4.3: clear a single aura slot for a unit + // Format: uint64 targetGuid + uint8 slot + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t clearGuid = packet.readUInt64(); + uint8_t slot = packet.readUInt8(); + std::vector* auraList = nullptr; + if (clearGuid == playerGuid) auraList = &playerAuras; + else if (clearGuid == targetGuid) auraList = &targetAuras; + if (auraList && slot < auraList->size()) { + (*auraList)[slot] = AuraSlot{}; + } + } + packet.setReadPos(packet.getSize()); + break; + } + // ---- Misc consume ---- - case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: case Opcode::SMSG_COMPLAIN_RESULT: case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: From c6e39707ded7bb8db5062138c2bb7c8af73aa552 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:27:24 -0700 Subject: [PATCH 33/71] Fix resurrect: correct packet routing and show caster name in dialog Two bugs fixed: 1. acceptResurrect() was always sending CMSG_SPIRIT_HEALER_ACTIVATE even for player-cast resurrections (Priest/Paladin/Druid). That opcode is only the correct response to SMSG_SPIRIT_HEALER_CONFIRM. For SMSG_RESURRECT_REQUEST the server expects CMSG_RESURRECT_RESPONSE with accept=1. Added resurrectIsSpiritHealer_ to track which path triggered the dialog and send the right packet per type. 2. The resurrect dialog showed a generic "Return to life?" string regardless of who cast the resurrection. Parse the optional CString name from SMSG_RESURRECT_REQUEST (or fall back to playerNameCache) and display "X wishes to resurrect you." when the caster is known. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 35 +++++++++++++++++++++++++++++------ src/ui/game_screen.cpp | 11 +++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8053fb42..2e53dda9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -735,6 +735,7 @@ public: bool isPlayerGhost() const { return releasedSpirit_; } bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } + const std::string& getResurrectCasterName() const { return resurrectCasterName_; } void releaseSpirit(); void acceptResurrect(); void declineResurrect(); @@ -2160,7 +2161,9 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST uint64_t resurrectCasterGuid_ = 0; + std::string resurrectCasterName_; bool repopPending_ = false; uint64_t lastRepopRequestMs_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 40d78b40..db003428 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2942,6 +2942,8 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Spirit healer confirm from 0x", std::hex, npcGuid, std::dec); if (npcGuid) { resurrectCasterGuid_ = npcGuid; + resurrectCasterName_ = ""; + resurrectIsSpiritHealer_ = true; resurrectRequestPending_ = true; } break; @@ -2952,9 +2954,22 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } uint64_t casterGuid = packet.readUInt64(); - LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec); + // Optional caster name (CString, may be absent on some server builds) + std::string casterName; + if (packet.getReadPos() < packet.getSize()) { + casterName = packet.readString(); + } + LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec, + " name='", casterName, "'"); if (casterGuid) { resurrectCasterGuid_ = casterGuid; + resurrectIsSpiritHealer_ = false; + if (!casterName.empty()) { + resurrectCasterName_ = casterName; + } else { + auto nit = playerNameCache.find(casterGuid); + resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : ""; + } resurrectRequestPending_ = true; } break; @@ -9455,11 +9470,19 @@ void GameHandler::activateSpiritHealer(uint64_t npcGuid) { void GameHandler::acceptResurrect() { if (state != WorldState::IN_WORLD || !socket || !resurrectRequestPending_) return; - // Send spirit healer activate (correct response to SMSG_SPIRIT_HEALER_CONFIRM) - auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_); - socket->send(activate); - LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE (0x21C) for 0x", - std::hex, resurrectCasterGuid_, std::dec); + if (resurrectIsSpiritHealer_) { + // Spirit healer resurrection — SMSG_SPIRIT_HEALER_CONFIRM → CMSG_SPIRIT_HEALER_ACTIVATE + auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_); + socket->send(activate); + LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", + std::hex, resurrectCasterGuid_, std::dec); + } else { + // Player-cast resurrection — SMSG_RESURRECT_REQUEST → CMSG_RESURRECT_RESPONSE (accept=1) + auto resp = ResurrectResponsePacket::build(resurrectCasterGuid_, true); + socket->send(resp); + LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (accept) for 0x", + std::hex, resurrectCasterGuid_, std::dec); + } resurrectRequestPending_ = false; resurrectPending_ = true; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dbfacd04..69d1668e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7034,10 +7034,13 @@ void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { ImGui::Spacing(); - const char* text = "Return to life?"; - float textW = ImGui::CalcTextSize(text).x; - ImGui::SetCursorPosX((dlgW - textW) / 2); - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text); + const std::string& casterName = gameHandler.getResurrectCasterName(); + std::string text = casterName.empty() + ? "Return to life?" + : casterName + " wishes to resurrect you."; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text.c_str()); ImGui::Spacing(); ImGui::Spacing(); From c44477fbee8df9854530c35f26b4509db709c09d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:31:56 -0700 Subject: [PATCH 34/71] Implement corpse reclaim: store death position and show Resurrect button When a player releases spirit, the server sends SMSG_DEATH_RELEASE_LOC with the corpse map and position. Store this so the ghost can reclaim. New flow: - SMSG_DEATH_RELEASE_LOC now stores corpseMapId_/corpseX_/Y_/Z_ instead of logging and discarding - canReclaimCorpse(): true when ghost is on same map within 40 yards of stored corpse position - reclaimCorpse(): sends CMSG_RECLAIM_CORPSE (no payload) - renderReclaimCorpseButton(): shows "Resurrect from Corpse" button at bottom-center when canReclaimCorpse() is true --- include/game/game_handler.hpp | 6 ++++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 31 +++++++++++++++++++++++++------ src/ui/game_screen.cpp | 29 +++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2e53dda9..af0fac2e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -736,6 +736,10 @@ public: bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } const std::string& getResurrectCasterName() const { return resurrectCasterName_; } + /** True when ghost is within 40 yards of corpse position (same map). */ + bool canReclaimCorpse() const; + /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ + void reclaimCorpse(); void releaseSpirit(); void acceptResurrect(); void declineResurrect(); @@ -2150,6 +2154,8 @@ private: float serverPitchRate_ = 3.14159f; bool playerDead_ = false; bool releasedSpirit_ = false; + uint32_t corpseMapId_ = 0; + float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f; // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially std::array playerRunes_ = [] { std::array r{}; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 49d429b6..db27a073 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -228,6 +228,7 @@ private: void renderTrainerWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); + void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index db003428..6eced5fe 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2021,13 +2021,14 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { - // uint32 mapId + float x + float y + float z — spirit healer position + // uint32 mapId + float x + float y + float z — corpse/spirit healer position if (packet.getSize() - packet.getReadPos() >= 16) { - uint32_t mapId = packet.readUInt32(); - float x = packet.readFloat(); - float y = packet.readFloat(); - float z = packet.readFloat(); - LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", mapId, " x=", x, " y=", y, " z=", z); + corpseMapId_ = packet.readUInt32(); + corpseX_ = packet.readFloat(); + corpseY_ = packet.readFloat(); + corpseZ_ = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_, + " x=", corpseX_, " y=", corpseY_, " z=", corpseZ_); } break; } @@ -9459,6 +9460,24 @@ void GameHandler::releaseSpirit() { } } +bool GameHandler::canReclaimCorpse() const { + if (!releasedSpirit_ || corpseMapId_ == 0) return false; + // Only if ghost is on the same map as their corpse + if (currentMapId_ != corpseMapId_) return false; + // Must be within 40 yards (server also validates proximity) + float dx = movementInfo.x - corpseX_; + float dy = movementInfo.y - corpseY_; + float dz = movementInfo.z - corpseZ_; + return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); +} + +void GameHandler::reclaimCorpse() { + if (!canReclaimCorpse() || !socket) return; + network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + socket->send(packet); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE"); +} + void GameHandler::activateSpiritHealer(uint64_t npcGuid) { if (state != WorldState::IN_WORLD || !socket) return; pendingSpiritHealerGuid_ = npcGuid; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 69d1668e..d238f461 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -433,6 +433,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now renderMinimapMarkers(gameHandler); renderDeathScreen(gameHandler); + renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); @@ -7013,6 +7014,34 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { + if (!gameHandler.isPlayerGhost() || !gameHandler.canReclaimCorpse()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float btnW = 220.0f, btnH = 36.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); + if (ImGui::Begin("##ReclaimCorpse", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoBringToFrontOnFocus)) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); + if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { + gameHandler.reclaimCorpse(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} + void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { if (!gameHandler.showResurrectDialog()) return; From f63b75c388bc57a3c49907c1e3d2e98604e4b78a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:39:08 -0700 Subject: [PATCH 35/71] tbc/classic: fix SMSG_RAID_INSTANCE_INFO format (uint32 resetTime, no extended) TBC 2.4.3 and Classic 1.12 send resetTime as uint32 (seconds) with no extended byte, while WotLK 3.3.5a sends uint64 timestamp + extended byte. Parse the correct field widths based on expansion to prevent corrupted instance lockout data on TBC/Classic realms. --- src/game/game_handler.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6eced5fe..644f7f09 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11161,23 +11161,33 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { } void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { - // SMSG_RAID_INSTANCE_INFO: uint32 count, then for each: - // mapId(u32) + difficulty(u32) + resetTime(u64) + locked(u8) + extended(u8) + // TBC 2.4.3 format: mapId(4) + difficulty(4) + resetTime(4 — uint32 seconds) + locked(1) + // WotLK 3.3.5a format: mapId(4) + difficulty(4) + resetTime(8 — uint64 timestamp) + locked(1) + extended(1) + const bool isTbc = isActiveExpansion("tbc"); + const bool isClassic = isClassicLikeExpansion(); + const bool useTbcFormat = isTbc || isClassic; + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t count = packet.readUInt32(); instanceLockouts_.clear(); instanceLockouts_.reserve(count); - constexpr size_t kEntrySize = 4 + 4 + 8 + 1 + 1; + const size_t kEntrySize = useTbcFormat ? (4 + 4 + 4 + 1) : (4 + 4 + 8 + 1 + 1); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < kEntrySize) break; InstanceLockout lo; lo.mapId = packet.readUInt32(); lo.difficulty = packet.readUInt32(); - lo.resetTime = packet.readUInt64(); - lo.locked = packet.readUInt8() != 0; - lo.extended = packet.readUInt8() != 0; + if (useTbcFormat) { + lo.resetTime = packet.readUInt32(); // TBC/Classic: 4-byte seconds + lo.locked = packet.readUInt8() != 0; + lo.extended = false; + } else { + lo.resetTime = packet.readUInt64(); // WotLK: 8-byte timestamp + lo.locked = packet.readUInt8() != 0; + lo.extended = packet.readUInt8() != 0; + } instanceLockouts_.push_back(lo); LOG_INFO("Instance lockout: mapId=", lo.mapId, " diff=", lo.difficulty, " reset=", lo.resetTime, " locked=", lo.locked, " extended=", lo.extended); From 3e5760aefe3d1f7d0867cd6247f0af84244b784a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:42:44 -0700 Subject: [PATCH 36/71] ui: add battleground score frame for WSG/AB/AV/EotS/SotA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a compact top-centre overlay showing Alliance vs Horde scores when the player is in a recognised battleground map. Score values are read directly from the world state map maintained by SMSG_INIT_WORLD_STATES and SMSG_UPDATE_WORLD_STATE, so no extra server packets are needed. Supported maps: 489 – Warsong Gulch (flag captures, max 3) 529 – Arathi Basin (resources, max 1600) 30 – Alterac Valley (reinforcements, max 600) 566 – Eye of the Storm (resources, max 1600) 607 – Strand of Ancients --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 119 +++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index db27a073..1496ea28 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -247,6 +247,7 @@ private: void renderDungeonFinderWindow(game::GameHandler& gameHandler); void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); + void renderBattlegroundScore(game::GameHandler& gameHandler); /** * Inventory screen diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d238f461..86a46464 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -401,6 +401,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderMirrorTimers(gameHandler); renderQuestObjectiveTracker(gameHandler); if (showNameplates_) renderNameplates(gameHandler); + renderBattlegroundScore(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); renderBossFrames(gameHandler); @@ -10296,4 +10297,122 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { ImGui::End(); } +// ============================================================================ +// Battleground score frame +// +// Displays the current score for the player's battleground using world states. +// Shown in the top-centre of the screen whenever SMSG_INIT_WORLD_STATES has +// been received for a known BG map. The layout adapts per battleground: +// +// WSG 489 – Alliance / Horde flag captures (max 3) +// AB 529 – Alliance / Horde resource scores (max 1600) +// AV 30 – Alliance / Horde reinforcements +// EotS 566 – Alliance / Horde resource scores (max 1600) +// ============================================================================ +void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { + // Only show when in a recognised battleground map + uint32_t mapId = gameHandler.getWorldStateMapId(); + + // World state key sets per battleground + // Keys from the WoW 3.3.5a WorldState.dbc / client source + struct BgScoreDef { + uint32_t mapId; + const char* name; + uint32_t allianceKey; // world state key for Alliance value + uint32_t hordeKey; // world state key for Horde value + uint32_t maxKey; // max score world state key (0 = use hardcoded) + uint32_t hardcodedMax; // used when maxKey == 0 + const char* unit; // suffix label (e.g. "flags", "resources") + }; + + static constexpr BgScoreDef kBgDefs[] = { + // Warsong Gulch: 3 flag captures wins + { 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" }, + // Arathi Basin: 1600 resources wins + { 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" }, + // Alterac Valley: reinforcements count down from 600 / 800 etc. + { 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" }, + // Eye of the Storm: 1600 resources wins + { 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" }, + // Strand of the Ancients (WotLK) + { 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" }, + }; + + const BgScoreDef* def = nullptr; + for (const auto& d : kBgDefs) { + if (d.mapId == mapId) { def = &d; break; } + } + if (!def) return; + + auto allianceOpt = gameHandler.getWorldState(def->allianceKey); + auto hordeOpt = gameHandler.getWorldState(def->hordeKey); + if (!allianceOpt && !hordeOpt) return; + + uint32_t allianceScore = allianceOpt.value_or(0); + uint32_t hordeScore = hordeOpt.value_or(0); + uint32_t maxScore = def->hardcodedMax; + if (def->maxKey != 0) { + if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv; + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Width scales with screen but stays reasonable + float frameW = 260.0f; + float frameH = 60.0f; + float posX = screenW / 2.0f - frameW / 2.0f; + float posY = 4.0f; + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + + if (ImGui::Begin("##BGScore", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoSavedSettings)) { + + // BG name centred at top + float nameW = ImGui::CalcTextSize(def->name).x; + ImGui::SetCursorPosX((frameW - nameW) / 2.0f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "%s", def->name); + + // Alliance score | separator | Horde score + float innerW = frameW - 12.0f; + float halfW = innerW / 2.0f - 4.0f; + + ImGui::SetCursorPosX(6.0f); + ImGui::BeginGroup(); + { + // Alliance (blue) + char aBuf[32]; + if (maxScore > 0 && strlen(def->unit) > 0) + snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore); + else + snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore); + ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "%s", aBuf); + } + ImGui::EndGroup(); + + ImGui::SameLine(halfW + 16.0f); + + ImGui::BeginGroup(); + { + // Horde (red) + char hBuf[32]; + if (maxScore > 0 && strlen(def->unit) > 0) + snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore); + else + snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore); + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "%s", hBuf); + } + ImGui::EndGroup(); + } + ImGui::End(); + ImGui::PopStyleVar(2); +} + }} // namespace wowee::ui From d3397341438bc66aa68351f6291ff2459bf6518d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:45:06 -0700 Subject: [PATCH 37/71] =?UTF-8?q?game:=20fix=20LFG=20reward=20money=20disp?= =?UTF-8?q?lay=20(copper=E2=86=92gold/silver/copper)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SMSG_LFG_PLAYER_REWARD handler was printing raw copper value with a "g" suffix (e.g. "12345g") instead of converting to gold/silver/copper. Now formats as "1g 23s 45c" matching the standard WoW convention. --- src/game/game_handler.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 644f7f09..ec1709d3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11411,8 +11411,20 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { uint32_t money = packet.readUInt32(); uint32_t xp = packet.readUInt32(); - std::string rewardMsg = "Dungeon Finder reward: " + std::to_string(money) + "g " + - std::to_string(xp) + " XP"; + // Convert copper to gold/silver/copper + uint32_t gold = money / 10000; + uint32_t silver = (money % 10000) / 100; + uint32_t copper = money % 100; + char moneyBuf[64]; + if (gold > 0) + snprintf(moneyBuf, sizeof(moneyBuf), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + snprintf(moneyBuf, sizeof(moneyBuf), "%us %uc", silver, copper); + else + snprintf(moneyBuf, sizeof(moneyBuf), "%uc", copper); + + std::string rewardMsg = std::string("Dungeon Finder reward: ") + moneyBuf + + ", " + std::to_string(xp) + " XP"; if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t rewardCount = packet.readUInt32(); From 52c1fed6ab622b5562cda0f2461223448bb32159 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:49:23 -0700 Subject: [PATCH 38/71] game: implement dual-spec switch via CMSG_SET_ACTIVE_TALENT_GROUP (0x4C3) switchTalentSpec() was only updating local state without notifying the server, leaving the server out of sync with the client's active talent group. Now sends CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (WotLK wire opcode 0x4C3) with the target group index (0=primary, 1=secondary), prompting the server to apply the spec swap and respond with a fresh SMSG_TALENTS_INFO for the newly active group. Also adds ActivateTalentGroupPacket::build() to world_packets for the packet construction. --- include/game/world_packets.hpp | 7 +++++++ src/game/game_handler.cpp | 13 ++++++++++--- src/game/world_packets.cpp | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 42f64bc9..e4a796e1 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2266,6 +2266,13 @@ public: static network::Packet build(bool accept); }; +/** CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3) — switch dual-spec talent group */ +class ActivateTalentGroupPacket { +public: + /** @param group 0 = primary spec, 1 = secondary spec */ + static network::Packet build(uint32_t group); +}; + // ============================================================ // Taxi / Flight Paths // ============================================================ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ec1709d3..52ad0e29 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12832,9 +12832,16 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { return; } - // For now, just switch locally. In a real implementation, we'd send - // MSG_TALENT_WIPE_CONFIRM to the server to trigger a spec switch. - // The server would respond with new SMSG_TALENTS_INFO for the new spec. + // Send CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3) to the server. + // The server will validate the swap, apply the new spec's spells/auras, + // and respond with SMSG_TALENTS_INFO for the newly active group. + // We optimistically update the local state so the UI reflects the change + // immediately; the server response will correct us if needed. + if (state == WorldState::IN_WORLD && socket) { + auto pkt = ActivateTalentGroupPacket::build(static_cast(newSpec)); + socket->send(pkt); + LOG_INFO("Sent CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE: group=", (int)newSpec); + } activeTalentSpec_ = newSpec; LOG_INFO("Switched to talent spec ", (int)newSpec, diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c83563f0..b71c2834 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3935,6 +3935,14 @@ network::Packet TalentWipeConfirmPacket::build(bool accept) { return packet; } +network::Packet ActivateTalentGroupPacket::build(uint32_t group) { + // CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3 in WotLK 3.3.5a) + // Payload: uint32 group (0 = primary, 1 = secondary) + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE)); + packet.writeUInt32(group); + return packet; +} + // ============================================================ // Death/Respawn // ============================================================ From 06a628dae2f27ffa62b86cb6671188b5f3e95753 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 22:53:09 -0700 Subject: [PATCH 39/71] game: implement SMSG_PET_SPELLS/MODE/BROKEN and pet action plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_PET_SPELLS: Parse full packet — pet GUID, react/command state, 10 action bar slots, per-spell entries with autocast flags. Previously only read the GUID. SMSG_PET_MODE: Parse petGuid + mode uint32 (command low byte, react high byte) to keep stance state in sync after server updates. SMSG_PET_BROKEN: Clear pet state and show "Your pet has died." chat message. SMSG_PET_LEARNED_SPELL / SMSG_PET_UNLEARNED_SPELL: Maintain pet spell list incrementally. SMSG_PET_CAST_FAILED: Parse and log cast count + spell + reason. New state accessors: getPetActionSlot(), getPetCommand(), getPetReact(), getPetSpells(), isPetSpellAutocast(). CMSG_PET_ACTION: Add targetGuid (uint64) field — the wire format requires petGuid(8)+action(4)+targetGuid(8). Was sending an 12-byte packet instead of the required 20 bytes. sendPetAction(): New method that builds and sends CMSG_PET_ACTION with the correct target guid. --- include/game/game_handler.hpp | 26 +++++++ include/game/world_packets.hpp | 3 +- src/game/game_handler.cpp | 132 ++++++++++++++++++++++++++++++--- src/game/world_packets.cpp | 4 +- 4 files changed, 153 insertions(+), 12 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index af0fac2e..1ca12c72 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -456,6 +456,27 @@ public: void dismissPet(); bool hasPet() const { return petGuid_ != 0; } uint64_t getPetGuid() const { return petGuid_; } + + // ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ---- + // 10 action bar slots; each entry is a packed uint32: + // bits 0-23 = spell ID (or 0 for empty) + // bits 24-31 = action type (0x00=cast, 0xC0=autocast on, 0x40=autocast off) + static constexpr int PET_ACTION_BAR_SLOTS = 10; + uint32_t getPetActionSlot(int idx) const { + if (idx < 0 || idx >= PET_ACTION_BAR_SLOTS) return 0; + return petActionSlots_[idx]; + } + // Pet command/react state from SMSG_PET_MODE or SMSG_PET_SPELLS + uint8_t getPetCommand() const { return petCommand_; } // 0=stay,1=follow,2=attack,3=dismiss + uint8_t getPetReact() const { return petReact_; } // 0=passive,1=defensive,2=aggressive + // Spells the pet knows (from SMSG_PET_SPELLS spell list) + const std::vector& getPetSpells() const { return petSpellList_; } + // Pet autocast set (spellIds that have autocast enabled) + bool isPetSpellAutocast(uint32_t spellId) const { + return petAutocastSpells_.count(spellId) != 0; + } + // Send CMSG_PET_ACTION to issue a pet command + void sendPetAction(uint32_t action, uint64_t targetGuid = 0); const std::unordered_set& getKnownSpells() const { return knownSpells; } // Player proficiency bitmasks (from SMSG_SET_PROFICIENCY) @@ -1763,6 +1784,11 @@ private: std::vector playerAuras; std::vector targetAuras; uint64_t petGuid_ = 0; + uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) + uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss + uint8_t petReact_ = 1; // 0=passive,1=defensive,2=aggressive + std::vector petSpellList_; // known pet spells + std::unordered_set petAutocastSpells_; // spells with autocast on // ---- Battleground queue state ---- struct BgQueueSlot { diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index e4a796e1..a65fd4aa 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1729,7 +1729,8 @@ public: /** CMSG_PET_ACTION packet builder */ class PetActionPacket { public: - static network::Packet build(uint64_t petGuid, uint32_t action); + /** CMSG_PET_ACTION: petGuid + action + targetGuid (0 = no target) */ + static network::Packet build(uint64_t petGuid, uint32_t action, uint64_t targetGuid = 0); }; /** SMSG_CAST_FAILED data */ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 52ad0e29..b309891f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5111,15 +5111,69 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; - // ---- Pet system (not yet implemented) ---- - case Opcode::SMSG_PET_GUIDS: - case Opcode::SMSG_PET_MODE: + // ---- Pet system ---- + case Opcode::SMSG_PET_MODE: { + // uint64 petGuid, uint32 mode + // mode bits: low byte = command state, next byte = react state + if (packet.getSize() - packet.getReadPos() >= 12) { + uint64_t modeGuid = packet.readUInt64(); + uint32_t mode = packet.readUInt32(); + if (modeGuid == petGuid_) { + petCommand_ = static_cast(mode & 0xFF); + petReact_ = static_cast((mode >> 8) & 0xFF); + LOG_DEBUG("SMSG_PET_MODE: command=", (int)petCommand_, + " react=", (int)petReact_); + } + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_PET_BROKEN: - case Opcode::SMSG_PET_CAST_FAILED: + // Pet bond broken (died or forcibly dismissed) — clear pet state + petGuid_ = 0; + petSpellList_.clear(); + petAutocastSpells_.clear(); + memset(petActionSlots_, 0, sizeof(petActionSlots_)); + addSystemChatMessage("Your pet has died."); + LOG_INFO("SMSG_PET_BROKEN: pet bond broken"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_PET_LEARNED_SPELL: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t spellId = packet.readUInt32(); + petSpellList_.push_back(spellId); + LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_PET_UNLEARNED_SPELL: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t spellId = packet.readUInt32(); + petSpellList_.erase( + std::remove(petSpellList_.begin(), petSpellList_.end(), spellId), + petSpellList_.end()); + petAutocastSpells_.erase(spellId); + LOG_DEBUG("SMSG_PET_UNLEARNED_SPELL: spellId=", spellId); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_PET_CAST_FAILED: { + if (packet.getSize() - packet.getReadPos() >= 5) { + uint8_t castCount = packet.readUInt8(); + uint32_t spellId = packet.readUInt32(); + uint32_t reason = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readUInt32() : 0; + LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, + " reason=", reason, " castCount=", (int)castCount); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_PET_GUIDS: case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: - case Opcode::SMSG_PET_LEARNED_SPELL: - case Opcode::SMSG_PET_UNLEARNED_SPELL: case Opcode::SMSG_PET_UNLEARN_CONFIRM: case Opcode::SMSG_PET_NAME_INVALID: case Opcode::SMSG_PET_RENAMEABLE: @@ -12419,14 +12473,72 @@ void GameHandler::cancelAura(uint32_t spellId) { } void GameHandler::handlePetSpells(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 8) { - // Empty packet = pet dismissed/died + const size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 8) { + // Empty or undersized → pet cleared (dismissed / died) petGuid_ = 0; - LOG_INFO("SMSG_PET_SPELLS: pet cleared (empty packet)"); + petSpellList_.clear(); + petAutocastSpells_.clear(); + memset(petActionSlots_, 0, sizeof(petActionSlots_)); + LOG_INFO("SMSG_PET_SPELLS: pet cleared"); return; } + petGuid_ = packet.readUInt64(); - LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec); + if (petGuid_ == 0) { + petSpellList_.clear(); + petAutocastSpells_.clear(); + memset(petActionSlots_, 0, sizeof(petActionSlots_)); + LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)"); + return; + } + + // uint16 duration (ms, 0 = permanent), uint16 timer (ms) + if (packet.getSize() - packet.getReadPos() < 4) goto done; + /*uint16_t dur =*/ packet.readUInt16(); + /*uint16_t timer =*/ packet.readUInt16(); + + // uint8 reactState, uint8 commandState (packed order varies; WotLK: react first) + if (packet.getSize() - packet.getReadPos() < 2) goto done; + petReact_ = packet.readUInt8(); // 0=passive, 1=defensive, 2=aggressive + petCommand_ = packet.readUInt8(); // 0=stay, 1=follow, 2=attack, 3=dismiss + + // 10 × uint32 action bar slots + if (packet.getSize() - packet.getReadPos() < PET_ACTION_BAR_SLOTS * 4u) goto done; + for (int i = 0; i < PET_ACTION_BAR_SLOTS; ++i) { + petActionSlots_[i] = packet.readUInt32(); + } + + // uint8 spell count, then per-spell: uint32 spellId, uint16 active flags + if (packet.getSize() - packet.getReadPos() < 1) goto done; + { + uint8_t spellCount = packet.readUInt8(); + petSpellList_.clear(); + petAutocastSpells_.clear(); + for (uint8_t i = 0; i < spellCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 6) break; + uint32_t spellId = packet.readUInt32(); + uint16_t activeFlags = packet.readUInt16(); + petSpellList_.push_back(spellId); + // activeFlags bit 0 = autocast on + if (activeFlags & 0x0001) { + petAutocastSpells_.insert(spellId); + } + } + } + +done: + LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec, + " react=", (int)petReact_, " command=", (int)petCommand_, + " spells=", petSpellList_.size()); +} + +void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { + if (!hasPet() || state != WorldState::IN_WORLD || !socket) return; + auto pkt = PetActionPacket::build(petGuid_, action, targetGuid); + socket->send(pkt); + LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, petGuid_, + " action=0x", action, " target=0x", targetGuid, std::dec); } void GameHandler::dismissPet() { diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index b71c2834..46fdcde4 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2934,10 +2934,12 @@ network::Packet CancelAuraPacket::build(uint32_t spellId) { return packet; } -network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action) { +network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action, uint64_t targetGuid) { + // CMSG_PET_ACTION: petGuid(8) + action(4) + targetGuid(8) network::Packet packet(wireOpcode(Opcode::CMSG_PET_ACTION)); packet.writeUInt64(petGuid); packet.writeUInt32(action); + packet.writeUInt64(targetGuid); return packet; } From 6951b7803d26fb87f01b38827054ac885c949d1b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:00:21 -0700 Subject: [PATCH 40/71] game: fix SMSG_SPELL_GO miss-entry consumption in WotLK and TBC parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SpellGoParser::parse (WotLK) and TbcPacketParsers::parseSpellGo (TBC) read missCount but did not consume the per-miss (guid + missType) entries that follow, leaving unread bytes in the packet and silently corrupting any subsequent parsing of cast-flags–gated spell data. - Add SpellGoMissEntry{targetGuid, missType} and missTargets vector to SpellGoData - WotLK parser now reads packed GUIDs + missType per miss entry - TBC parser now reads full uint64 GUIDs + missType per miss entry (9 bytes per entry, bounds-checked) - handleSpellGo now shows MISS/DODGE/PARRY/BLOCK combat text for each missed target when the local player cast the spell, complementing the existing SMSG_SPELLLOGMISS path - Remove unused foliageLikeModel variable in m2_renderer pass-2 loop (fix unused-variable warning) - Update smoke model comment in m2_renderer to reflect current state --- include/game/world_packets.hpp | 8 +++++++- src/game/game_handler.cpp | 20 ++++++++++++++++++++ src/game/packet_parsers_tbc.cpp | 7 +++++++ src/game/world_packets.cpp | 8 +++++++- src/rendering/m2_renderer.cpp | 3 +-- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index a65fd4aa..affb44ad 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1766,6 +1766,11 @@ public: }; /** SMSG_SPELL_GO data (simplified) */ +struct SpellGoMissEntry { + uint64_t targetGuid = 0; + uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST +}; + struct SpellGoData { uint64_t casterGuid = 0; uint64_t casterUnit = 0; @@ -1773,8 +1778,9 @@ struct SpellGoData { uint32_t spellId = 0; uint32_t castFlags = 0; uint8_t hitCount = 0; - std::vector hitTargets; + std::vector hitTargets; uint8_t missCount = 0; + std::vector missTargets; bool isValid() const { return spellId != 0; } }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b309891f..759e4ffb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12713,6 +12713,26 @@ void GameHandler::handleSpellGo(network::Packet& packet) { castTimeRemaining = 0.0f; } + // Show miss/dodge/parry/etc combat text when player's spells miss targets + if (data.casterUnit == playerGuid && !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::MISS, // 4=EVADE → show as MISS + CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::MISS, // 7=ABSORB + CombatTextEntry::MISS, // 8=RESIST + }; + // Show text for each miss (usually just 1 target per spell go) + for (const auto& m : data.missTargets) { + CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; + addCombatText(ct, 0, 0, true); + } + } + // Play impact sound when player is hit by any spell (from self or others) bool playerIsHit = false; for (const auto& tgt : data.hitTargets) { diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index f65de114..db9d007b 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1051,6 +1051,13 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) if (packet.getReadPos() < packet.getSize()) { data.missCount = packet.readUInt8(); + data.missTargets.reserve(data.missCount); + for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) { + SpellGoMissEntry m; + m.targetGuid = packet.readUInt64(); // full GUID in TBC + m.missType = packet.readUInt8(); + data.missTargets.push_back(m); + } } LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 46fdcde4..9ef9a71a 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2987,7 +2987,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } data.missCount = packet.readUInt8(); - // Skip miss details for now + data.missTargets.reserve(data.missCount); + for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 2 <= packet.getSize(); ++i) { + SpellGoMissEntry m; + m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK + m.missType = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; + data.missTargets.push_back(m); + } LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 4f7e2d0c..b079f50a 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1185,7 +1185,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { " tris, grid ", gpuModel.collision.gridCellsX, "x", gpuModel.collision.gridCellsY); } - // Flag smoke models for UV scroll animation (particle emitters not implemented) + // Flag smoke models for UV scroll animation (in addition to particle emitters) { std::string smokeName = model.name; std::transform(smokeName.begin(), smokeName.end(), smokeName.begin(), @@ -2709,7 +2709,6 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const uint16_t targetLOD = desiredLOD; if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0; - const bool foliageLikeModel = model.isFoliageLike; const bool particleDominantEffect = model.isSpellEffect && !model.particleEmitters.empty() && model.batches.size() <= 2; From 4d39736d29b44318231b520457cfa5b95ce55b32 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:06:40 -0700 Subject: [PATCH 41/71] game/ui: add target cast bar to target frame (SMSG_SPELL_START tracking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_SPELL_START fires for all units, not just the player. Previously only the player's own cast was tracked; now we also track when the current target is casting, enabling interrupt decisions. - GameHandler: track targetCasting_/targetCastSpellId_/targetCastTimeTotal_ /targetCastTimeRemaining_ — updated by SMSG_SPELL_START for the current target and ticked down in the update loop each frame - Target cast cleared when: target changes (setTarget), target's spell lands (SMSG_SPELL_GO), or cast timer expires naturally - game_screen: renderTargetFrame shows a red cast progress bar between the power bar and distance line when the target is casting, with spell name + remaining seconds - Public accessors: isTargetCasting(), getTargetCastSpellId(), getTargetCastProgress(), getTargetCastTimeRemaining() --- include/game/game_handler.hpp | 15 +++++++++++++++ src/game/game_handler.cpp | 30 ++++++++++++++++++++++++++++++ src/ui/game_screen.cpp | 16 ++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1ca12c72..b8f66903 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -513,6 +513,16 @@ public: float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; } float getCastTimeRemaining() const { return castTimeRemaining; } + // Target cast bar (shows when the current target is casting) + bool isTargetCasting() const { return targetCasting_; } + uint32_t getTargetCastSpellId() const { return targetCastSpellId_; } + float getTargetCastProgress() const { + return targetCastTimeTotal_ > 0.0f + ? (targetCastTimeTotal_ - targetCastTimeRemaining_) / targetCastTimeTotal_ + : 0.0f; + } + float getTargetCastTimeRemaining() const { return targetCastTimeRemaining_; } + // Talents uint8_t getActiveTalentSpec() const { return activeTalentSpec_; } uint8_t getUnspentTalentPoints() const { return unspentTalentPoints_[activeTalentSpec_]; } @@ -1754,6 +1764,11 @@ private: bool casting = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; + // Target cast bar state (populated from SMSG_SPELL_START for the current target) + bool targetCasting_ = false; + uint32_t targetCastSpellId_ = 0; + float targetCastTimeRemaining_= 0.0f; + float targetCastTimeTotal_ = 0.0f; uint64_t pendingGameObjectInteractGuid_ = 0; // Talents (dual-spec support) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 759e4ffb..9b4b697f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -759,6 +759,16 @@ void GameHandler::update(float deltaTime) { } } + // Tick down target cast bar + if (targetCasting_ && targetCastTimeRemaining_ > 0.0f) { + targetCastTimeRemaining_ -= deltaTime; + if (targetCastTimeRemaining_ <= 0.0f) { + targetCasting_ = false; + targetCastSpellId_ = 0; + targetCastTimeRemaining_ = 0.0f; + } + } + // Update spell cooldowns (Phase 3) for (auto it = spellCooldowns.begin(); it != spellCooldowns.end(); ) { it->second -= deltaTime; @@ -8546,6 +8556,11 @@ void GameHandler::setTarget(uint64_t guid) { targetGuid = guid; + // Clear target cast bar when target changes + targetCasting_ = false; + targetCastSpellId_ = 0; + targetCastTimeRemaining_ = 0.0f; + // Inform server of target selection (Phase 1) if (state == WorldState::IN_WORLD && socket) { auto packet = SetSelectionPacket::build(guid); @@ -12641,6 +12656,14 @@ void GameHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; if (!packetParsers_->parseSpellStart(packet, data)) return; + // Track cast bar for the current target (for interrupt awareness) + if (data.casterUnit == targetGuid && data.castTime > 0) { + targetCasting_ = true; + targetCastSpellId_ = data.spellId; + targetCastTimeTotal_ = data.castTime / 1000.0f; + targetCastTimeRemaining_ = targetCastTimeTotal_; + } + // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { casting = true; @@ -12713,6 +12736,13 @@ void GameHandler::handleSpellGo(network::Packet& packet) { castTimeRemaining = 0.0f; } + // Clear target cast bar when the target's spell lands + if (data.casterUnit == targetGuid) { + targetCasting_ = false; + targetCastSpellId_ = 0; + targetCastTimeRemaining_ = 0.0f; + } + // Show miss/dodge/parry/etc combat text when player's spells miss targets if (data.casterUnit == playerGuid && !data.missTargets.empty()) { static const CombatTextEntry::Type missTypes[] = { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 86a46464..5a890112 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2093,6 +2093,22 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // Target cast bar — shown when the target is casting + if (gameHandler.isTargetCasting()) { + float castPct = gameHandler.getTargetCastProgress(); + float castLeft = gameHandler.getTargetCastTimeRemaining(); + uint32_t tspell = gameHandler.getTargetCastSpellId(); + const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + char castLabel[72]; + if (!castName.empty()) + snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); + else + snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + ImGui::PopStyleColor(); + } + // Distance const auto& movement = gameHandler.getMovementInfo(); float dx = target->getX() - movement.x; From 1c85b7a46d3479e330c0064bda0bdd1b62329add Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:09:58 -0700 Subject: [PATCH 42/71] ui: add combo point display to player frame (Rogue/Druid) Adds 5 gold/grey dot indicators below the power bar in the player frame for Rogue (class 4) and Druid (class 11), showing the current combo point count from SMSG_UPDATE_COMBO_POINTS. Active points are bright gold; empty slots are dark grey. Dots are centered in the frame width. The display is always shown for Rogues; for Druids it only appears when combo points are non-zero (they only accumulate in Cat Form). --- src/ui/game_screen.cpp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5a890112..1d103bcd 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1868,6 +1868,37 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } ImGui::Dummy(ImVec2(totalW, squareH)); } + + // Combo point display — Rogue (4) and Druid (11) in Cat Form + { + uint8_t cls = gameHandler.getPlayerClass(); + const bool isRogue = (cls == 4); + const bool isDruid = (cls == 11); + if (isRogue || isDruid) { + uint8_t cp = gameHandler.getComboPoints(); + if (cp > 0 || isRogue) { // always show for rogue; only when non-zero for druid + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + constexpr int MAX_CP = 5; + constexpr float DOT_R = 7.0f; + constexpr float SPACING = 4.0f; + float totalDotsW = MAX_CP * (DOT_R * 2.0f) + (MAX_CP - 1) * SPACING; + float startX = cursor.x + (totalW - totalDotsW) * 0.5f; + float cy = cursor.y + DOT_R; + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (int i = 0; i < MAX_CP; ++i) { + float cx = startX + i * (DOT_R * 2.0f + SPACING) + DOT_R; + ImU32 col = (i < static_cast(cp)) + ? IM_COL32(255, 210, 0, 240) // bright gold — active + : IM_COL32(60, 60, 60, 160); // dark — empty + dl->AddCircleFilled(ImVec2(cx, cy), DOT_R, col); + dl->AddCircle(ImVec2(cx, cy), DOT_R, IM_COL32(160, 140, 0, 180), 0, 1.5f); + } + ImGui::Dummy(ImVec2(totalW, DOT_R * 2.0f)); + } + } + } } ImGui::End(); From 07d0485a3196fe9ada48d0626615d8fb03201b8a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:13:30 -0700 Subject: [PATCH 43/71] game/ui: generalize cast tracking to per-GUID map; add boss cast bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the target cast bar tracked a single target using 4 private fields. This replaces that with unitCastStates_ (unordered_map), tracking cast state for every non-player unit whose SMSG_SPELL_START we receive. Changes: - GameHandler::UnitCastState struct: casting, spellId, timeRemaining, timeTotal - getUnitCastState(guid) → returns cast state for any tracked unit - isTargetCasting(), getTargetCastSpellId(), getTargetCastProgress(), getTargetCastTimeRemaining() now delegate to getUnitCastState(targetGuid) - handleSpellStart: tracks all non-player casters (not just the target) - handleSpellGo: erases caster from map when spell lands - update loop: ticks down all unit cast states, erasing expired entries - unitCastStates_ cleared on world reset - renderBossFrames: shows red cast progress bar per boss slot with spell name + remaining seconds — critical for instance interrupt play --- include/game/game_handler.hpp | 41 +++++++++++++++++++++---------- src/game/game_handler.cpp | 45 +++++++++++++++++------------------ src/ui/game_screen.cpp | 18 ++++++++++++++ 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b8f66903..260e4c74 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -513,15 +513,33 @@ public: float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; } float getCastTimeRemaining() const { return castTimeRemaining; } - // Target cast bar (shows when the current target is casting) - bool isTargetCasting() const { return targetCasting_; } - uint32_t getTargetCastSpellId() const { return targetCastSpellId_; } - float getTargetCastProgress() const { - return targetCastTimeTotal_ > 0.0f - ? (targetCastTimeTotal_ - targetCastTimeRemaining_) / targetCastTimeTotal_ - : 0.0f; + // Unit cast state (tracked per GUID for target frame + boss frames) + struct UnitCastState { + bool casting = false; + uint32_t spellId = 0; + float timeRemaining = 0.0f; + float timeTotal = 0.0f; + }; + // Returns cast state for any unit by GUID (empty/non-casting if not found) + const UnitCastState* getUnitCastState(uint64_t guid) const { + auto it = unitCastStates_.find(guid); + return (it != unitCastStates_.end() && it->second.casting) ? &it->second : nullptr; + } + // Convenience helpers for the current target + bool isTargetCasting() const { return getUnitCastState(targetGuid) != nullptr; } + uint32_t getTargetCastSpellId() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->spellId : 0; + } + float getTargetCastProgress() const { + auto* s = getUnitCastState(targetGuid); + return (s && s->timeTotal > 0.0f) + ? (s->timeTotal - s->timeRemaining) / s->timeTotal : 0.0f; + } + float getTargetCastTimeRemaining() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->timeRemaining : 0.0f; } - float getTargetCastTimeRemaining() const { return targetCastTimeRemaining_; } // Talents uint8_t getActiveTalentSpec() const { return activeTalentSpec_; } @@ -1764,11 +1782,8 @@ private: bool casting = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; - // Target cast bar state (populated from SMSG_SPELL_START for the current target) - bool targetCasting_ = false; - uint32_t targetCastSpellId_ = 0; - float targetCastTimeRemaining_= 0.0f; - float targetCastTimeTotal_ = 0.0f; + // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) + std::unordered_map unitCastStates_; uint64_t pendingGameObjectInteractGuid_ = 0; // Talents (dual-spec support) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9b4b697f..9d35d7af 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -759,14 +759,17 @@ void GameHandler::update(float deltaTime) { } } - // Tick down target cast bar - if (targetCasting_ && targetCastTimeRemaining_ > 0.0f) { - targetCastTimeRemaining_ -= deltaTime; - if (targetCastTimeRemaining_ <= 0.0f) { - targetCasting_ = false; - targetCastSpellId_ = 0; - targetCastTimeRemaining_ = 0.0f; + // Tick down all tracked unit cast bars + for (auto it = unitCastStates_.begin(); it != unitCastStates_.end(); ) { + auto& s = it->second; + if (s.casting && s.timeRemaining > 0.0f) { + s.timeRemaining -= deltaTime; + if (s.timeRemaining <= 0.0f) { + it = unitCastStates_.erase(it); + continue; + } } + ++it; } // Update spell cooldowns (Phase 3) @@ -5700,6 +5703,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { actionBar = {}; playerAuras.clear(); targetAuras.clear(); + unitCastStates_.clear(); petGuid_ = 0; playerXp_ = 0; playerNextLevelXp_ = 0; @@ -8556,10 +8560,8 @@ void GameHandler::setTarget(uint64_t guid) { targetGuid = guid; - // Clear target cast bar when target changes - targetCasting_ = false; - targetCastSpellId_ = 0; - targetCastTimeRemaining_ = 0.0f; + // Clear previous target's cast bar on target change + // (the new target's cast state is naturally fetched from unitCastStates_ by GUID) // Inform server of target selection (Phase 1) if (state == WorldState::IN_WORLD && socket) { @@ -12656,12 +12658,13 @@ void GameHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; if (!packetParsers_->parseSpellStart(packet, data)) return; - // Track cast bar for the current target (for interrupt awareness) - if (data.casterUnit == targetGuid && data.castTime > 0) { - targetCasting_ = true; - targetCastSpellId_ = data.spellId; - targetCastTimeTotal_ = data.castTime / 1000.0f; - targetCastTimeRemaining_ = targetCastTimeTotal_; + // Track cast bar for any non-player caster (target frame + boss frames) + if (data.casterUnit != playerGuid && data.castTime > 0) { + auto& s = unitCastStates_[data.casterUnit]; + s.casting = true; + s.spellId = data.spellId; + s.timeTotal = data.castTime / 1000.0f; + s.timeRemaining = s.timeTotal; } // If this is the player's own cast, start cast bar @@ -12736,12 +12739,8 @@ void GameHandler::handleSpellGo(network::Packet& packet) { castTimeRemaining = 0.0f; } - // Clear target cast bar when the target's spell lands - if (data.casterUnit == targetGuid) { - targetCasting_ = false; - targetCastSpellId_ = 0; - targetCastTimeRemaining_ = 0.0f; - } + // Clear unit cast bar when the spell lands (for any tracked unit) + unitCastStates_.erase(data.casterUnit); // Show miss/dodge/parry/etc combat text when player's spells miss targets if (data.casterUnit == playerGuid && !data.missTargets.empty()) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1d103bcd..8a609dd3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5010,6 +5010,24 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Boss cast bar — shown when the boss is casting (critical for interrupt) + if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { + float castPct = (cs->timeTotal > 0.0f) + ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; + uint32_t bspell = cs->spellId; + const std::string& bcastName = (bspell != 0) + ? gameHandler.getSpellName(bspell) : ""; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + char bcastLabel[72]; + if (!bcastName.empty()) + snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", + bcastName.c_str(), cs->timeRemaining); + else + snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + ImGui::PopStyleColor(); + } + ImGui::PopID(); ImGui::Spacing(); } From 640eaacb8c6d2393306a802b591c6de3217285e3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:16:15 -0700 Subject: [PATCH 44/71] game: clear unit cast bars on SMSG_SPELL_FAILURE and SMSG_SPELL_FAILED_OTHER When a spell fails or is interrupted, the server sends SMSG_SPELL_FAILURE (for the caster's own POV) or SMSG_SPELL_FAILED_OTHER (for observers). Previously these were consumed without updating cast state, leaving stale cast bars for interrupted enemies. Now: - SMSG_SPELL_FAILURE: erases unitCastStates_[failGuid] for non-player casters (still clears player casting/currentCastSpellId for own casts) - SMSG_SPELL_FAILED_OTHER: erases unitCastStates_[guid] for the caster so boss/enemy cast bars immediately clear on interrupt/kick --- src/game/game_handler.cpp | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9d35d7af..543236ea 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1779,10 +1779,15 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Spell failed on another unit ---- - case Opcode::SMSG_SPELL_FAILED_OTHER: - // packed_guid + uint8 castCount + uint32 spellId + uint8 reason — just consume + case Opcode::SMSG_SPELL_FAILED_OTHER: { + // packed_guid + uint8 castCount + uint32 spellId + uint8 reason + uint64_t failOtherGuid = UpdateObjectParser::readPackedGuid(packet); + if (failOtherGuid != 0 && failOtherGuid != playerGuid) { + unitCastStates_.erase(failOtherGuid); + } packet.setReadPos(packet.getSize()); break; + } // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { @@ -2526,16 +2531,24 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPELL_GO: handleSpellGo(packet); break; - case Opcode::SMSG_SPELL_FAILURE: - // Spell failed mid-cast - casting = false; - currentCastSpellId = 0; - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* ssm = renderer->getSpellSoundManager()) { - ssm->stopPrecast(); + case Opcode::SMSG_SPELL_FAILURE: { + // packed_guid caster + uint8 castCount + uint32 spellId + uint8 failReason + uint64_t failGuid = UpdateObjectParser::readPackedGuid(packet); + if (failGuid == playerGuid || failGuid == 0) { + // Player's own cast failed + casting = false; + currentCastSpellId = 0; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + ssm->stopPrecast(); + } } + } else { + // Another unit's cast failed — clear their tracked cast bar + unitCastStates_.erase(failGuid); } break; + } case Opcode::SMSG_SPELL_COOLDOWN: handleSpellCooldown(packet); break; From d72912714b9771b15fcd88df4e25eae561d1515b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:20:15 -0700 Subject: [PATCH 45/71] game: fix SMSG_SPELL_FAILURE GUID format for TBC/Classic vs WotLK WotLK uses packed GUIDs in SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER. TBC 2.4.3 and Classic 1.12 use full uint64 GUIDs. The previous fix used UpdateObjectParser::readPackedGuid for all expansions, which would mis-parse the caster GUID on TBC/Classic servers, leaving stale cast bars and potentially corrupting subsequent packet reads. Now checks isClassicLikeExpansion() || isActiveExpansion("tbc") and reads a raw uint64 for those expansions, matching the TBC/Classic wire format used in parseSpellStart/parseSpellGo overrides. --- src/game/game_handler.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 543236ea..c7a7eefa 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1780,8 +1780,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell failed on another unit ---- case Opcode::SMSG_SPELL_FAILED_OTHER: { - // packed_guid + uint8 castCount + uint32 spellId + uint8 reason - uint64_t failOtherGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 reason + // TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 reason + const bool tbcLike2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t failOtherGuid = tbcLike2 + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); if (failOtherGuid != 0 && failOtherGuid != playerGuid) { unitCastStates_.erase(failOtherGuid); } @@ -2532,8 +2536,12 @@ void GameHandler::handlePacket(network::Packet& packet) { handleSpellGo(packet); break; case Opcode::SMSG_SPELL_FAILURE: { - // packed_guid caster + uint8 castCount + uint32 spellId + uint8 failReason - uint64_t failGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 failReason + // TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 failReason + const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t failGuid = tbcOrClassic + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed casting = false; From f31fa2961686e4aa449cbcd9a48bb34c13b19b22 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:36:14 -0700 Subject: [PATCH 46/71] game/ui: add channeled spell cast tracking and party cast bars - Handle MSG_CHANNEL_START: populate unitCastStates_ for both the local player and any non-player caster (boss/mob channeled spells); use full uint64 GUIDs for TBC/Classic, packed GUIDs for WotLK - Handle MSG_CHANNEL_UPDATE: sync remaining channel time; clear cast state on channel completion (remainingMs == 0) - Fix SMSG_RESUME_CAST_BAR: also resumes non-player units' cast bars (previously only resumed the player's own bar after zone transitions) - Add party member cast bars in renderPartyFrames: golden progress bar appears beneath the power bar when a party member is casting, leveraging the existing unitCastStates_ per-GUID map --- src/game/game_handler.cpp | 76 +++++++++++++++++++++++++++++++++++---- src/ui/game_screen.cpp | 15 ++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c7a7eefa..fa55ee0e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4854,16 +4854,78 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t spellId = packet.readUInt32(); uint32_t remainMs = packet.readUInt32(); uint32_t totalMs = packet.readUInt32(); - if (caster == playerGuid && totalMs > 0) { - casting = true; - currentCastSpellId = spellId; - castTimeTotal = totalMs / 1000.0f; - castTimeRemaining = remainMs / 1000.0f; - LOG_DEBUG("SMSG_RESUME_CAST_BAR: spell=", spellId, - " remaining=", remainMs, "ms total=", totalMs, "ms"); + if (totalMs > 0) { + if (caster == playerGuid) { + casting = true; + currentCastSpellId = spellId; + castTimeTotal = totalMs / 1000.0f; + castTimeRemaining = remainMs / 1000.0f; + } else { + auto& s = unitCastStates_[caster]; + s.casting = true; + s.spellId = spellId; + s.timeTotal = totalMs / 1000.0f; + s.timeRemaining = remainMs / 1000.0f; + } + LOG_DEBUG("SMSG_RESUME_CAST_BAR: caster=0x", std::hex, caster, std::dec, + " spell=", spellId, " remaining=", remainMs, "ms total=", totalMs, "ms"); } break; } + // ---- Channeled spell start/tick (WotLK: packed GUIDs; TBC/Classic: full uint64) ---- + case Opcode::MSG_CHANNEL_START: { + // casterGuid + uint32 spellId + uint32 totalDurationMs + const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t chanCaster = tbcOrClassic + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t chanSpellId = packet.readUInt32(); + uint32_t chanTotalMs = packet.readUInt32(); + if (chanTotalMs > 0 && chanCaster != 0) { + if (chanCaster == playerGuid) { + casting = true; + currentCastSpellId = chanSpellId; + castTimeTotal = chanTotalMs / 1000.0f; + castTimeRemaining = castTimeTotal; + } else { + auto& s = unitCastStates_[chanCaster]; + s.casting = true; + s.spellId = chanSpellId; + s.timeTotal = chanTotalMs / 1000.0f; + s.timeRemaining = s.timeTotal; + } + LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, + " spell=", chanSpellId, " total=", chanTotalMs, "ms"); + } + break; + } + case Opcode::MSG_CHANNEL_UPDATE: { + // casterGuid + uint32 remainingMs + const bool tbcOrClassic2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t chanCaster2 = tbcOrClassic2 + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t chanRemainMs = packet.readUInt32(); + if (chanCaster2 == playerGuid) { + castTimeRemaining = chanRemainMs / 1000.0f; + if (chanRemainMs == 0) { + casting = false; + currentCastSpellId = 0; + } + } else if (chanCaster2 != 0) { + auto it = unitCastStates_.find(chanCaster2); + if (it != unitCastStates_.end()) { + it->second.timeRemaining = chanRemainMs / 1000.0f; + if (chanRemainMs == 0) unitCastStates_.erase(it); + } + } + LOG_DEBUG("MSG_CHANNEL_UPDATE: caster=0x", std::hex, chanCaster2, std::dec, + " remaining=", chanRemainMs, "ms"); + break; + } + case Opcode::SMSG_THREAT_UPDATE: { // packed_guid (unit) + packed_guid (target) + uint32 count // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8a609dd3..50c2a062 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4937,6 +4937,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Party member cast bar — shows when the party member is casting + if (auto* cs = gameHandler.getUnitCastState(member.guid)) { + float castPct = (cs->timeTotal > 0.0f) + ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.8f, 0.2f, 1.0f)); + char pcastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(cs->spellId); + if (!spellNm.empty()) + snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); + else + snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + ImGui::PopStyleColor(); + } + ImGui::Separator(); ImGui::PopID(); } From 011b1c82951cccc4435706d96bcaeed026a72510 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:39:00 -0700 Subject: [PATCH 47/71] game: fix SMSG_SPELL_DELAYED to also extend non-player cast bars Previously SMSG_SPELL_DELAYED only adjusted the local player's cast bar. Now it also extends unitCastStates_ for any non-player caster (e.g. boss cast bar extends correctly when hit by a tank during cast). --- src/game/game_handler.cpp | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fa55ee0e..7c6a956b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3176,12 +3176,19 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: case Opcode::SMSG_SPELL_DELAYED: { // packed_guid (caster) + uint32 delayMs — spell cast was pushed back - // Adjust cast bar if it's the player's spell - if (casting && packet.getSize() - packet.getReadPos() >= 1) { - uint64_t caster = UpdateObjectParser::readPackedGuid(packet); - if (caster == playerGuid && packet.getSize() - packet.getReadPos() >= 4) { - uint32_t delayMs = packet.readUInt32(); - castTimeRemaining += delayMs / 1000.0f; // Extend cast bar by delay + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t caster = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t delayMs = packet.readUInt32(); + if (delayMs == 0) break; + float delaySec = delayMs / 1000.0f; + if (caster == playerGuid) { + if (casting) castTimeRemaining += delaySec; + } else { + auto it = unitCastStates_.find(caster); + if (it != unitCastStates_.end() && it->second.casting) { + it->second.timeRemaining += delaySec; + it->second.timeTotal += delaySec; } } break; From e11d0956fb2df501170fa2941643921d1c53646e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:45:10 -0700 Subject: [PATCH 48/71] game: fix TBC/Classic GUID format for SPELLLOGMISS, IMMUNE, and SPELLDISPELLOG --- src/game/game_handler.cpp | 46 ++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7c6a956b..f339c4d7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2233,12 +2233,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { - // packed_guid caster + packed_guid target + uint8 isCrit + uint32 count + // WotLK: packed_guid caster + packed_guid target + uint8 isCrit + uint32 count + // TBC/Classic: full uint64 caster + full uint64 target + uint8 isCrit + uint32 count // + count × (uint64 victimGuid + uint8 missInfo) - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) break; - /*uint64_t targetGuidLog =*/ UpdateObjectParser::readPackedGuid(packet); + const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto readSpellMissGuid = [&]() -> uint64_t { + if (spellMissTbcLike) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + uint64_t casterGuid = readSpellMissGuid(); + if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + /*uint64_t targetGuidLog =*/ readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t isCrit =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); @@ -4751,13 +4758,18 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { - // casterGuid(packed) + victimGuid(packed) + uint32 spellId + uint8 saveType - if (packet.getSize() - packet.getReadPos() < 2) { + // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType + // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 + const bool immuneTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const size_t minSz = immuneTbcLike ? 21u : 2u; + if (packet.getSize() - packet.getReadPos() < minSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = immuneTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (immuneTbcLike ? 8u : 2u)) break; + uint64_t victimGuid = immuneTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint32_t spellId =*/ packet.readUInt32(); /*uint8_t saveType =*/ packet.readUInt8(); @@ -4770,14 +4782,18 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLDISPELLOG: { - // packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // WotLK: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) - if (packet.getSize() - packet.getReadPos() < 2) { + const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = dispelTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) break; + uint64_t victimGuid = dispelTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) break; /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); From abf9ef0b5fbc4db6b21110e8a9f5127a61183518 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:48:06 -0700 Subject: [PATCH 49/71] game: fix expansion-gated GUIDs for PERIODICAURALOG, SPELLENERGIZELOG, SPELL_DELAYED; separate FEATURE_SYSTEM_STATUS/SPELL_MODIFIER from SPELL_DELAYED case --- src/game/game_handler.cpp | 41 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f339c4d7..f7244a7f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3181,10 +3181,18 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: + // Different formats than SMSG_SPELL_DELAYED — consume and ignore + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_SPELL_DELAYED: { - // packed_guid (caster) + uint32 delayMs — spell cast was pushed back - if (packet.getSize() - packet.getReadPos() < 1) break; - uint64_t caster = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid (caster) + uint32 delayMs + // TBC/Classic: uint64 (caster) + uint32 delayMs + const bool spellDelayTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (spellDelayTbcLike ? 8u : 1u)) break; + uint64_t caster = spellDelayTbcLike + ? packet.readUInt64() + : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t delayMs = packet.readUInt32(); if (delayMs == 0) break; @@ -3205,11 +3213,16 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_DEBUG("Equipment set saved"); break; case Opcode::SMSG_PERIODICAURALOG: { - // packed_guid victim, packed_guid caster, uint32 spellId, uint32 count, then per-effect - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects + // TBC/Classic: uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects + const bool periodicTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const size_t guidMinSz = periodicTbcLike ? 8u : 2u; + if (packet.getSize() - packet.getReadPos() < guidMinSz) break; + uint64_t victimGuid = periodicTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < guidMinSz) break; + uint64_t casterGuid = periodicTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t spellId = packet.readUInt32(); uint32_t count = packet.readUInt32(); @@ -3248,11 +3261,15 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLENERGIZELOG: { - // packed victim GUID, packed caster GUID, uint32 spellId, uint8 powerType, int32 amount + // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint8 powerType + int32 amount + // TBC/Classic: uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount + const bool energizeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 4) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem < (energizeTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = energizeTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = energizeTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); rem = packet.getSize() - packet.getReadPos(); if (rem < 6) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); From e122d725f6b90a22b8a24e80ae3818633ad5a3dd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:51:01 -0700 Subject: [PATCH 50/71] game: fix expansion-gated GUIDs for HEALTH_UPDATE, POWER_UPDATE, COMBO_POINTS --- src/game/game_handler.cpp | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f7244a7f..0cd7dca3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1644,9 +1644,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Entity health/power delta updates ---- case Opcode::SMSG_HEALTH_UPDATE: { - // packed_guid + uint32 health - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + uint32 health + // TBC/Classic: uint64 + uint32 health + const bool huTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (huTbcLike ? 8u : 2u)) break; + uint64_t guid = huTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t hp = packet.readUInt32(); auto entity = entityManager.getEntity(guid); @@ -1656,9 +1659,12 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_POWER_UPDATE: { - // packed_guid + uint8 powerType + uint32 value - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + uint8 powerType + uint32 value + // TBC/Classic: uint64 + uint8 powerType + uint32 value + const bool puTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (puTbcLike ? 8u : 2u)) break; + uint64_t guid = puTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint8_t powerType = packet.readUInt8(); uint32_t value = packet.readUInt32(); @@ -1703,9 +1709,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Combo points ---- case Opcode::SMSG_UPDATE_COMBO_POINTS: { - // packed_guid (target) + uint8 points - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t target = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid (target) + uint8 points + // TBC/Classic: uint64 (target) + uint8 points + const bool cpTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (cpTbcLike ? 8u : 2u)) break; + uint64_t target = cpTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; comboPoints_ = packet.readUInt8(); comboTarget_ = target; From deea701222c834794457051b313aab3e21503ff2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:53:43 -0700 Subject: [PATCH 51/71] game: fix expansion-gated GUIDs for PARTY_MEMBER_STATS and MINIMAP_PING --- src/game/game_handler.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0cd7dca3..f7ef872f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2646,9 +2646,12 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("Your fish escaped!"); break; case Opcode::MSG_MINIMAP_PING: { - // SMSG: packed_guid + float posX (canonical WoW Y=west) + float posY (canonical WoW X=north) - if (packet.getSize() - packet.getReadPos() < 1) break; - uint64_t senderGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + float posX + float posY + // TBC/Classic: uint64 + float posX + float posY + const bool mmTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (mmTbcLike ? 8u : 1u)) break; + uint64_t senderGuid = mmTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; float pingX = packet.readFloat(); // server sends map-coord X (east-west) float pingY = packet.readFloat(); // server sends map-coord Y (north-south) @@ -13259,7 +13262,11 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { packet.readUInt8(); } - uint64_t memberGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK uses packed GUID; TBC/Classic use full uint64 + const bool pmsTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (remaining() < (pmsTbcLike ? 8u : 1u)) return; + uint64_t memberGuid = pmsTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (remaining() < 4) return; uint32_t updateFlags = packet.readUInt32(); From 3d2bade521f970c21da3bb4a3abbcbce55b6996b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 9 Mar 2026 23:58:15 -0700 Subject: [PATCH 52/71] game: fix expansion-gated GUIDs for movement handlers (FORCE_SPEED, FORCE_FLAG, KNOCK_BACK, other-player relayed moves) --- src/game/game_handler.cpp | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f7ef872f..e9651a45 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10997,8 +10997,10 @@ void GameHandler::dismount() { void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage) { - // Packed GUID - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed GUID; TBC/Classic: full uint64 + const bool fscTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t guid = fscTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); // uint32 counter uint32_t counter = packet.readUInt32(); @@ -11144,8 +11146,11 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set) { - if (packet.getSize() - packet.getReadPos() < 2) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed GUID; TBC/Classic: full uint64 + const bool fmfTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (fmfTbcLike ? 8u : 2u)) return; + uint64_t guid = fmfTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); @@ -11200,8 +11205,11 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* } void GameHandler::handleMoveKnockBack(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 2) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed GUID; TBC/Classic: full uint64 + const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (mkbTbc ? 8u : 2u)) return; + uint64_t guid = mkbTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4) uint32_t counter = packet.readUInt32(); [[maybe_unused]] float vcos = packet.readFloat(); @@ -11940,8 +11948,10 @@ void GameHandler::handleArenaError(network::Packet& packet) { } void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { - // Server relays MSG_MOVE_* for other players: PackedGuid + MovementInfo - uint64_t moverGuid = UpdateObjectParser::readPackedGuid(packet); + // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) + const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t moverGuid = otherMoveTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (moverGuid == playerGuid || moverGuid == 0) { return; // Skip our own echoes } From 9cf331fdab945dc4c7ecf86cfa6046be508b351f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:00:21 -0700 Subject: [PATCH 53/71] game: fix expansion-gated GUIDs for RESUME_CAST_BAR, TALENTS_INFO, and TELEPORT_ACK --- src/game/game_handler.cpp | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e9651a45..f15b4e3d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4895,13 +4895,16 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_RESUME_CAST_BAR: { - // packed_guid caster + packed_guid target + uint32 spellId - // + uint32 remainingMs + uint32 totalMs + uint8 schoolMask + // WotLK: packed_guid caster + packed_guid target + uint32 spellId + uint32 remainingMs + uint32 totalMs + uint8 schoolMask + // TBC/Classic: uint64 caster + uint64 target + ... + const bool rcbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (remaining() < 1) break; - uint64_t caster = UpdateObjectParser::readPackedGuid(packet); - if (remaining() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); // target + if (remaining() < (rcbTbc ? 8u : 1u)) break; + uint64_t caster = rcbTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (remaining() < (rcbTbc ? 8u : 1u)) break; + if (rcbTbc) packet.readUInt64(); // target (discard) + else (void)UpdateObjectParser::readPackedGuid(packet); // target if (remaining() < 12) break; uint32_t spellId = packet.readUInt32(); uint32_t remainMs = packet.readUInt32(); @@ -10082,9 +10085,12 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } // talentType == 1: inspect result - if (packet.getSize() - packet.getReadPos() < 2) return; + // WotLK: packed GUID; TBC: full uint64 + const bool talentTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (talentTbc ? 8u : 2u)) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + uint64_t guid = talentTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (guid == 0) return; size_t bytesLeft = packet.getSize() - packet.getReadPos(); @@ -15390,14 +15396,17 @@ void GameHandler::addSystemChatMessage(const std::string& message) { // ============================================================ void GameHandler::handleTeleportAck(network::Packet& packet) { - // MSG_MOVE_TELEPORT_ACK (server→client): packedGuid + u32 counter + u32 time - // followed by movement info with the new position - if (packet.getSize() - packet.getReadPos() < 4) { + // MSG_MOVE_TELEPORT_ACK (server→client): + // WotLK: packed GUID + u32 counter + u32 time + movement info with new position + // TBC/Classic: uint64 + u32 counter + u32 time + movement info + const bool taTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (taTbc ? 8u : 4u)) { LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short"); return; } - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + uint64_t guid = taTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); From 5d2bc9503d107743b5aab4c9b2a89a7389e565e6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:06:11 -0700 Subject: [PATCH 54/71] game: fix expansion-gated GUID for FORCE_MOVE_ROOT/UNROOT --- src/game/game_handler.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f15b4e3d..0f120d31 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11093,10 +11093,13 @@ void GameHandler::handleForceRunSpeedChange(network::Packet& packet) { void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) { // Packet is server movement control update: - // packedGuid + uint32 counter + [optional unknown field(s)]. + // WotLK: packed GUID + uint32 counter + [optional unknown field(s)] + // TBC/Classic: full uint64 + uint32 counter // We always ACK with current movement state, same pattern as speed-change ACKs. - if (packet.getSize() - packet.getReadPos() < 2) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + const bool rootTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (rootTbc ? 8u : 2u)) return; + uint64_t guid = rootTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); From c011d724c6d7dcf58cdd0f1187c7d3b9b23fd1ed Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:16:13 -0700 Subject: [PATCH 55/71] game: implement SMSG_RESISTLOG combat text (resist/miss display for all expansions) --- src/game/game_handler.cpp | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0f120d31..e8a02d5d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5194,9 +5194,33 @@ void GameHandler::handlePacket(network::Packet& packet) { break; // ---- Resistance/combat log ---- - case Opcode::SMSG_RESISTLOG: + case Opcode::SMSG_RESISTLOG: { + // WotLK: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId + // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... + // TBC/Classic: same but full uint64 GUIDs + // Show RESIST combat text when player resists an incoming spell. + const bool rlTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } + /*uint32_t hitInfo =*/ packet.readUInt32(); + if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t attackerGuid = rlTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = rlTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } + uint32_t spellId = packet.readUInt32(); + (void)attackerGuid; + // Show RESIST when player is the victim; show as caster-side MISS when player is attacker + if (victimGuid == playerGuid) { + addCombatText(CombatTextEntry::MISS, 0, spellId, false); + } else if (attackerGuid == playerGuid) { + addCombatText(CombatTextEntry::MISS, 0, spellId, true); + } packet.setReadPos(packet.getSize()); break; + } // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: From 5f06c18a543b56e20db3e3827dce35aaca61e379 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:24:16 -0700 Subject: [PATCH 56/71] game: add Classic 1.12 overrides for parseSpellStart and parseSpellGo Vanilla 1.12 SMSG_SPELL_START and SMSG_SPELL_GO use: - PackedGuid (variable-length) for caster and target GUIDs, not full uint64 - uint16 castFlags, not uint32 as in TBC/WotLK - uint16 targetFlags in SpellCastTargets, not uint32 Without these overrides Classic inherited TBC's implementations which read 8 bytes for each GUID (over-reading the PackedGuid) and then 4 bytes for castFlags instead of 2, misaligning all subsequent fields and producing garbage spell IDs, cast times, and target GUIDs. Hit and miss target GUIDs in SMSG_SPELL_GO are also PackedGuid in Vanilla (vs full uint64 in TBC), handled by the new parseSpellGo. --- include/game/packet_parsers.hpp | 3 + src/game/packet_parsers_classic.cpp | 92 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 933ce955..2d7cd1e7 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -405,6 +405,9 @@ public: bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); } + // Classic 1.12 uses PackedGuid (not full uint64) and uint16 castFlags (not uint32) + bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; + bool parseSpellGo(network::Packet& packet, SpellGoData& data) override; }; /** diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 60a282dc..bfc6cdc4 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -314,6 +314,98 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo return packet; } +// ============================================================================ +// Classic parseSpellStart — Vanilla 1.12 SMSG_SPELL_START +// +// Key differences from TBC: +// - GUIDs are PackedGuid (variable-length byte mask + non-zero bytes), +// NOT full uint64 as in TBC/WotLK. +// - castFlags is uint16 (NOT uint32 as in TBC/WotLK). +// - SpellCastTargets uses uint16 targetFlags (NOT uint32 as in TBC). +// +// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount) +// + uint32(spellId) + uint16(castFlags) + uint32(castTime) +// + uint16(targetFlags) [+ PackedGuid(unitTarget) if TARGET_FLAG_UNIT] +// ============================================================================ +bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 1) return false; + data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + + // uint8 castCount + uint32 spellId + uint16 castFlags + uint32 castTime = 11 bytes + if (rem() < 11) return false; + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) + data.castTime = packet.readUInt32(); + + // SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK) + 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) { + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + } + + LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + return true; +} + +// ============================================================================ +// Classic parseSpellGo — Vanilla 1.12 SMSG_SPELL_GO +// +// Same GUID and castFlags format differences as parseSpellStart: +// - GUIDs are PackedGuid (not full uint64) +// - castFlags is uint16 (not uint32) +// - Hit/miss target GUIDs are also PackedGuid in Vanilla +// +// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount) +// + uint32(spellId) + uint16(castFlags) +// + uint8(hitCount) + [PackedGuid(hitTarget) × hitCount] +// + uint8(missCount) + [PackedGuid(missTarget) + uint8(missType)] × missCount +// ============================================================================ +bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 1) return false; + data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + + // uint8 castCount + uint32 spellId + uint16 castFlags = 7 bytes + if (rem() < 7) return false; + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) + + // Hit targets + if (rem() < 1) return true; + data.hitCount = packet.readUInt8(); + data.hitTargets.reserve(data.hitCount); + for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) { + data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); + } + + // Miss targets + if (rem() < 1) return true; + data.missCount = packet.readUInt8(); + data.missTargets.reserve(data.missCount); + for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) { + SpellGoMissEntry m; + m.targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 1) break; + m.missType = packet.readUInt8(); + data.missTargets.push_back(m); + } + + LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, + " misses=", (int)data.missCount); + return true; +} + // ============================================================================ // Classic SMSG_CAST_FAILED: no castCount byte (added in TBC/WotLK) // Format: spellId(u32) + result(u8) From b15a21a957a34a32535b564fbfac15d573ae4f8e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:27:08 -0700 Subject: [PATCH 57/71] game: add Classic 1.12 overrides for melee/spell damage log packets Vanilla 1.12 SMSG_ATTACKERSTATEUPDATE, SMSG_SPELLNONMELEEDAMAGELOG, and SMSG_SPELLHEALLOG use PackedGuid for all entity GUIDs, not full uint64 as TBC and WotLK do. Without these overrides Classic inherited TBC's implementations, which over-read PackedGuid fields as fixed 8-byte GUIDs, misaligning all subsequent damage/heal fields and making combat parsing unusable on Classic servers. The Classic override logic is identical to TBC except for the GUID reads, so combat text, damage numbers, and kill tracking now work correctly on Vanilla 1.12 connections. --- include/game/packet_parsers.hpp | 4 + src/game/packet_parsers_classic.cpp | 109 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 2d7cd1e7..d34df5d5 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -408,6 +408,10 @@ public: // Classic 1.12 uses PackedGuid (not full uint64) and uint16 castFlags (not uint32) bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; bool parseSpellGo(network::Packet& packet, SpellGoData& data) override; + // Classic 1.12 melee/spell log packets use PackedGuid (not full uint64) + bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override; + bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; + bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override; }; /** diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index bfc6cdc4..bd65e03d 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -406,6 +406,115 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da return true; } +// ============================================================================ +// Classic parseAttackerStateUpdate — Vanilla 1.12 SMSG_ATTACKERSTATEUPDATE +// +// Identical to TBC format except GUIDs are PackedGuid (not full uint64). +// Format: uint32(hitInfo) + PackedGuid(attacker) + PackedGuid(target) +// + int32(totalDamage) + uint8(subDamageCount) +// + [per sub: uint32(schoolMask) + float(damage) + uint32(intDamage) +// + uint32(absorbed) + uint32(resisted)] +// + uint32(victimState) + int32(overkill) [+ uint32(blocked)] +// ============================================================================ +bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 5) return false; // hitInfo(4) + at least GUID mask byte(1) + + data.hitInfo = packet.readUInt32(); + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + if (rem() < 1) return false; + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + + if (rem() < 5) 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) { + SubDamage sub; + sub.schoolMask = packet.readUInt32(); + sub.damage = packet.readFloat(); + sub.intDamage = packet.readUInt32(); + sub.absorbed = packet.readUInt32(); + sub.resisted = packet.readUInt32(); + data.subDamages.push_back(sub); + } + + if (rem() < 8) return true; + data.victimState = packet.readUInt32(); + data.overkill = static_cast(packet.readUInt32()); + + if (rem() >= 4) { + data.blocked = packet.readUInt32(); + } + + LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); + return true; +} + +// ============================================================================ +// Classic parseSpellDamageLog — Vanilla 1.12 SMSG_SPELLNONMELEEDAMAGELOG +// +// Identical to TBC except GUIDs are PackedGuid (not full uint64). +// Format: PackedGuid(target) + PackedGuid(caster) + uint32(spellId) +// + uint32(damage) + uint8(schoolMask) + uint32(absorbed) + uint32(resisted) +// + uint8(periodicLog) + uint8(unused) + uint32(blocked) + uint32(flags) +// ============================================================================ +bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + if (rem() < 1) return false; + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + + // uint32(spellId) + uint32(damage) + uint8(schoolMask) + uint32(absorbed) + // + uint32(resisted) + uint8 + uint8 + uint32(blocked) + uint32(flags) = 21 bytes + if (rem() < 21) return false; + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); + packet.readUInt8(); // periodicLog + packet.readUInt8(); // unused + packet.readUInt32(); // blocked + uint32_t flags = packet.readUInt32(); + data.isCrit = (flags & 0x02) != 0; + data.overkill = 0; // no overkill field in Vanilla (same as TBC) + + LOG_INFO("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); + return true; +} + +// ============================================================================ +// Classic parseSpellHealLog — Vanilla 1.12 SMSG_SPELLHEALLOG +// +// Identical to TBC except GUIDs are PackedGuid (not full uint64). +// Format: PackedGuid(target) + PackedGuid(caster) + uint32(spellId) +// + uint32(heal) + uint32(overheal) + uint8(crit) +// ============================================================================ +bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + if (rem() < 1) return false; + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + + if (rem() < 13) return false; // uint32 + uint32 + uint32 + uint8 = 13 bytes + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); + data.isCrit = (packet.readUInt8() != 0); + + LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); + return true; +} + // ============================================================================ // Classic SMSG_CAST_FAILED: no castCount byte (added in TBC/WotLK) // Format: spellId(u32) + result(u8) From cb0dfddf59838508c291329f602932883a3f282c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:30:28 -0700 Subject: [PATCH 58/71] game: add Classic 1.12 parseAuraUpdate override to restore aura tracking Classic 1.12 sends SMSG_AURA_UPDATE/SMSG_AURA_UPDATE_ALL, but ClassicPacketParsers inherited TBC's override which returns false (TBC uses a different aura system and doesn't send SMSG_AURA_UPDATE at all). Classic aura format differs from WotLK in two key ways: - DURATION flag bit is 0x10 in Vanilla, not 0x20 as in WotLK; reading with the WotLK parser would incorrectly gate duration reads and misparse aura fields - No caster GUID field in Classic; WotLK parser tries to read one (gated by 0x08) which would consume spell ID or flag bytes from the next aura slot With this override, player/target aura bars and buff tracking work correctly on Classic 1.12 connections for the first time. --- include/game/packet_parsers.hpp | 3 ++ src/game/packet_parsers_classic.cpp | 61 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index d34df5d5..88eea5de 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -412,6 +412,9 @@ public: bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override; bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override; + // Classic 1.12 has SMSG_AURA_UPDATE (unlike TBC which doesn't); + // format differs from WotLK: no caster GUID, DURATION flag is 0x10 not 0x20 + bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) override; }; /** diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index bd65e03d..2b558f17 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -515,6 +515,67 @@ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealL return true; } +// ============================================================================ +// Classic parseAuraUpdate — Vanilla 1.12 SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL +// +// Classic has SMSG_AURA_UPDATE (TBC does not — TBC uses a different aura system +// and the TBC override returns false with a warning). Classic inherits TBC's +// override by default, so this override is needed to restore aura tracking. +// +// Classic aura flags differ from WotLK: +// 0x01/0x02/0x04 = effect indices active (same as WotLK) +// 0x08 = CANCELABLE / NOT-NEGATIVE (WotLK: 0x08 = NOT_CASTER) +// 0x10 = DURATION (WotLK: 0x20 = DURATION) +// 0x20 = NOT_CASTER (WotLK: no caster GUID at all if 0x08) +// 0x40 = POSITIVE (WotLK: 0x40 = EFFECT_AMOUNTS) +// +// Key differences from WotLK parser: +// - No caster GUID field in Classic SMSG_AURA_UPDATE packets +// - DURATION bit is 0x10, not 0x20 +// - No effect amounts field (WotLK 0x40 = EFFECT_AMOUNTS does not exist here) +// +// Format: PackedGuid(entity) + [uint8(slot) + uint32(spellId) +// [+ uint8(flags) + uint8(level) + uint8(charges) +// + [uint32(maxDuration) + uint32(duration) if flags & 0x10]]* +// ============================================================================ +bool ClassicPacketParsers::parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; + + data.guid = UpdateObjectParser::readPackedGuid(packet); + + while (rem() > 0) { + if (rem() < 1) break; + uint8_t slot = packet.readUInt8(); + if (rem() < 4) break; + uint32_t spellId = packet.readUInt32(); + + AuraSlot aura; + if (spellId != 0) { + aura.spellId = spellId; + if (rem() < 3) { data.updates.push_back({slot, aura}); break; } + aura.flags = packet.readUInt8(); + aura.level = packet.readUInt8(); + aura.charges = packet.readUInt8(); + + // Classic DURATION flag is 0x10 (WotLK uses 0x20) + if ((aura.flags & 0x10) && rem() >= 8) { + aura.maxDurationMs = static_cast(packet.readUInt32()); + aura.durationMs = static_cast(packet.readUInt32()); + } + // No caster GUID field in Classic (WotLK added it gated by 0x08 NOT_CASTER) + // No effect amounts field in Classic (WotLK added it gated by 0x40) + } + + data.updates.push_back({slot, aura}); + if (!isAll) break; + } + + LOG_DEBUG("[Classic] Aura update for 0x", std::hex, data.guid, std::dec, + ": ", data.updates.size(), " slots"); + return true; +} + // ============================================================================ // Classic SMSG_CAST_FAILED: no castCount byte (added in TBC/WotLK) // Format: spellId(u32) + result(u8) From 8014f2650c72085de5fbf5c6cef472a3b84b2b40 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:38:47 -0700 Subject: [PATCH 59/71] game: fix Classic 1.12 GUID format for health/power/aura/energize packets Classic 1.12 sends packed GUIDs (byte mask + non-zero bytes) for these server packets, not full uint64 as TBC does. The previous fixes incorrectly grouped Classic with TBC, causing the GUID readers to over-read 8 bytes from what were 2-4 byte packed GUIDs, corrupting health values and spell IDs parsed from subsequent bytes. Verified from vmangos/cmangos-classic source code: SMSG_HEALTH_UPDATE: data << GetPackGUID() SMSG_POWER_UPDATE: data << GetPackGUID() SMSG_UPDATE_COMBO_POINTS: data << combotarget->GetPackGUID() SMSG_PERIODICAURALOG: data << victim->GetPackGUID() + caster->GetPackGUID() SMSG_SPELLENERGIZELOG: data << victim->GetPackGUID() + caster->GetPackGUID() TBC continues to use full uint64 for these packets. WotLK and Classic both use packed GUIDs. The branching now correctly distinguishes TBC from the rest. --- src/game/game_handler.cpp | 49 +++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e8a02d5d..ba9ef3eb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1645,10 +1645,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Entity health/power delta updates ---- case Opcode::SMSG_HEALTH_UPDATE: { // WotLK: packed_guid + uint32 health - // TBC/Classic: uint64 + uint32 health - const bool huTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (huTbcLike ? 8u : 2u)) break; - uint64_t guid = huTbcLike + // TBC: full uint64 + uint32 health + // Classic/Vanilla: packed_guid + uint32 health (same as WotLK) + const bool huTbc = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (huTbc ? 8u : 2u)) break; + uint64_t guid = huTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t hp = packet.readUInt32(); @@ -1660,10 +1661,11 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_POWER_UPDATE: { // WotLK: packed_guid + uint8 powerType + uint32 value - // TBC/Classic: uint64 + uint8 powerType + uint32 value - const bool puTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (puTbcLike ? 8u : 2u)) break; - uint64_t guid = puTbcLike + // TBC: full uint64 + uint8 powerType + uint32 value + // Classic/Vanilla: packed_guid + uint8 powerType + uint32 value (same as WotLK) + const bool puTbc = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (puTbc ? 8u : 2u)) break; + uint64_t guid = puTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint8_t powerType = packet.readUInt8(); @@ -1710,10 +1712,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Combo points ---- case Opcode::SMSG_UPDATE_COMBO_POINTS: { // WotLK: packed_guid (target) + uint8 points - // TBC/Classic: uint64 (target) + uint8 points - const bool cpTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (cpTbcLike ? 8u : 2u)) break; - uint64_t target = cpTbcLike + // TBC: full uint64 (target) + uint8 points + // Classic/Vanilla: packed_guid (target) + uint8 points (same as WotLK) + const bool cpTbc = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (cpTbc ? 8u : 2u)) break; + uint64_t target = cpTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; comboPoints_ = packet.readUInt8(); @@ -3226,14 +3229,15 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_PERIODICAURALOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects - // TBC/Classic: uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects - const bool periodicTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - const size_t guidMinSz = periodicTbcLike ? 8u : 2u; + // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects + // Classic/Vanilla: packed_guid (same as WotLK) + const bool periodicTbc = isActiveExpansion("tbc"); + const size_t guidMinSz = periodicTbc ? 8u : 2u; if (packet.getSize() - packet.getReadPos() < guidMinSz) break; - uint64_t victimGuid = periodicTbcLike + uint64_t victimGuid = periodicTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < guidMinSz) break; - uint64_t casterGuid = periodicTbcLike + uint64_t casterGuid = periodicTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t spellId = packet.readUInt32(); @@ -3274,13 +3278,14 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_SPELLENERGIZELOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint8 powerType + int32 amount - // TBC/Classic: uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount - const bool energizeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount + // Classic/Vanilla: packed_guid (same as WotLK) + const bool energizeTbc = isActiveExpansion("tbc"); size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < (energizeTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = energizeTbcLike + if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = energizeTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = energizeTbcLike + uint64_t casterGuid = energizeTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); rem = packet.getSize() - packet.getReadPos(); if (rem < 6) { packet.setReadPos(packet.getSize()); break; } From d3ec230cecaf8d6326f21606daae37df833cc663 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:42:52 -0700 Subject: [PATCH 60/71] game: fix Classic 1.12 packed GUID for SMSG_PARTY_MEMBER_STATS Classic/Vanilla uses ObjectGuid::WriteAsPacked() for party member stats packets (same packed format as WotLK), not full uint64 as TBC does. Reading 8 fixed bytes for the GUID over-read the packed GUID field, misaligning updateFlags and all subsequent stat fields, breaking party frame HP/mana display in Classic. --- src/game/game_handler.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ba9ef3eb..9e1aa3cd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13310,10 +13310,11 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { packet.readUInt8(); } - // WotLK uses packed GUID; TBC/Classic use full uint64 - const bool pmsTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (remaining() < (pmsTbcLike ? 8u : 1u)) return; - uint64_t memberGuid = pmsTbcLike + // WotLK and Classic/Vanilla use packed GUID; TBC uses full uint64 + // (Classic uses ObjectGuid::WriteAsPacked() = packed format, same as WotLK) + const bool pmsTbc = isActiveExpansion("tbc"); + if (remaining() < (pmsTbc ? 8u : 1u)) return; + uint64_t memberGuid = pmsTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (remaining() < 4) return; uint32_t updateFlags = packet.readUInt32(); From 04f22376ce561a97e1e21326c6d99912a5c03ef1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:53:03 -0700 Subject: [PATCH 61/71] game: fix Classic 1.12 SMSG_NAME_QUERY_RESPONSE race/gender/class parsing Classic 1.12 servers (vmangos/cmangos-classic) send: uint64 guid + CString name + CString realmName + uint32 race + uint32 gender + uint32 class TBC's Variant A (which Classic inherited) skipped the realmName CString, causing the null terminator of the empty realmName to be absorbed into the low byte of the uint32 race read, producing race=0 and shifted gender/class. Adds a ClassicPacketParsers::parseNameQueryResponse override that correctly reads the realmName CString before the race/gender/class uint32 fields. --- include/game/packet_parsers.hpp | 3 +++ src/game/packet_parsers_classic.cpp | 40 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 88eea5de..8413002c 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -415,6 +415,9 @@ public: // Classic 1.12 has SMSG_AURA_UPDATE (unlike TBC which doesn't); // format differs from WotLK: no caster GUID, DURATION flag is 0x10 not 0x20 bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) override; + // Classic 1.12 SMSG_NAME_QUERY_RESPONSE: full uint64 guid + name + realmName CString + + // uint32 race + uint32 gender + uint32 class (TBC Variant A skips the realmName CString) + bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override; }; /** diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 2b558f17..6a264874 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -576,6 +576,46 @@ bool ClassicPacketParsers::parseAuraUpdate(network::Packet& packet, AuraUpdateDa return true; } +// ============================================================================ +// Classic SMSG_NAME_QUERY_RESPONSE format (1.12 / vmangos): +// uint64 guid (full, GetObjectGuid) +// CString name +// CString realmName (usually empty = single \0 byte) +// uint32 race +// uint32 gender +// uint32 class +// +// TBC Variant A (inherited from TbcPacketParsers) skips the realmName CString, +// causing it to misread the uint32 race field (absorbs the realmName \0 byte +// as the low byte), producing race=0 and shifted gender/class values. +// ============================================================================ +bool ClassicPacketParsers::parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) { + data = NameQueryResponseData{}; + + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 8) return false; + + data.guid = packet.readUInt64(); // full uint64, not PackedGuid + data.name = packet.readString(); // null-terminated name + if (rem() == 0) return !data.name.empty(); + + data.realmName = packet.readString(); // null-terminated realm name (usually "") + if (rem() < 12) return !data.name.empty(); + + uint32_t race = packet.readUInt32(); + uint32_t gender = packet.readUInt32(); + uint32_t cls = packet.readUInt32(); + data.race = static_cast(race & 0xFF); + data.gender = static_cast(gender & 0xFF); + data.classId = static_cast(cls & 0xFF); + data.found = 0; + + LOG_DEBUG("[Classic] Name query response: ", data.name, + " (race=", (int)data.race, " gender=", (int)data.gender, + " class=", (int)data.classId, ")"); + return !data.name.empty(); +} + // ============================================================================ // Classic SMSG_CAST_FAILED: no castCount byte (added in TBC/WotLK) // Format: spellId(u32) + result(u8) From a0979b9cd85135faa80f24d1d742728cee015aba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 00:58:56 -0700 Subject: [PATCH 62/71] game: fix Classic/TBC SMSG_GROUP_LIST parsing - missing roles byte WotLK 3.3.5a added a group-level and per-member roles byte (tank/healer/dps) for the Dungeon Finder system. Classic 1.12 and TBC 2.4.3 do not send this byte. The previous GroupListParser always read the roles byte, causing a one-byte misalignment in Classic/TBC group lists that corrupted member GUID reads and all subsequent fields (loot method, leader GUID, etc.). GroupListParser::parse now takes a hasRoles parameter (default true for backward compatibility). handleGroupList passes hasRoles=isActiveExpansion("wotlk"). Also adds range-checking throughout to prevent out-of-bounds reads on malformed or unexpectedly short group list packets. --- include/game/world_packets.hpp | 4 +- src/game/game_handler.cpp | 5 +- src/game/world_packets.cpp | 83 ++++++++++++++++++++++++---------- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index affb44ad..3d9695cd 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1855,7 +1855,9 @@ public: /** SMSG_GROUP_LIST parser */ class GroupListParser { public: - static bool parse(network::Packet& packet, GroupListData& data); + // hasRoles: WotLK 3.3.5a added a roles byte at group level and per-member for LFD. + // Classic 1.12 and TBC 2.4.3 do not send this byte. + static bool parse(network::Packet& packet, GroupListData& data, bool hasRoles = true); }; /** SMSG_PARTY_COMMAND_RESULT data */ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9e1aa3cd..415e5295 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13260,7 +13260,10 @@ void GameHandler::handleGroupDecline(network::Packet& packet) { } void GameHandler::handleGroupList(network::Packet& packet) { - if (!GroupListParser::parse(packet, partyData)) return; + // WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder. + // Classic 1.12 and TBC 2.4.3 do not send the roles byte. + const bool hasRoles = isActiveExpansion("wotlk"); + if (!GroupListParser::parse(packet, partyData, hasRoles)) return; if (partyData.isEmpty()) { LOG_INFO("No longer in a group"); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 9ef9a71a..d23210c3 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3090,47 +3090,82 @@ network::Packet GroupDeclinePacket::build() { return packet; } -bool GroupListParser::parse(network::Packet& packet, GroupListData& data) { - data.groupType = packet.readUInt8(); - data.subGroup = packet.readUInt8(); - data.flags = packet.readUInt8(); - data.roles = packet.readUInt8(); +bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool hasRoles) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - // Skip LFG data if present - if (data.groupType & 0x04) { - packet.readUInt8(); // lfg state - packet.readUInt32(); // lfg entry - packet.readUInt8(); // lfg flags (3.3.5a may not have this) + if (rem() < 3) return false; + data.groupType = packet.readUInt8(); + data.subGroup = packet.readUInt8(); + data.flags = packet.readUInt8(); + + // WotLK 3.3.5a added a roles byte (tank/healer/dps) for the dungeon finder. + // Classic 1.12 and TBC 2.4.3 do not have this byte. + if (hasRoles) { + if (rem() < 1) return false; + data.roles = packet.readUInt8(); + } else { + data.roles = 0; } - packet.readUInt64(); // group GUID - packet.readUInt32(); // counter + // WotLK: LFG data gated by groupType bit 0x04 (LFD group type) + if (hasRoles && (data.groupType & 0x04)) { + if (rem() < 5) return false; + packet.readUInt8(); // lfg state + packet.readUInt32(); // lfg entry + // WotLK 3.3.5a may or may not send the lfg flags byte — read it only if present + if (rem() >= 13) { // enough for lfgFlags(1)+groupGuid(8)+counter(4) + packet.readUInt8(); // lfg flags + } + } + if (rem() < 12) return false; + packet.readUInt64(); // group GUID + packet.readUInt32(); // update counter + + if (rem() < 4) return false; data.memberCount = packet.readUInt32(); + if (data.memberCount > 40) { + LOG_WARNING("GroupListParser: implausible memberCount=", data.memberCount, ", clamping"); + data.memberCount = 40; + } data.members.reserve(data.memberCount); for (uint32_t i = 0; i < data.memberCount; ++i) { + if (rem() == 0) break; GroupMember member; - member.name = packet.readString(); - member.guid = packet.readUInt64(); + member.name = packet.readString(); + if (rem() < 8) break; + member.guid = packet.readUInt64(); + if (rem() < 3) break; member.isOnline = packet.readUInt8(); member.subGroup = packet.readUInt8(); - member.flags = packet.readUInt8(); - member.roles = packet.readUInt8(); + member.flags = packet.readUInt8(); + // WotLK added per-member roles byte; Classic/TBC do not have it. + if (hasRoles) { + if (rem() < 1) break; + member.roles = packet.readUInt8(); + } else { + member.roles = 0; + } data.members.push_back(member); } + if (rem() < 8) { + LOG_INFO("Group list: ", data.memberCount, " members (no leader GUID in packet)"); + return true; + } data.leaderGuid = packet.readUInt64(); - if (data.memberCount > 0 && packet.getReadPos() < packet.getSize()) { - data.lootMethod = packet.readUInt8(); - data.looterGuid = packet.readUInt64(); + if (data.memberCount > 0 && rem() >= 10) { + data.lootMethod = packet.readUInt8(); + data.looterGuid = packet.readUInt64(); data.lootThreshold = packet.readUInt8(); - data.difficultyId = packet.readUInt8(); - data.raidDifficultyId = packet.readUInt8(); - if (packet.getReadPos() < packet.getSize()) { - packet.readUInt8(); // unknown byte - } + // Dungeon difficulty (heroic/normal) — Classic doesn't send this; TBC/WotLK do + if (rem() >= 1) data.difficultyId = packet.readUInt8(); + // Raid difficulty — WotLK only + if (rem() >= 1) data.raidDifficultyId = packet.readUInt8(); + // Extra byte in some 3.3.5a builds + if (hasRoles && rem() >= 1) packet.readUInt8(); } LOG_INFO("Group list: ", data.memberCount, " members, leader=0x", From c19edd407a86ad39b1a8e21dc9c996a14e5b8cbd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:05:23 -0700 Subject: [PATCH 63/71] game: fix Classic/TBC SMSG_TEXT_EMOTE field order Classic 1.12 and TBC 2.4.3 send SMSG_TEXT_EMOTE with the field order: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + nameLen(u32) + name WotLK 3.3.5a swapped senderGuid to the front: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + nameLen(u32) + name The previous TextEmoteParser always used the WotLK order, causing senderGuid to be read as a mashup of textEmoteId+emoteNum for Classic/TBC. Emote animations and chat entries were associated with wrong GUIDs. TextEmoteParser::parse now takes a legacyFormat parameter; handleTextEmote passes it based on expansion detection. --- include/game/world_packets.hpp | 4 +++- src/game/game_handler.cpp | 5 ++++- src/game/world_packets.cpp | 20 +++++++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 3d9695cd..0809ac43 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -717,7 +717,9 @@ struct TextEmoteData { */ class TextEmoteParser { public: - static bool parse(network::Packet& packet, TextEmoteData& data); + // legacyFormat: Classic 1.12 and TBC 2.4.3 send textEmoteId+emoteNum first, then senderGuid. + // WotLK 3.3.5a reverses this: senderGuid first, then textEmoteId+emoteNum. + static bool parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat = false); }; // ============================================================ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 415e5295..d5cbbba5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8560,8 +8560,11 @@ void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { } void GameHandler::handleTextEmote(network::Packet& packet) { + // Classic 1.12 and TBC 2.4.3 send: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + nameLen(u32) + name + // WotLK 3.3.5a reversed this to: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + nameLen(u32) + name + const bool legacyFormat = isClassicLikeExpansion() || isActiveExpansion("tbc"); TextEmoteData data; - if (!TextEmoteParser::parse(packet, data)) { + if (!TextEmoteParser::parse(packet, data, legacyFormat)) { LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE"); return; } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d23210c3..275ad5b8 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1510,20 +1510,30 @@ network::Packet TextEmotePacket::build(uint32_t textEmoteId, uint64_t targetGuid return packet; } -bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data) { +bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat) { size_t bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft < 20) { LOG_WARNING("SMSG_TEXT_EMOTE too short: ", bytesLeft, " bytes"); return false; } - data.senderGuid = packet.readUInt64(); - data.textEmoteId = packet.readUInt32(); - data.emoteNum = packet.readUInt32(); + + if (legacyFormat) { + // Classic 1.12 / TBC 2.4.3: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + data.textEmoteId = packet.readUInt32(); + data.emoteNum = packet.readUInt32(); + data.senderGuid = packet.readUInt64(); + } else { + // WotLK 3.3.5a: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + data.senderGuid = packet.readUInt64(); + data.textEmoteId = packet.readUInt32(); + data.emoteNum = packet.readUInt32(); + } + uint32_t nameLen = packet.readUInt32(); if (nameLen > 0 && nameLen <= 256) { data.targetName = packet.readString(); } else if (nameLen > 0) { - // Skip garbage + // Implausible name length — misaligned read return false; } return true; From ab0828a4cebca5a5c8af040c6840fa77e500fd36 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:08:13 -0700 Subject: [PATCH 64/71] game: fix Classic 1.12 SMSG_WHO missing gender byte alignment Vanilla 1.12 SMSG_WHO per-player format: name(CString) + guild(CString) + level(u32) + class(u32) + race(u32) + zone(u32) WotLK 3.3.5a added a gender(u8) byte between race and zone. The previous handleWho always read the gender byte, causing a one-byte misalignment for Classic/TBC: the first byte of zoneId was consumed as gender, then zoneId read from the next 4 bytes (spanning into the next player entry). Now only reads the gender byte for WotLK (isActiveExpansion("wotlk")), and adds bounds checks to prevent out-of-bounds reads on truncated packets. --- src/game/game_handler.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d5cbbba5..57197a39 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16390,7 +16390,10 @@ void GameHandler::handlePlayedTime(network::Packet& packet) { } void GameHandler::handleWho(network::Packet& packet) { - // Parse WHO response + // Classic 1.12 / TBC 2.4.3 per-player: name + guild + level(u32) + class(u32) + race(u32) + zone(u32) + // WotLK 3.3.5a added a gender(u8) field between race and zone. + const bool hasGender = isActiveExpansion("wotlk"); + uint32_t displayCount = packet.readUInt32(); uint32_t onlineCount = packet.readUInt32(); @@ -16404,18 +16407,21 @@ void GameHandler::handleWho(network::Packet& packet) { addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:"); for (uint32_t i = 0; i < displayCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) break; std::string playerName = packet.readString(); std::string guildName = packet.readString(); - uint32_t level = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 12) break; + uint32_t level = packet.readUInt32(); uint32_t classId = packet.readUInt32(); - uint32_t raceId = packet.readUInt32(); - packet.readUInt8(); // gender (unused) - packet.readUInt32(); // zoneId (unused) + uint32_t raceId = packet.readUInt32(); + if (hasGender && packet.getSize() - packet.getReadPos() >= 1) + packet.readUInt8(); // gender (WotLK only, unused) + if (packet.getSize() - packet.getReadPos() >= 4) + packet.readUInt32(); // zoneId (unused) std::string msg = " " + playerName; - if (!guildName.empty()) { + if (!guildName.empty()) msg += " <" + guildName + ">"; - } msg += " - Level " + std::to_string(level); addSystemChatMessage(msg); From 23878e530fee53405437459bbd4e2edfe366d1db Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:15:51 -0700 Subject: [PATCH 65/71] game: implement Classic SMSG_FRIEND_LIST and full SMSG_CONTACT_LIST parsing Classic 1.12 and TBC use SMSG_FRIEND_LIST (not SMSG_CONTACT_LIST) to send the initial friend list at login. Previously this packet was silently dropped, leaving friendsCache empty and breaking /friend remove and note operations for Classic players. - Add handleFriendList(): parses Classic format (u8 count, then per-entry: u64 guid + u8 status + optional area/level/class if online) - Add handleContactList(): fully parses WotLK SMSG_CONTACT_LIST entries (previously only read mask+count header and dropped all entries) - Both handlers populate friendGuids_ and call queryPlayerName() for unknown GUIDs; handleNameQueryResponse() now backfills friendsCache when a name resolves for a known friend GUID - Clear friendGuids_ on disconnect alongside playerNameCache --- include/game/game_handler.hpp | 3 + src/game/game_handler.cpp | 117 ++++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 260e4c74..47dd523d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1537,6 +1537,8 @@ private: void handleWho(network::Packet& packet); // ---- Social handlers ---- + void handleFriendList(network::Packet& packet); // Classic SMSG_FRIEND_LIST + void handleContactList(network::Packet& packet); // WotLK SMSG_CONTACT_LIST (full parse) void handleFriendStatus(network::Packet& packet); void handleRandomRoll(network::Packet& packet); @@ -1656,6 +1658,7 @@ private: // ---- Friend list cache ---- std::unordered_map friendsCache; // name -> guid + std::unordered_set friendGuids_; // all known friend GUIDs (for name backfill) uint32_t lastContactListMask_ = 0; uint32_t lastContactListCount_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 57197a39..a22aa7c2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -482,6 +482,7 @@ void GameHandler::disconnect() { activeCharacterGuid_ = 0; playerNameCache.clear(); pendingNameQueries.clear(); + friendGuids_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); requiresWarden_ = false; @@ -1484,27 +1485,15 @@ void GameHandler::handlePacket(network::Packet& packet) { handleFriendStatus(packet); } break; - case Opcode::SMSG_CONTACT_LIST: { - // Known variants: - // - Full form: uint32 listMask, uint32 count, then variable-size entries. - // - Minimal/legacy keepalive-ish form observed on some servers: 1 byte. - size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining >= 8) { - lastContactListMask_ = packet.readUInt32(); - lastContactListCount_ = packet.readUInt32(); - } else if (remaining == 1) { - /*uint8_t marker =*/ packet.readUInt8(); - lastContactListMask_ = 0; - lastContactListCount_ = 0; - } else if (remaining > 0) { - // Unknown short variant: consume to keep stream aligned, no warning spam. - packet.setReadPos(packet.getSize()); - } + case Opcode::SMSG_CONTACT_LIST: + handleContactList(packet); break; - } case Opcode::SMSG_FRIEND_LIST: + // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) + handleFriendList(packet); + break; case Opcode::SMSG_IGNORE_LIST: - // Legacy social list variants; CONTACT_LIST is primary in modern flow. + // Ignore list: consume to avoid spurious warnings; not parsed. packet.setReadPos(packet.getSize()); break; @@ -9909,6 +9898,12 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { mail.senderName = data.name; } } + + // Backfill friend list: if this GUID came from a friend list packet, + // register the name in friendsCache now that we know it. + if (friendGuids_.count(data.guid)) { + friendsCache[data.name] = data.guid; + } } } @@ -16429,6 +16424,92 @@ void GameHandler::handleWho(network::Packet& packet) { } } +void GameHandler::handleFriendList(network::Packet& packet) { + // Classic 1.12 / TBC 2.4.3 SMSG_FRIEND_LIST format: + // uint8 count + // for each entry: + // uint64 guid (full) + // uint8 status (0=offline, 1=online, 2=AFK, 3=DND) + // if status != 0: + // uint32 area + // uint32 level + // uint32 class + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return; + uint8_t count = packet.readUInt8(); + LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries"); + for (uint8_t i = 0; i < count && rem() >= 9; ++i) { + uint64_t guid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + uint32_t area = 0, level = 0, classId = 0; + if (status != 0 && rem() >= 12) { + area = packet.readUInt32(); + level = packet.readUInt32(); + classId = packet.readUInt32(); + } + (void)area; (void)level; (void)classId; + // Track as a friend GUID; resolve name via name query + friendGuids_.insert(guid); + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) { + friendsCache[nit->second] = guid; + LOG_INFO(" Friend: ", nit->second, " status=", (int)status); + } else { + LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec, + " status=", (int)status, " (name pending)"); + queryPlayerName(guid); + } + } +} + +void GameHandler::handleContactList(network::Packet& packet) { + // WotLK SMSG_CONTACT_LIST format: + // uint32 listMask (1=friend, 2=ignore, 4=mute) + // uint32 count + // for each entry: + // uint64 guid (full) + // uint32 flags + // string note (null-terminated) + // if flags & 0x1 (friend): + // uint8 status (0=offline, 1=online, 2=AFK, 3=DND) + // if status != 0: + // uint32 area, uint32 level, uint32 class + // Short/keepalive variant (1-7 bytes): consume silently. + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 8) { + packet.setReadPos(packet.getSize()); + return; + } + lastContactListMask_ = packet.readUInt32(); + lastContactListCount_ = packet.readUInt32(); + for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) { + uint64_t guid = packet.readUInt64(); + if (rem() < 4) break; + uint32_t flags = packet.readUInt32(); + std::string note = packet.readString(); // may be empty + (void)note; + if (flags & 0x1) { // SOCIAL_FLAG_FRIEND + if (rem() < 1) break; + uint8_t status = packet.readUInt8(); + if (status != 0 && rem() >= 12) { + packet.readUInt32(); // area + packet.readUInt32(); // level + packet.readUInt32(); // class + } + friendGuids_.insert(guid); + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) { + friendsCache[nit->second] = guid; + } else { + queryPlayerName(guid); + } + } + // ignore / mute entries: no additional fields beyond guid+flags+note + } + LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, + " count=", lastContactListCount_); +} + void GameHandler::handleFriendStatus(network::Packet& packet) { FriendStatusData data; if (!FriendStatusParser::parse(packet, data)) { From 8dd4bc80ec4fbaa73202935ef93367b29a1cfec2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:20:41 -0700 Subject: [PATCH 66/71] game: fix Classic 1.12 SMSG_TRAINER_LIST per-spell field layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic 1.12 trainer list entries lack the profDialog and profButton uint32 fields (8 bytes) that TBC/WotLK added before reqLevel. Instead, reqLevel immediately follows spellCost, and a trailing unk uint32 appears at the end of each entry. Parsing the WotLK format for Classic caused misalignment from the third field onward, corrupting state, cost, level, skill, and chain data for all trainer spells. - TrainerListParser::parse() gains a isClassic bool parameter (default false) - Classic path: cost(4) → reqLevel(1) → reqSkill... → chainNode3 → unk(4) - WotLK/TBC path: cost(4) → profDialog(4) → profButton(4) → reqLevel(1) → reqSkill... - handleTrainerList() passes isClassicLikeExpansion() as the flag --- include/game/world_packets.hpp | 4 +++- src/game/game_handler.cpp | 3 ++- src/game/world_packets.cpp | 40 ++++++++++++++++++++++++---------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 0809ac43..4d308028 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2231,7 +2231,9 @@ struct TrainerListData { class TrainerListParser { public: - static bool parse(network::Packet& packet, TrainerListData& data); + // isClassic: Classic 1.12 per-spell layout has no profDialog/profButton fields + // (reqLevel immediately follows cost), plus a trailing unk uint32 per entry. + static bool parse(network::Packet& packet, TrainerListData& data, bool isClassic = false); }; class TrainerBuySpellPacket { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a22aa7c2..01679430 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15012,7 +15012,8 @@ void GameHandler::handleListInventory(network::Packet& packet) { // ============================================================ void GameHandler::handleTrainerList(network::Packet& packet) { - if (!TrainerListParser::parse(packet, currentTrainerList_)) return; + const bool isClassic = isClassicLikeExpansion(); + if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return; trainerWindowOpen_ = true; gossipWindowOpen = false; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 275ad5b8..c88f3750 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3833,7 +3833,11 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data // Trainer // ============================================================ -bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) { +bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bool isClassic) { + // WotLK per-entry: spellId(4) + state(1) + cost(4) + profDialog(4) + profButton(4) + + // reqLevel(1) + reqSkill(4) + reqSkillValue(4) + chain×3(12) = 38 bytes + // Classic per-entry: spellId(4) + state(1) + cost(4) + reqLevel(1) + + // reqSkill(4) + reqSkillValue(4) + chain×3(12) + unk(4) = 34 bytes data = TrainerListData{}; data.trainerGuid = packet.readUInt64(); data.trainerType = packet.readUInt32(); @@ -3847,23 +3851,35 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) { data.spells.reserve(spellCount); for (uint32_t i = 0; i < spellCount; ++i) { TrainerSpell spell; - spell.spellId = packet.readUInt32(); - spell.state = packet.readUInt8(); - spell.spellCost = packet.readUInt32(); - spell.profDialog = packet.readUInt32(); - spell.profButton = packet.readUInt32(); - spell.reqLevel = packet.readUInt8(); - spell.reqSkill = packet.readUInt32(); + spell.spellId = packet.readUInt32(); + spell.state = packet.readUInt8(); + spell.spellCost = packet.readUInt32(); + if (isClassic) { + // Classic 1.12: reqLevel immediately after cost; no profDialog/profButton + spell.profDialog = 0; + spell.profButton = 0; + spell.reqLevel = packet.readUInt8(); + } else { + // TBC / WotLK: profDialog + profButton before reqLevel + spell.profDialog = packet.readUInt32(); + spell.profButton = packet.readUInt32(); + spell.reqLevel = packet.readUInt8(); + } + spell.reqSkill = packet.readUInt32(); spell.reqSkillValue = packet.readUInt32(); - spell.chainNode1 = packet.readUInt32(); - spell.chainNode2 = packet.readUInt32(); - spell.chainNode3 = packet.readUInt32(); + spell.chainNode1 = packet.readUInt32(); + spell.chainNode2 = packet.readUInt32(); + spell.chainNode3 = packet.readUInt32(); + if (isClassic) { + packet.readUInt32(); // trailing unk / sort index + } data.spells.push_back(spell); } data.greeting = packet.readString(); - LOG_INFO("Trainer list: ", spellCount, " spells, type=", data.trainerType, + LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ", + spellCount, " spells, type=", data.trainerType, ", greeting=\"", data.greeting, "\""); return true; } From 528b796dff3402fde483772fdf30292daebc09c8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:25:27 -0700 Subject: [PATCH 67/71] game: fix Classic 1.12 SMSG_AUCTION_LIST_RESULT enchant slot count Classic 1.12 auction entries contain only 1 enchant slot (3 uint32s), while TBC and WotLK expanded this to 3 enchant slots (9 uint32s). Parsing Classic auction results with the WotLK parser consumed 24 extra bytes per entry (two extra enchant slots), corrupting randomPropertyId, stackCount, ownerGuid, pricing and expiry data for every auction item. - AuctionListResultParser::parse() gains a numEnchantSlots parameter (default 3) - Classic path reads 1 enchant slot; TBC/WotLK read 3 - handleAuctionListResult/OwnerList/BidderList pass isClassicLikeExpansion()?1:3 --- include/game/world_packets.hpp | 3 +- src/game/game_handler.cpp | 10 +++++-- src/game/world_packets.cpp | 53 ++++++++++++++++++++-------------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 4d308028..7f62b622 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2632,7 +2632,8 @@ public: /** SMSG_AUCTION_LIST_RESULT parser (shared for browse/owner/bidder) */ class AuctionListResultParser { public: - static bool parse(network::Packet& packet, AuctionListResult& data); + // numEnchantSlots: Classic 1.12 = 1, TBC/WotLK = 3 (extra enchant slots per entry) + static bool parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots = 3); }; /** SMSG_AUCTION_COMMAND_RESULT parser */ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 01679430..9602d805 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17576,8 +17576,10 @@ void GameHandler::handleAuctionHello(network::Packet& packet) { } void GameHandler::handleAuctionListResult(network::Packet& packet) { + // Classic 1.12 has 1 enchant slot per auction entry; TBC/WotLK have 3. + const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; - if (!AuctionListResultParser::parse(packet, result)) { + if (!AuctionListResultParser::parse(packet, result, enchSlots)) { LOG_WARNING("Failed to parse SMSG_AUCTION_LIST_RESULT"); return; } @@ -17594,8 +17596,9 @@ void GameHandler::handleAuctionListResult(network::Packet& packet) { } void GameHandler::handleAuctionOwnerListResult(network::Packet& packet) { + const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; - if (!AuctionListResultParser::parse(packet, result)) { + if (!AuctionListResultParser::parse(packet, result, enchSlots)) { LOG_WARNING("Failed to parse SMSG_AUCTION_OWNER_LIST_RESULT"); return; } @@ -17607,8 +17610,9 @@ void GameHandler::handleAuctionOwnerListResult(network::Packet& packet) { } void GameHandler::handleAuctionBidderListResult(network::Packet& packet) { + const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; - if (!AuctionListResultParser::parse(packet, result)) { + if (!AuctionListResultParser::parse(packet, result, enchSlots)) { LOG_WARNING("Failed to parse SMSG_AUCTION_BIDDER_LIST_RESULT"); return; } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c88f3750..4ecc1555 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -4514,45 +4514,54 @@ network::Packet AuctionListBidderItemsPacket::build( return p; } -bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data) { +bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots) { + // Per-entry fixed size: auctionId(4) + itemEntry(4) + enchantSlots×3×4 + + // randProp(4) + suffix(4) + stack(4) + charges(4) + flags(4) + + // ownerGuid(8) + startBid(4) + outbid(4) + buyout(4) + expire(4) + + // bidderGuid(8) + curBid(4) + // Classic: numEnchantSlots=1 → 80 bytes/entry + // TBC/WotLK: numEnchantSlots=3 → 104 bytes/entry if (packet.getSize() - packet.getReadPos() < 4) return false; uint32_t count = packet.readUInt32(); data.auctions.clear(); data.auctions.reserve(count); + const size_t minPerEntry = static_cast(8 + numEnchantSlots * 12 + 28 + 8 + 8); for (uint32_t i = 0; i < count; ++i) { - if (packet.getReadPos() + 64 > packet.getSize()) break; + if (packet.getReadPos() + minPerEntry > packet.getSize()) break; AuctionEntry e; e.auctionId = packet.readUInt32(); e.itemEntry = packet.readUInt32(); - // 3 enchant slots: enchantId, duration, charges + // First enchant slot always present e.enchantId = packet.readUInt32(); - packet.readUInt32(); // enchant duration - packet.readUInt32(); // enchant charges - packet.readUInt32(); // enchant2 id - packet.readUInt32(); // enchant2 duration - packet.readUInt32(); // enchant2 charges - packet.readUInt32(); // enchant3 id - packet.readUInt32(); // enchant3 duration - packet.readUInt32(); // enchant3 charges + packet.readUInt32(); // enchant1 duration + packet.readUInt32(); // enchant1 charges + // Extra enchant slots for TBC/WotLK + for (int s = 1; s < numEnchantSlots; ++s) { + packet.readUInt32(); // enchant N id + packet.readUInt32(); // enchant N duration + packet.readUInt32(); // enchant N charges + } e.randomPropertyId = packet.readUInt32(); - e.suffixFactor = packet.readUInt32(); - e.stackCount = packet.readUInt32(); + e.suffixFactor = packet.readUInt32(); + e.stackCount = packet.readUInt32(); packet.readUInt32(); // item charges packet.readUInt32(); // item flags (unused) - e.ownerGuid = packet.readUInt64(); - e.startBid = packet.readUInt32(); - e.minBidIncrement = packet.readUInt32(); - e.buyoutPrice = packet.readUInt32(); - e.timeLeftMs = packet.readUInt32(); - e.bidderGuid = packet.readUInt64(); - e.currentBid = packet.readUInt32(); + e.ownerGuid = packet.readUInt64(); + e.startBid = packet.readUInt32(); + e.minBidIncrement = packet.readUInt32(); + e.buyoutPrice = packet.readUInt32(); + e.timeLeftMs = packet.readUInt32(); + e.bidderGuid = packet.readUInt64(); + e.currentBid = packet.readUInt32(); data.auctions.push_back(e); } - data.totalCount = packet.readUInt32(); - data.searchDelay = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() >= 8) { + data.totalCount = packet.readUInt32(); + data.searchDelay = packet.readUInt32(); + } return true; } From 7270a4e6900e0295befe14a7ee63dd4085234da8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:30:06 -0700 Subject: [PATCH 68/71] game: fix TBC 2.4.3 SMSG_CAST_FAILED missing parseCastFailed override TBC 2.4.3 SMSG_CAST_FAILED format is spellId(u32) + result(u8), same as Classic. WotLK added a castCount(u8) prefix before spellId. TbcPacketParsers lacked a parseCastFailed override, so it fell through to the WotLK base which read one extra byte as castCount, shifting the spellId read by one byte and corrupting the spell ID and result for every failed cast on TBC. - Add TbcPacketParsers::parseCastFailed override: reads spellId(4)+result(1) - ClassicPacketParsers already overrides this (enum shift +1), so Classic unaffected --- include/game/packet_parsers.hpp | 2 ++ src/game/packet_parsers_tbc.cpp | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 8413002c..03fc502e 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -332,6 +332,8 @@ public: bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; // TBC 2.4.3 SMSG_CAST_RESULT: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) override; + // TBC 2.4.3 SMSG_CAST_FAILED: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix + bool parseCastFailed(network::Packet& packet, CastFailedData& data) override; // TBC 2.4.3 SMSG_SPELL_START: full uint64 GUIDs (WotLK uses packed GUIDs) bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; // TBC 2.4.3 SMSG_SPELL_GO: full uint64 GUIDs, no timestamp field (WotLK added one) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index db9d007b..5cef0290 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1080,6 +1080,24 @@ bool TbcPacketParsers::parseCastResult(network::Packet& packet, uint32_t& spellI return true; } +// ============================================================================ +// TbcPacketParsers::parseCastFailed — TBC 2.4.3 SMSG_CAST_FAILED +// +// TBC format: spellId(u32) + result(u8) +// WotLK added castCount(u8) before spellId; reading it on TBC would shift +// the spellId by one byte and corrupt all subsequent fields. +// Classic has the same layout, but the result enum starts differently (offset +1); +// TBC uses the same result values as WotLK so no offset is needed. +// ============================================================================ +bool TbcPacketParsers::parseCastFailed(network::Packet& packet, CastFailedData& data) { + if (packet.getSize() - packet.getReadPos() < 5) return false; + data.castCount = 0; // not present in TBC + data.spellId = packet.readUInt32(); + data.result = packet.readUInt8(); // same enum as WotLK + LOG_DEBUG("[TBC] Cast failed: spell=", data.spellId, " result=", (int)data.result); + return true; +} + // ============================================================================ // TbcPacketParsers::parseAttackerStateUpdate — TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE // From 967cedba0efee51a24ab8ce21d00b30379d6daee Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 01:54:01 -0700 Subject: [PATCH 69/71] game: fix Classic/TBC SMSG_QUESTGIVER_QUEST_LIST quest title and questCount parsing Classic 1.12 and TBC 2.4.3 don't include questFlags(u32) + isRepeatable(u8) before each quest title in SMSG_QUESTGIVER_QUEST_LIST. WotLK 3.3.5a added those 5 bytes. The previous code read them speculatively for all expansions and only rewound on empty title, which failed for any non-empty title. Also fix questCount always reading as uint8 (all WoW versions use u8 here). The old u32/u8 heuristic could misread 4 bytes instead of 1, misaligning all subsequent quest item reads. --- src/game/game_handler.cpp | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9602d805..9d5e4587 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14887,21 +14887,16 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) { } (void)header; - auto readQuestCount = [&](network::Packet& pkt) -> uint32_t { - size_t rem = pkt.getSize() - pkt.getReadPos(); - if (rem >= 4) { - size_t p = pkt.getReadPos(); - uint32_t c = pkt.readUInt32(); - if (c <= 64) return c; - pkt.setReadPos(p); - } - if (rem >= 1) { - return static_cast(pkt.readUInt8()); - } - return 0; - }; + // questCount is uint8 in all WoW versions for SMSG_QUESTGIVER_QUEST_LIST. + uint32_t questCount = 0; + if (packet.getSize() - packet.getReadPos() >= 1) { + questCount = packet.readUInt8(); + } + + // Classic 1.12 and TBC 2.4.3 don't include questFlags(u32) + isRepeatable(u8) + // before the quest title. WotLK 3.3.5a added those 5 bytes. + const bool hasQuestFlagsField = !isClassicLikeExpansion() && !isActiveExpansion("tbc"); - uint32_t questCount = readQuestCount(packet); data.quests.reserve(questCount); for (uint32_t i = 0; i < questCount; ++i) { if (packet.getSize() - packet.getReadPos() < 12) break; @@ -14910,23 +14905,14 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) { q.questIcon = packet.readUInt32(); q.questLevel = static_cast(packet.readUInt32()); - // WotLK includes questFlags + isRepeatable; Classic variants may omit. - size_t titlePos = packet.getReadPos(); - if (packet.getSize() - packet.getReadPos() >= 5) { + if (hasQuestFlagsField && packet.getSize() - packet.getReadPos() >= 5) { q.questFlags = packet.readUInt32(); q.isRepeatable = packet.readUInt8(); - q.title = normalizeWowTextTokens(packet.readString()); - if (q.title.empty()) { - packet.setReadPos(titlePos); - q.questFlags = 0; - q.isRepeatable = 0; - q.title = normalizeWowTextTokens(packet.readString()); - } } else { q.questFlags = 0; q.isRepeatable = 0; - q.title = normalizeWowTextTokens(packet.readString()); } + q.title = normalizeWowTextTokens(packet.readString()); if (q.questId != 0) { data.quests.push_back(std::move(q)); } From 16cdde82b3803d8944b75cec08a825059ae46437 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 02:23:54 -0700 Subject: [PATCH 70/71] rendering: add diagnostic logging to applyEquipment for Classic equipment debugging Log each equipment item's displayModel, inventoryType, and DBC lookup result to help identify why Classic character equipment does not render correctly. Also log ItemDisplayInfo.dbc field count, found texture names per region, and missing texture paths so the exact failure point is visible in logs. --- src/rendering/character_preview.cpp | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 964357f4..9dc0efcf 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -543,9 +543,24 @@ bool CharacterPreview::applyEquipment(const std::vector& eq auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc || !displayInfoDbc->isLoaded()) { + LOG_WARNING("applyEquipment: ItemDisplayInfo.dbc not loaded"); return false; } + // Diagnostic: log equipment vector and DBC state + LOG_INFO("applyEquipment: ", equipment.size(), " items, ItemDisplayInfo.dbc records=", + displayInfoDbc->getRecordCount(), " fields=", displayInfoDbc->getFieldCount(), + " bodySkin=", bodySkinPath_.empty() ? "(empty)" : bodySkinPath_); + for (size_t ei = 0; ei < equipment.size(); ++ei) { + const auto& it = equipment[ei]; + if (it.displayModel == 0) continue; + int32_t dbcRec = displayInfoDbc->findRecordById(it.displayModel); + LOG_INFO(" slot[", ei, "]: displayModel=", it.displayModel, + " invType=", (int)it.inventoryType, + " dbcRec=", dbcRec, + (dbcRec >= 0 ? " (found)" : " (NOT FOUND in ItemDisplayInfo.dbc)")); + } + auto hasInvType = [&](std::initializer_list types) -> bool { for (const auto& it : equipment) { if (it.displayModel == 0) continue; @@ -560,7 +575,7 @@ bool CharacterPreview::applyEquipment(const std::vector& eq for (const auto& it : equipment) { if (it.displayModel == 0) continue; for (uint8_t t : types) { - if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum) + if (it.inventoryType == t) return it.displayModel; } } return 0; @@ -570,7 +585,12 @@ bool CharacterPreview::applyEquipment(const std::vector& eq if (displayInfoId == 0) return 0; int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); if (recIdx < 0) return 0; - return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + uint32_t val = displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + if (val > 0) { + LOG_INFO(" getGeosetGroup: displayInfoId=", displayInfoId, + " groupField=", groupField, " field=", (7 + groupField), " val=", val); + } + return val; }; // --- Geosets --- @@ -654,6 +674,9 @@ bool CharacterPreview::applyEquipment(const std::vector& eq std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); if (texName.empty()) continue; + LOG_INFO(" texture region ", region, " (field ", fieldIdx, "): texName=", texName, + " for displayModel=", it.displayModel); + std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; @@ -669,6 +692,7 @@ bool CharacterPreview::applyEquipment(const std::vector& eq } else if (assetManager_->fileExists(basePath)) { fullPath = basePath; } else { + LOG_INFO(" texture path not found: ", base, " (_M/_F/_U/.blp)"); continue; } regionLayers.emplace_back(region, fullPath); From 29ca9809b1c7bc45a92f65a00a7406a6a8d2cb0a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 03:09:12 -0700 Subject: [PATCH 71/71] game: fix Classic parseQuestDetails missing emote section before reward items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vanilla 1.12 SMSG_QUESTGIVER_QUEST_DETAILS includes an emote section between suggestedPlayers and the choice/reward item lists: activateAccept(u8) + suggestedPlayers(u32) + emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount + choiceCount(u32) + choices + rewardCount(u32) + rewards + money(u32) The parser was skipping the emote section, causing the emote count to be misread as the choice item count. Quests with emotes would show zero choice items and shifted/missing reward and money data. --- src/game/packet_parsers_classic.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 6a264874..c0ab0c88 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1584,6 +1584,16 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai /*activateAccept*/ packet.readUInt8(); data.suggestedPlayers = packet.readUInt32(); + // Vanilla 1.12: emote section before reward items + // Format: emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t emoteCount = packet.readUInt32(); + for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { + packet.readUInt32(); // delay + packet.readUInt32(); // type + } + } + // Choice reward items: variable count + 3 uint32s each if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t choiceCount = packet.readUInt32();