From 4a213d8da8835c3d2ca7ff714d1c91465b85f0f7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 03:27:30 -0700 Subject: [PATCH 01/67] tools/game: fix dbc_to_csv false-positive string detection + clear DBC cache on expansion switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dbc_to_csv: The string-column auto-detector would mark integer fields (e.g. RaceID=1, SexID=0, BaseSection=0-4) as string columns whenever their small values were valid string-block offsets that happened to land inside longer strings. Fix by requiring that an offset point to a string *boundary* (offset 0 or immediately after a null byte) rather than any valid position — this eliminates false positives from integer fields whose values accidentally alias path substrings. Affected CSVs (CharSections, ItemDisplayInfo for Classic/TBC) can now be regenerated correctly. game_handler: clearDBCCache() is already called by application.cpp before resetDbcCaches(), but also add it inside resetDbcCaches() as a defensive measure so that future callers of resetDbcCaches() alone also flush stale expansion-specific DBC data (CharSections, ItemDisplayInfo, etc.). --- src/game/game_handler.cpp | 7 +++++++ tools/dbc_to_csv/main.cpp | 32 +++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9d5e4587..192c213b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -520,6 +520,13 @@ void GameHandler::resetDbcCaches() { talentDbcLoaded_ = false; talentCache_.clear(); talentTabCache_.clear(); + // Clear the AssetManager DBC file cache so that expansion-specific DBCs + // (CharSections, ItemDisplayInfo, etc.) are reloaded from the new expansion's + // MPQ files instead of returning stale data from a previous session/expansion. + auto* am = core::Application::getInstance().getAssetManager(); + if (am) { + am->clearDBCCache(); + } LOG_INFO("GameHandler: DBC caches cleared for expansion switch"); } diff --git a/tools/dbc_to_csv/main.cpp b/tools/dbc_to_csv/main.cpp index 514d5a40..9afa875d 100644 --- a/tools/dbc_to_csv/main.cpp +++ b/tools/dbc_to_csv/main.cpp @@ -41,9 +41,31 @@ std::vector readFileBytes(const std::string& path) { return buf; } -// Check whether offset points to a plausible string in the string block. -bool isValidStringOffset(const std::vector& stringBlock, uint32_t offset) { +// Precompute the set of valid string-boundary offsets in the string block. +// An offset is a valid boundary if it is 0 or immediately follows a null byte. +// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely +// detected as string offsets just because they land in the middle of a longer +// string that starts at a lower offset. +std::set computeStringBoundaries(const std::vector& stringBlock) { + std::set boundaries; + if (stringBlock.empty()) return boundaries; + boundaries.insert(0); // offset 0 is always a valid start + for (size_t i = 0; i + 1 < stringBlock.size(); ++i) { + if (stringBlock[i] == 0) { + boundaries.insert(static_cast(i + 1)); + } + } + return boundaries; +} + +// Check whether offset points to a valid string-boundary position in the block +// and that the string there is printable and null-terminated. +bool isValidStringOffset(const std::vector& stringBlock, + const std::set& boundaries, + uint32_t offset) { if (offset >= stringBlock.size()) return false; + // Must start at a string boundary (offset 0 or right after a null byte). + if (!boundaries.count(offset)) return false; // Must be null-terminated within the block and contain only printable/whitespace bytes. for (size_t i = offset; i < stringBlock.size(); ++i) { uint8_t c = stringBlock[i]; @@ -75,6 +97,10 @@ std::set detectStringColumns(const DBCFile& dbc, // If no string block (or trivial size), no string columns. if (stringBlock.size() <= 1) return stringCols; + // Precompute valid string-start boundaries to avoid false positives from + // integer fields whose small values accidentally land inside longer strings. + auto boundaries = computeStringBoundaries(stringBlock); + for (uint32_t col = 0; col < fieldCount; ++col) { bool allZeroOrValid = true; bool hasNonZero = false; @@ -83,7 +109,7 @@ std::set detectStringColumns(const DBCFile& dbc, uint32_t val = dbc.getUInt32(row, col); if (val == 0) continue; hasNonZero = true; - if (!isValidStringOffset(stringBlock, val)) { + if (!isValidStringOffset(stringBlock, boundaries, val)) { allZeroOrValid = false; break; } From 5b06a62d916dc05d767fd87465d20dde0fddbf27 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 03:30:24 -0700 Subject: [PATCH 02/67] game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard Five movement control response handlers (speed change, move-root, move-flag change, knock-back, teleport) had guards of the form !isClassicLikeExpansion() or isClassicLikeExpansion() that prevented ACKs from ever being sent on Classic/Turtle. Each handler already contained correct legacyGuidAck logic (full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable due to the outer guard. Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs. Without them the server stalls the player's speed update, keeps root state desynced, or generates movement hacks. Fix by removing the erroneous expansion guard and relying on the existing legacyGuidAck path. Affected: handleForceSpeedChange, handleForceMoveRootState, handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport. --- src/game/game_handler.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 192c213b..ae40b5e1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11063,7 +11063,8 @@ void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* na if (guid != playerGuid) return; // Always ACK the speed change to prevent server stall. - if (socket && !isClassicLikeExpansion()) { + // Classic/TBC use full uint64 GUID; WotLK uses packed GUID. + if (socket) { network::Packet ack(wireOpcode(ackOpcode)); const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); @@ -11149,7 +11150,7 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) movementInfo.flags &= ~static_cast(MovementFlags::ROOT); } - if (!socket || isClassicLikeExpansion()) return; + if (!socket) return; uint16_t ackWire = wireOpcode(rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK : Opcode::CMSG_FORCE_MOVE_UNROOT_ACK); if (ackWire == 0xFFFF) return; @@ -11210,7 +11211,7 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* } } - if (!socket || isClassicLikeExpansion()) return; + if (!socket) return; uint16_t ackWire = wireOpcode(ackOpcode); if (ackWire == 0xFFFF) return; @@ -11265,7 +11266,7 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) { if (guid != playerGuid) return; - if (!socket || isClassicLikeExpansion()) return; + if (!socket) return; uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK); if (ackWire == 0xFFFF) return; @@ -15467,12 +15468,13 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { // Send the ack back to the server // Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time - if (socket && !isClassicLikeExpansion()) { + // Classic/TBC use full uint64 GUID; WotLK uses packed GUID. + if (socket) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK)); const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); if (legacyGuidAck) { - ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for teleport ACK + ack.writeUInt64(playerGuid); // CMaNGOS/VMaNGOS expects full GUID for Classic/TBC } else { MovementPacket::writePackedGuid(ack, playerGuid); } From b31a2a66b600cf7003c0a5c92705275e5162dd0f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 03:49:06 -0700 Subject: [PATCH 03/67] tools: fix DBC string-column detection false positives in both dbc_to_csv and asset_extract The string-column auto-detector in both tools had two gaps that caused small integer fields (RaceID=1, SexID=0/1, BaseSection, ColorIndex) to be falsely classified as string columns, corrupting the generated CSVs: 1. No boundary check: a value of N was accepted as a valid string offset even when N landed inside a longer string (e.g. offset 3 inside "Character\..."). Fix: precompute valid string-start boundaries (offset 0 plus every position immediately after a null byte); reject offsets that are not boundaries. 2. No diversity check: a column whose only non-zero value is 1 would pass the boundary test because offset 1 is always a valid boundary (it follows the mandatory null at offset 0). Fix: require at least 2 distinct non-empty string values before marking a column as a string column. Columns like SexID (all values are 0 or 1, resolving to "" and the same path fragment) are integer fields, not string fields. Both dbc_to_csv and asset_extract now produce correct column metadata, e.g. CharSections.dbc yields "strings=6,7,8" instead of "strings=0,1,...,9". --- tools/asset_extract/extractor.cpp | 39 ++++++++++++++++++++++++++++--- tools/dbc_to_csv/main.cpp | 12 +++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/tools/asset_extract/extractor.cpp b/tools/asset_extract/extractor.cpp index 615b43b9..1df2d510 100644 --- a/tools/asset_extract/extractor.cpp +++ b/tools/asset_extract/extractor.cpp @@ -76,8 +76,29 @@ static std::vector readFileBytes(const std::string& path) { return buf; } -static bool isValidStringOffset(const std::vector& stringBlock, uint32_t offset) { +// Precompute the set of valid string-boundary offsets in the string block. +// An offset is a valid boundary if it is 0 or immediately follows a null byte. +// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely +// detected as string offsets just because they land in the middle of a longer +// string that starts at a lower offset. +static std::set computeStringBoundaries(const std::vector& stringBlock) { + std::set boundaries; + if (stringBlock.empty()) return boundaries; + boundaries.insert(0); + for (size_t i = 0; i + 1 < stringBlock.size(); ++i) { + if (stringBlock[i] == 0) { + boundaries.insert(static_cast(i + 1)); + } + } + return boundaries; +} + +static bool isValidStringOffset(const std::vector& stringBlock, + const std::set& boundaries, + uint32_t offset) { if (offset >= stringBlock.size()) return false; + // Must start at a string boundary (offset 0 or right after a null byte). + if (!boundaries.count(offset)) return false; for (size_t i = offset; i < stringBlock.size(); ++i) { uint8_t c = stringBlock[i]; if (c == 0) return true; @@ -105,21 +126,33 @@ static std::set detectStringColumns(const DBCFile& dbc, std::set cols; if (stringBlock.size() <= 1) return cols; + auto boundaries = computeStringBoundaries(stringBlock); + for (uint32_t col = 0; col < fieldCount; ++col) { bool allZeroOrValid = true; bool hasNonZero = false; + std::set distinctStrings; for (uint32_t row = 0; row < recordCount; ++row) { uint32_t val = dbc.getUInt32(row, col); if (val == 0) continue; hasNonZero = true; - if (!isValidStringOffset(stringBlock, val)) { + if (!isValidStringOffset(stringBlock, boundaries, val)) { allZeroOrValid = false; break; } + // Collect distinct non-empty strings for diversity check. + const char* s = reinterpret_cast(stringBlock.data() + val); + if (*s != '\0') { + distinctStrings.insert(std::string(s, strnlen(s, 256))); + } } - if (allZeroOrValid && hasNonZero) { + // Require at least 2 distinct non-empty string values. Columns that + // only ever point to a single string (e.g. SexID=1 always resolves to + // the same path fragment at offset 1 in the block) are almost certainly + // integer fields whose small values accidentally land at a string boundary. + if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) { cols.insert(col); } } diff --git a/tools/dbc_to_csv/main.cpp b/tools/dbc_to_csv/main.cpp index 9afa875d..53e28bf8 100644 --- a/tools/dbc_to_csv/main.cpp +++ b/tools/dbc_to_csv/main.cpp @@ -104,6 +104,7 @@ std::set detectStringColumns(const DBCFile& dbc, for (uint32_t col = 0; col < fieldCount; ++col) { bool allZeroOrValid = true; bool hasNonZero = false; + std::set distinctStrings; for (uint32_t row = 0; row < recordCount; ++row) { uint32_t val = dbc.getUInt32(row, col); @@ -113,9 +114,18 @@ std::set detectStringColumns(const DBCFile& dbc, allZeroOrValid = false; break; } + // Collect distinct non-empty strings for diversity check. + const char* s = reinterpret_cast(stringBlock.data() + val); + if (*s != '\0') { + distinctStrings.insert(std::string(s, strnlen(s, 256))); + } } - if (allZeroOrValid && hasNonZero) { + // Require at least 2 distinct non-empty string values. Columns that + // only ever point to a single string (e.g. SexID=1 always resolves to + // the same path fragment at offset 1 in the block) are almost certainly + // integer fields whose small values accidentally land at a string boundary. + if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) { stringCols.insert(col); } } From 5c830216be99e0a1d5896a93590dbaa3a0b6508f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:16:27 -0700 Subject: [PATCH 04/67] Remove debug LOG_INFO spam from applyEquipment; use DBC layout for texture fields The verbose diagnostic logs added in 16cdde8 for Classic equipment debugging are no longer needed now that the CSV string-detection bug is fixed. Remove them to eliminate log spam on every character screen open. Also replace the hardcoded `14 + region` texture field lookup with the same DBC-layout-aware array pattern used in game_screen.cpp::updateCharacterTextures, so texture field indices are correctly resolved per expansion. --- src/rendering/character_preview.cpp | 39 +++++++++++------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 9dc0efcf..2314e6e9 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -547,20 +547,6 @@ bool CharacterPreview::applyEquipment(const std::vector& eq 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; @@ -586,10 +572,6 @@ bool CharacterPreview::applyEquipment(const std::vector& eq int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); if (recIdx < 0) return 0; 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; }; @@ -661,6 +643,20 @@ bool CharacterPreview::applyEquipment(const std::vector& eq "LegUpperTexture", "LegLowerTexture", "FootTexture", }; + // Texture component region fields — use DBC layout when available, fall back to binary offsets. + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + const uint32_t texRegionFields[8] = { + idiL ? (*idiL)["TextureArmUpper"] : 14u, + idiL ? (*idiL)["TextureArmLower"] : 15u, + idiL ? (*idiL)["TextureHand"] : 16u, + idiL ? (*idiL)["TextureTorsoUpper"] : 17u, + idiL ? (*idiL)["TextureTorsoLower"] : 18u, + idiL ? (*idiL)["TextureLegUpper"] : 19u, + idiL ? (*idiL)["TextureLegLower"] : 20u, + idiL ? (*idiL)["TextureFoot"] : 21u, + }; + std::vector> regionLayers; regionLayers.reserve(32); @@ -670,13 +666,9 @@ bool CharacterPreview::applyEquipment(const std::vector& eq if (recIdx < 0) continue; for (int region = 0; region < 8; region++) { - uint32_t fieldIdx = 14 + region; // texture_1..texture_8 - std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); + std::string texName = displayInfoDbc->getString(static_cast(recIdx), texRegionFields[region]); 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; @@ -692,7 +684,6 @@ 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 3bee0882cc537a651b21ddca5171afb2acdd10d0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:20:13 -0700 Subject: [PATCH 05/67] game: fix Classic parseQuestDetails missing rewardXp field Vanilla 1.12 SMSG_QUESTGIVER_QUEST_DETAILS includes rewardXp (uint32) after rewardMoney, same as WotLK. Without this read the XP reward was always 0 in the quest accept dialog for Classic. --- src/game/packet_parsers_classic.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index c0ab0c88..abc856fa 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1616,6 +1616,9 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai if (packet.getReadPos() + 4 <= packet.getSize()) data.rewardMoney = packet.readUInt32(); + // Vanilla 1.12 includes rewardXp after rewardMoney (same as WotLK) + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardXp = packet.readUInt32(); LOG_INFO("Quest details classic: id=", data.questId, " title='", data.title, "'"); return true; From d7ef40c9d7b313a81c076ce756f2548194550039 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:23:54 -0700 Subject: [PATCH 06/67] game: downgrade combat/charEnum per-entry LOG_INFO to LOG_DEBUG in Classic/TBC parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combat event logs (melee hit, spell damage, spell heal) fire on every combat event and should be DEBUG-level. The per-character detail lines in parseCharEnum are also moved to DEBUG — the summary line stays at INFO. --- src/game/packet_parsers_classic.cpp | 24 ++++++++++-------------- src/game/packet_parsers_tbc.cpp | 24 ++++++++++-------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index abc856fa..eeebe520 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -447,9 +447,9 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att data.blocked = packet.readUInt32(); } - LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); + LOG_DEBUG("[Classic] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); return true; } @@ -484,8 +484,8 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam 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" : ""); + LOG_DEBUG("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); return true; } @@ -510,8 +510,8 @@ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealL data.overheal = packet.readUInt32(); data.isCrit = (packet.readUInt8() != 0); - LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); return true; } @@ -700,13 +700,9 @@ bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumRespon character.equipment.push_back(item); } - LOG_INFO(" Character ", (int)(i + 1), ": ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass), " (", - getGenderName(character.gender), ")"); - LOG_INFO(" Level: ", (int)character.level); - LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId); + LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name, + " (", getRaceName(character.race), " ", getClassName(character.characterClass), + " level ", (int)character.level, " zone ", character.zoneId, ")"); response.characters.push_back(character); } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 5cef0290..7851007e 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -355,13 +355,9 @@ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& character.equipment.push_back(item); } - LOG_INFO(" Character ", (int)(i + 1), ": ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass), " (", - getGenderName(character.gender), ")"); - LOG_INFO(" Level: ", (int)character.level); - LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId); + LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name, + " (", getRaceName(character.race), " ", getClassName(character.characterClass), + " level ", (int)character.level, " zone ", character.zoneId, ")"); response.characters.push_back(character); } @@ -1131,9 +1127,9 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke data.blocked = packet.readUInt32(); } - LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); + LOG_DEBUG("[TBC] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); return true; } @@ -1163,8 +1159,8 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL // 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" : ""); + LOG_DEBUG("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); return true; } @@ -1187,8 +1183,8 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa data.isCrit = (critFlag != 0); } - LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); return true; } From f53337305021c8a484b9510d1c715c711b2575c8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:25:45 -0700 Subject: [PATCH 07/67] game: downgrade combat/charEnum per-entry LOG_INFO to LOG_DEBUG in WotLK parsers Consistent with the same cleanup for Classic/TBC parsers. Melee hit, spell damage, and spell heal logs fire on every combat event and belong at DEBUG level. Per-character detail lines in the WotLK CharEnum parser are also consolidated to a single DEBUG line per character. --- src/game/world_packets.cpp | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4ecc1555..11049d5d 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -480,21 +480,9 @@ bool CharEnumParser::parse(network::Packet& packet, CharEnumResponse& response) character.equipment.push_back(item); } - LOG_INFO(" Character ", (int)(i + 1), ": ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass), " (", - getGenderName(character.gender), ")"); - LOG_INFO(" Level: ", (int)character.level); - LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId); - LOG_INFO(" Position: (", character.x, ", ", character.y, ", ", character.z, ")"); - if (character.hasGuild()) { - LOG_INFO(" Guild ID: ", character.guildId); - } - if (character.hasPet()) { - LOG_INFO(" Pet: Model ", character.pet.displayModel, - ", Level ", character.pet.level); - } + LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name, + " (", getRaceName(character.race), " ", getClassName(character.characterClass), + " level ", (int)character.level, " zone ", character.zoneId, ")"); response.characters.push_back(character); } @@ -2771,9 +2759,9 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda data.blocked = packet.readUInt32(); } - LOG_INFO("Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); + LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); return true; } @@ -2797,8 +2785,8 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da // Check crit flag data.isCrit = (flags & 0x02) != 0; - LOG_INFO("Spell damage: spellId=", data.spellId, " dmg=", data.damage, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); return true; } @@ -2812,8 +2800,8 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) uint8_t critFlag = packet.readUInt8(); data.isCrit = (critFlag != 0); - LOG_INFO("Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); return true; } From 475e0c213c6dffc88ac800d448df9f667f6ae43e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:30:01 -0700 Subject: [PATCH 08/67] rendering: downgrade per-NPC-spawn LOG_INFO spam to LOG_DEBUG in application.cpp Model batch submesh IDs and NPC geoset lists fire on every NPC spawn and produce excessive log noise in normal gameplay. Downgrade to LOG_DEBUG. Also downgrade per-equipment-slot DBC lookups from LOG_INFO to LOG_DEBUG. --- src/core/application.cpp | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index b3883e0c..f52be835 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -5595,11 +5595,11 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (did == 0) return 0; int32_t idx = itemDisplayDbc->findRecordById(did); if (idx < 0) { - LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc"); + LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc"); return 0; } uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg, " (field=", fGG1, ")"); + LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg); return gg; }; @@ -5729,23 +5729,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x activeGeosets.insert(101); // Default group 1 connector } - // Log model's actual submesh IDs for debugging geoset mismatches - if (auto* md = charRenderer->getModelData(modelId)) { - std::string batchIds; - for (const auto& b : md->batches) { - if (!batchIds.empty()) batchIds += ","; - batchIds += std::to_string(b.submeshId); - } - LOG_INFO("Model batches submeshIds: [", batchIds, "]"); - } - - // Log what geosets we're setting for debugging - std::string geosetList; - for (uint16_t g : activeGeosets) { - if (!geosetList.empty()) geosetList += ","; - geosetList += std::to_string(g); - } - LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]"); charRenderer->setActiveGeosets(instanceId, activeGeosets); if (geosetCape != 0 && npcCapeTextureId) { charRenderer->setGroupTextureOverride(instanceId, 15, npcCapeTextureId); From 31ae689b2ce1809b6a8081bb8bb478b7558849bc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:37:03 -0700 Subject: [PATCH 09/67] game: fix TBC SMSG_QUESTGIVER_QUEST_DETAILS parsing by promoting Classic override to TbcPacketParsers TBC 2.4.3 and Classic 1.12 share the same SMSG_QUESTGIVER_QUEST_DETAILS format. WotLK 3.3.5a adds three extra fields (informUnit u64, flags u32, isFinished u8) that the base QuestDetailsParser::parse handles. TBC had no override, so it fell through to the WotLK heuristic which read flags+isFinished as if they were TBC fields, misaligning choiceCount, rewardMoney, and rewardXp. Fix: move parseQuestDetails from ClassicPacketParsers to TbcPacketParsers. Classic inherits it unchanged (formats are identical). Both expansions now correctly parse: no informUnit, activateAccept(u8), suggestedPlayers(u32), emote section, variable choice/reward item counts, rewardMoney, and rewardXp. --- include/game/packet_parsers.hpp | 5 ++- src/game/packet_parsers_classic.cpp | 63 -------------------------- src/game/packet_parsers_tbc.cpp | 69 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 64 deletions(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 03fc502e..d2556e7b 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -353,6 +353,9 @@ public: // 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; + // TBC/Classic SMSG_QUESTGIVER_QUEST_DETAILS lacks informUnit(u64), flags(u32), + // isFinished(u8) that WotLK added; uses variable item counts + emote section. + bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override; }; /** @@ -402,7 +405,7 @@ public: uint8_t readQuestGiverStatus(network::Packet& packet) override; network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override; network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; - bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override; + // parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3) uint8_t questLogStride() const override { return 3; } bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index eeebe520..946b9acc 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1557,69 +1557,6 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u return packet; } -// ============================================================================ -// Classic SMSG_QUESTGIVER_QUEST_DETAILS — Vanilla 1.12 format -// WotLK inserts an informUnit GUID (8 bytes) between npcGuid and questId. -// Vanilla has: npcGuid(8) + questId(4) + title + details + objectives + ... -// ============================================================================ -bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) { - if (packet.getSize() < 16) return false; - - data.npcGuid = packet.readUInt64(); - // Vanilla: questId follows immediately — no informUnit GUID - data.questId = packet.readUInt32(); - data.title = normalizeWowTextTokens(packet.readString()); - data.details = normalizeWowTextTokens(packet.readString()); - data.objectives = normalizeWowTextTokens(packet.readString()); - - if (packet.getReadPos() + 5 > packet.getSize()) { - LOG_INFO("Quest details classic (short): id=", data.questId, " title='", data.title, "'"); - return !data.title.empty() || data.questId != 0; - } - - /*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(); - for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo - } - } - - // Fixed reward items: variable count + 3 uint32s each - if (packet.getReadPos() + 4 <= packet.getSize()) { - uint32_t rewardCount = packet.readUInt32(); - for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo - } - } - - if (packet.getReadPos() + 4 <= packet.getSize()) - data.rewardMoney = packet.readUInt32(); - // Vanilla 1.12 includes rewardXp after rewardMoney (same as WotLK) - if (packet.getReadPos() + 4 <= packet.getSize()) - data.rewardXp = packet.readUInt32(); - - LOG_INFO("Quest details classic: id=", data.questId, " title='", data.title, "'"); - return true; -} - // ============================================================================ // ClassicPacketParsers::parseCreatureQueryResponse // diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 7851007e..5232ba33 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -694,6 +694,75 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3 return packet; } +// ============================================================================ +// TBC 2.4.3 SMSG_QUESTGIVER_QUEST_DETAILS +// +// TBC and Classic share the same format — neither has the WotLK-specific fields +// (informUnit GUID, flags uint32, isFinished uint8) that were added in 3.x. +// +// Format: +// npcGuid(8) + questId(4) + title + details + objectives +// + activateAccept(1) + suggestedPlayers(4) +// + emoteCount(4) + [delay(4)+type(4)] × emoteCount +// + choiceCount(4) + [itemId(4)+count(4)+displayInfo(4)] × choiceCount +// + rewardCount(4) + [itemId(4)+count(4)+displayInfo(4)] × rewardCount +// + rewardMoney(4) + rewardXp(4) +// ============================================================================ +bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) { + if (packet.getSize() < 16) return false; + + data.npcGuid = packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = normalizeWowTextTokens(packet.readString()); + data.details = normalizeWowTextTokens(packet.readString()); + data.objectives = normalizeWowTextTokens(packet.readString()); + + if (packet.getReadPos() + 5 > packet.getSize()) { + LOG_INFO("Quest details tbc/classic (short): id=", data.questId, " title='", data.title, "'"); + return !data.title.empty() || data.questId != 0; + } + + /*activateAccept*/ packet.readUInt8(); + data.suggestedPlayers = packet.readUInt32(); + + // TBC/Classic: emote section before reward items + 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, up to QUEST_REWARD_CHOICES_COUNT) + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t choiceCount = packet.readUInt32(); + for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { + packet.readUInt32(); // itemId + packet.readUInt32(); // count + packet.readUInt32(); // displayInfo + } + } + + // Fixed reward items (variable count, up to QUEST_REWARDS_COUNT) + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t rewardCount = packet.readUInt32(); + for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { + packet.readUInt32(); // itemId + packet.readUInt32(); // count + packet.readUInt32(); // displayInfo + } + } + + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardMoney = packet.readUInt32(); + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardXp = packet.readUInt32(); + + LOG_INFO("Quest details tbc/classic: id=", data.questId, " title='", data.title, "'"); + return true; +} + // ============================================================================ // TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST // From f22845b2381b9306d789f5c1a30affa8d1c31493 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:46:42 -0700 Subject: [PATCH 10/67] game: downgrade trainer/initial-spells diagnostic LOG_INFO to LOG_DEBUG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug-labeled LOG_INFO calls in handleTrainerList and handleInitialSpells fire every time the trainer window opens or the player logs in, producing noisy output that obscures meaningful events. - handleTrainerList: known spells list dump, hardcoded prerequisite checks (527/25312), and per-spell detail lines → LOG_DEBUG Keep one LOG_INFO for the spell count summary (meaningful lifecycle event) - handleInitialSpells: hardcoded spell presence checks (527/988/1180) → LOG_DEBUG; replace with a single LOG_INFO for spell count summary --- src/game/game_handler.cpp | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ae40b5e1..45969ae6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12771,11 +12771,9 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { knownSpells = {data.spellIds.begin(), data.spellIds.end()}; - // Debug: check if specific spells are in initial spells - bool has527 = knownSpells.count(527u); - bool has988 = knownSpells.count(988u); - bool has1180 = knownSpells.count(1180u); - LOG_INFO("Initial spells include: 527=", has527, " 988=", has988, " 1180=", has1180); + LOG_INFO("SMSG_INITIAL_SPELLS: ", knownSpells.size(), " spells"); + LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u), + " 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u)); // Ensure Attack (6603) and Hearthstone (8690) are always present knownSpells.insert(6603u); @@ -15011,29 +15009,24 @@ void GameHandler::handleTrainerList(network::Packet& packet) { trainerWindowOpen_ = true; gossipWindowOpen = false; - // Debug: log known spells - LOG_INFO("Known spells count: ", knownSpells.size()); + LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells"); + LOG_DEBUG("Known spells count: ", knownSpells.size()); if (knownSpells.size() <= 50) { std::string spellList; for (uint32_t id : knownSpells) { if (!spellList.empty()) spellList += ", "; spellList += std::to_string(id); } - LOG_INFO("Known spells: ", spellList); + LOG_DEBUG("Known spells: ", spellList); } - // Check if specific prerequisite spells are known - bool has527 = knownSpells.count(527u); - bool has25312 = knownSpells.count(25312u); - LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312); - - // Debug: log first few trainer spells to see their state - LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells"); + LOG_DEBUG("Prerequisite check: 527=", knownSpells.count(527u), + " 25312=", knownSpells.count(25312u)); for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) { const auto& s = currentTrainerList_.spells[i]; - LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state, - " cost=", s.spellCost, " reqLvl=", (int)s.reqLevel, - " chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")"); + LOG_DEBUG(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state, + " cost=", s.spellCost, " reqLvl=", (int)s.reqLevel, + " chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")"); } From dd8c2cbb203d1732d7e25909f42330957ed5e2ca Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:48:33 -0700 Subject: [PATCH 11/67] game: downgrade per-item-query LOG_INFO to LOG_DEBUG in game_handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryItemInfo and handleItemQueryResponse fire for every item in inventory, loot windows, vendor lists, and mail — potentially dozens of times at login or when any container is opened. Downgrade to LOG_DEBUG to reduce noise. Also downgrade useItemById search traces to LOG_DEBUG; the final warning (item not found) stays at LOG_WARNING. --- src/game/game_handler.cpp | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 45969ae6..fc5cc0b1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10044,8 +10044,8 @@ void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) { ? packetParsers_->buildItemQuery(entry, queryGuid) : ItemQueryPacket::build(entry, queryGuid); socket->send(packet); - LOG_INFO("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec, - " pending=", pendingItemQueries_.size()); + LOG_DEBUG("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec, + " pending=", pendingItemQueries_.size()); } void GameHandler::handleItemQueryResponse(network::Packet& packet) { @@ -10059,9 +10059,8 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { } pendingItemQueries_.erase(data.entry); - LOG_INFO("handleItemQueryResponse: entry=", data.entry, " valid=", data.valid, - " name='", data.name, "' displayInfoId=", data.displayInfoId, - " pending=", pendingItemQueries_.size()); + LOG_DEBUG("handleItemQueryResponse: entry=", data.entry, " name='", data.name, + "' displayInfoId=", data.displayInfoId, " pending=", pendingItemQueries_.size()); if (data.valid) { itemInfoCache_[data.entry] = data; @@ -14694,12 +14693,12 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) { void GameHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; - LOG_INFO("useItemById: searching for itemId=", itemId, " in backpack (", inventory.getBackpackSize(), " slots)"); + LOG_DEBUG("useItemById: searching for itemId=", itemId); // Search backpack first for (int i = 0; i < inventory.getBackpackSize(); i++) { const auto& slot = inventory.getBackpackSlot(i); if (!slot.empty() && slot.item.itemId == itemId) { - LOG_INFO("useItemById: found itemId=", itemId, " at backpack slot ", i); + LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i); useItemBySlot(i); return; } @@ -14710,7 +14709,7 @@ void GameHandler::useItemById(uint32_t itemId) { for (int slot = 0; slot < bagSize; slot++) { const auto& bagSlot = inventory.getBagSlot(bag, slot); if (!bagSlot.empty() && bagSlot.item.itemId == itemId) { - LOG_INFO("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot); + LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot); useItemInBag(bag, slot); return; } From 4972472b2a4a9e7a14cdaca89a87330738f07c9c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:51:01 -0700 Subject: [PATCH 12/67] security+game: downgrade auth credential and high-frequency LOG_INFO to LOG_DEBUG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AUTH HASH logs (sessionKey, hash input, digest): session key material must never appear in production logs at INFO level — downgrade to DEBUG - SMSG_AUTH_CHALLENGE field details (seeds, unknown1): downgrade to DEBUG; keep one INFO line with format name for connection diagnostics - SMSG_MOTD per-line content: downgrade to DEBUG; keep INFO line count - Transport position update per-entity: fires on every update for each entity riding a transport — downgrade to DEBUG --- src/game/world_packets.cpp | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 11049d5d..91f5d36a 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -228,10 +228,10 @@ std::vector AuthSessionPacket::computeAuthHash( } return s; }; - LOG_INFO("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed, - " serverSeed=0x", serverSeed, std::dec); - LOG_INFO("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size())); - LOG_INFO("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size())); + LOG_DEBUG("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed, + " serverSeed=0x", serverSeed, std::dec); + LOG_DEBUG("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size())); + LOG_DEBUG("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size())); } // Compute SHA1 hash @@ -245,7 +245,7 @@ std::vector AuthSessionPacket::computeAuthHash( } return s; }; - LOG_INFO("AUTH HASH: digest=", toHex(result.data(), result.size())); + LOG_DEBUG("AUTH HASH: digest=", toHex(result.data(), result.size())); } return result; @@ -265,22 +265,22 @@ bool AuthChallengeParser::parse(network::Packet& packet, AuthChallengeData& data // Original vanilla/TBC format: just the server seed (4 bytes) data.unknown1 = 0; data.serverSeed = packet.readUInt32(); - LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (TBC format, 4 bytes):"); + LOG_INFO("SMSG_AUTH_CHALLENGE: TBC format (", packet.getSize(), " bytes)"); } else if (packet.getSize() < 40) { // Vanilla with encryption seeds (36 bytes): serverSeed + 32 bytes seeds // No "unknown1" prefix — first uint32 IS the server seed data.unknown1 = 0; data.serverSeed = packet.readUInt32(); - LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (Classic+seeds format, ", packet.getSize(), " bytes):"); + LOG_INFO("SMSG_AUTH_CHALLENGE: Classic+seeds format (", packet.getSize(), " bytes)"); } else { // WotLK format (40+ bytes): unknown1 + serverSeed + 32 bytes encryption seeds data.unknown1 = packet.readUInt32(); data.serverSeed = packet.readUInt32(); - LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (WotLK format, ", packet.getSize(), " bytes):"); - LOG_INFO(" Unknown1: 0x", std::hex, data.unknown1, std::dec); + LOG_INFO("SMSG_AUTH_CHALLENGE: WotLK format (", packet.getSize(), " bytes)"); + LOG_DEBUG(" Unknown1: 0x", std::hex, data.unknown1, std::dec); } - LOG_INFO(" Server seed: 0x", std::hex, data.serverSeed, std::dec); + LOG_DEBUG(" Server seed: 0x", std::hex, data.serverSeed, std::dec); return true; } @@ -586,8 +586,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) { uint32_t lineCount = packet.readUInt32(); - LOG_INFO("Parsed SMSG_MOTD:"); - LOG_INFO(" Line count: ", lineCount); + LOG_INFO("Parsed SMSG_MOTD: ", lineCount, " line(s)"); data.lines.clear(); data.lines.reserve(lineCount); @@ -595,7 +594,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) { for (uint32_t i = 0; i < lineCount; ++i) { std::string line = packet.readString(); data.lines.push_back(line); - LOG_INFO(" [", i + 1, "] ", line); + LOG_DEBUG(" MOTD[", i + 1, "]: ", line); } return true; @@ -1021,9 +1020,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock block.hasMovement = true; if (block.onTransport) { - LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation, - " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); + LOG_DEBUG(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation, + " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); } } else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) { From 4dab5daf792d1fcbc00976f164c042acbffdf678 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:52:22 -0700 Subject: [PATCH 13/67] game: remove duplicate initial-spells LOG_INFO and downgrade debug spell list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - world_packets.cpp::InitialSpellsParser::parse already logs spell count at LOG_INFO; remove the duplicate count from handleInitialSpells() - Downgrade verbose format-detection LOG_INFO to LOG_DEBUG (packet size, format name, first-10 spell IDs) — these are diagnostic details that clutter INFO output without adding operational value --- src/game/game_handler.cpp | 1 - src/game/world_packets.cpp | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fc5cc0b1..d9cf49e1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12770,7 +12770,6 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { knownSpells = {data.spellIds.begin(), data.spellIds.end()}; - LOG_INFO("SMSG_INITIAL_SPELLS: ", knownSpells.size(), " spells"); LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u), " 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u)); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 91f5d36a..d64c6d49 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2839,8 +2839,8 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data size_t remainingAfterHeader = packetSize - 3; // subtract talentSpec(1) + spellCount(2) bool vanillaFormat = remainingAfterHeader < static_cast(spellCount) * 6 + 2; - LOG_INFO("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount, - vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)"); + LOG_DEBUG("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount, + vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)"); data.spellIds.reserve(spellCount); for (uint16_t i = 0; i < spellCount; ++i) { @@ -2876,14 +2876,13 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ", data.cooldowns.size(), " cooldowns"); - // Log first 10 spell IDs for debugging if (!data.spellIds.empty()) { std::string first10; for (size_t i = 0; i < std::min(size_t(10), data.spellIds.size()); ++i) { if (!first10.empty()) first10 += ", "; first10 += std::to_string(data.spellIds[i]); } - LOG_INFO("First spells: ", first10); + LOG_DEBUG("Initial spell IDs (first 10): ", first10); } return true; From f0233c092b301d30634b83bfb6951b0d152a86ff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 04:56:42 -0700 Subject: [PATCH 14/67] game: downgrade false-positive LOG_WARNING calls for normal game events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_SHOW_BANK and SMSG_BUY_BANK_SLOT_RESULT are normal interactions that were incorrectly logged at WARNING level. LOGIN_VERIFY_WORLD coord dump is diagnostic detail, not a warning condition. Downgraded: - SMSG_SHOW_BANK: LOG_WARNING → LOG_INFO - SMSG_BUY_BANK_SLOT_RESULT: LOG_WARNING → LOG_INFO - SMSG_TRANSFER_PENDING: LOG_WARNING → LOG_INFO (from previous session) - SMSG_NEW_WORLD: LOG_WARNING → LOG_INFO (from previous session) - LOGIN_VERIFY_WORLD coord dump: LOG_WARNING → LOG_DEBUG --- src/game/game_handler.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d9cf49e1..4ee45df0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4147,7 +4147,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_TRANSFER_PENDING: { // SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data uint32_t pendingMapId = packet.readUInt32(); - LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); + LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); // Optional: if remaining data, there's a transport entry + mapId if (packet.getReadPos() + 8 <= packet.getSize()) { uint32_t transportEntry = packet.readUInt32(); @@ -5959,7 +5959,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { // Initialize movement info with world entry position (server → canonical) glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); - LOG_WARNING("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z, + LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z, ") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId); movementInfo.x = canonical.x; movementInfo.y = canonical.y; @@ -15495,7 +15495,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { float serverZ = packet.readFloat(); float orientation = packet.readFloat(); - LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId, + LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId, " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", " orient=", orientation); @@ -17361,7 +17361,7 @@ void GameHandler::handleShowBank(network::Packet& packet) { for (int i = 0; i < effectiveBankBagSlots_; i++) { if (inventory.getBankBagSize(i) > 0) filledBags++; } - LOG_WARNING("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec, + LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec, " purchased=", static_cast(inventory.getPurchasedBankBagSlots()), " filledBags=", filledBags, " effectiveBankBagSlots=", effectiveBankBagSlots_); @@ -17370,7 +17370,7 @@ void GameHandler::handleShowBank(network::Packet& packet) { void GameHandler::handleBuyBankSlotResult(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t result = packet.readUInt32(); - LOG_WARNING("SMSG_BUY_BANK_SLOT_RESULT: result=", result); + LOG_INFO("SMSG_BUY_BANK_SLOT_RESULT: result=", result); // AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK if (result == 3) { addSystemChatMessage("Bank slot purchased."); From 682f47f66b23dc8c3b1bd92d4587acf945b4423a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:09:43 -0700 Subject: [PATCH 15/67] game: downgrade high-frequency per-interaction LOG_INFO/WARNING to LOG_DEBUG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demote parse-level diagnostic logs that fire on every game interaction: - TBC/Classic gossip, quest details, quest rewards: LOG_INFO → LOG_DEBUG - WotLK gossip, quest details/reward/request-items: LOG_INFO → LOG_DEBUG - Attack start/stop, XP gain, loot, name query, vendor, party: LOG_INFO → LOG_DEBUG - TBC SMSG_UPDATE_OBJECT has_transport fallback: LOG_WARNING → LOG_DEBUG - TBC parseAuraUpdate not-in-TBC diagnostic: LOG_WARNING → LOG_DEBUG - Turtle SMSG_MONSTER_MOVE WotLK fallback: LOG_WARNING → LOG_DEBUG These all fire multiple times per second during normal gameplay. --- src/game/packet_parsers_classic.cpp | 4 ++-- src/game/packet_parsers_tbc.cpp | 10 +++++----- src/game/world_packets.cpp | 28 ++++++++++++++-------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 946b9acc..03e0c5a0 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1012,7 +1012,7 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes data.quests.push_back(quest); } - LOG_INFO("Classic Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("Classic Gossip: ", optionCount, " options, ", questCount, " quests"); return true; } @@ -1503,7 +1503,7 @@ bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveD packet.setReadPos(start); if (MonsterMoveParser::parse(packet, data)) { - LOG_WARNING("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); + LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); return true; } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 5232ba33..ffc7d3cd 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -484,7 +484,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa packet.setReadPos(startPos); if (parseWithLayout(false, parsed)) { - LOG_WARNING("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + LOG_DEBUG("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); data = std::move(parsed); return true; } @@ -536,7 +536,7 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage data.quests.push_back(quest); } - LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests"); return true; } @@ -718,7 +718,7 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa data.objectives = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 5 > packet.getSize()) { - LOG_INFO("Quest details tbc/classic (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest details tbc/classic (short): id=", data.questId, " title='", data.title, "'"); return !data.title.empty() || data.questId != 0; } @@ -759,7 +759,7 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa if (packet.getReadPos() + 4 <= packet.getSize()) data.rewardXp = packet.readUInt32(); - LOG_INFO("Quest details tbc/classic: id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest details tbc/classic: id=", data.questId, " title='", data.title, "'"); return true; } @@ -783,7 +783,7 @@ network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32 // SMSG_SET_EXTRA_AURA_INFO_OBSOLETE (0x3A4) instead // ============================================================================ bool TbcPacketParsers::parseAuraUpdate(network::Packet& /*packet*/, AuraUpdateData& /*data*/, bool /*isAll*/) { - LOG_WARNING("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3"); + LOG_DEBUG("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3"); return false; } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d64c6d49..69427728 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2228,7 +2228,7 @@ bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseDa data.gender = packet.readUInt8(); data.classId = packet.readUInt8(); - LOG_INFO("Name query response: ", data.name, " (race=", (int)data.race, + LOG_DEBUG("Name query response: ", data.name, " (race=", (int)data.race, " class=", (int)data.classId, ")"); return true; } @@ -2718,7 +2718,7 @@ bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) { if (packet.getSize() < 16) return false; data.attackerGuid = packet.readUInt64(); data.victimGuid = packet.readUInt64(); - LOG_INFO("Attack started: 0x", std::hex, data.attackerGuid, + LOG_DEBUG("Attack started: 0x", std::hex, data.attackerGuid, " -> 0x", data.victimGuid, std::dec); return true; } @@ -2729,7 +2729,7 @@ bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { if (packet.getReadPos() < packet.getSize()) { data.unknown = packet.readUInt32(); } - LOG_INFO("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); + LOG_DEBUG("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); return true; } @@ -2821,7 +2821,7 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { data.groupBonus = data.totalXp - static_cast(data.totalXp / groupRate); } } - LOG_INFO("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); + LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); return data.totalXp > 0; } @@ -3173,7 +3173,7 @@ bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResult data.command = static_cast(packet.readUInt32()); data.name = packet.readString(); data.result = static_cast(packet.readUInt32()); - LOG_INFO("Party command result: ", (int)data.result); + LOG_DEBUG("Party command result: ", (int)data.result); return true; } @@ -3320,7 +3320,7 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data) } } - LOG_INFO("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount, + LOG_DEBUG("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount, " quest items, ", data.gold, " copper"); return true; } @@ -3389,7 +3389,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) data.objectives = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 10 > packet.getSize()) { - LOG_INFO("Quest details (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'"); return true; } @@ -3426,7 +3426,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) if (packet.getReadPos() + 4 <= packet.getSize()) data.rewardXp = packet.readUInt32(); - LOG_INFO("Quest details: id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'"); return true; } @@ -3463,7 +3463,7 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data data.quests.push_back(quest); } - LOG_INFO("Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("Gossip: ", optionCount, " options, ", questCount, " quests"); return true; } @@ -3495,7 +3495,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa data.completionText = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 9 > packet.getSize()) { - LOG_INFO("Quest request items (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'"); return true; } @@ -3571,7 +3571,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa data.completableFlags = chosen->completableFlags; data.requiredItems = chosen->requiredItems; - LOG_INFO("Quest request items: id=", data.questId, " title='", data.title, + LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title, "' items=", data.requiredItems.size(), " completable=", data.isCompletable()); return true; } @@ -3584,7 +3584,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.rewardText = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 10 > packet.getSize()) { - LOG_INFO("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); return true; } @@ -3698,7 +3698,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.rewardXp = best->rewardXp; } - LOG_INFO("Quest offer reward: id=", data.questId, " title='", data.title, + LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size()); return true; } @@ -3811,7 +3811,7 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data data.items.push_back(item); } - LOG_INFO("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")"); + LOG_DEBUG("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")"); return true; } From fb80b125bd4580eb43725b42a74c6c72dfbb2ea3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:18:45 -0700 Subject: [PATCH 16/67] Fix post-hearthstone asset gaps and add quest tracker interactivity Hearthstone post-teleport fix: - Expand same-map hearthstone precache from 5x5 to 9x9 tiles so workers have more tiles parsed before the player arrives at the bind point - After same-map teleport arrival, enqueue the full load-radius tile grid (17x17 = 289 tiles) at the new position so background workers immediately start loading all WMOs/M2s visible from the new location Quest tracker improvements: - Clicking a quest in the tracker now opens the Quest Log (L) - Remove NoInputs flag so the tracker window receives mouse events - Show only tracked quests in tracker; fall back to all quests if none tracked - Add Track/Untrack button in Quest Log details panel - Abandoning a quest automatically untracks it - Track state stored in GameHandler::trackedQuestIds_ (per-session) --- include/game/game_handler.hpp | 7 ++++++ src/core/application.cpp | 23 ++++++++++++++--- src/ui/game_screen.cpp | 47 +++++++++++++++++++++++++++-------- src/ui/quest_log_screen.cpp | 10 ++++++-- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 47dd523d..a596c959 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -966,6 +966,12 @@ public: const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); bool requestQuestQuery(uint32_t questId, bool force = false); + bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; } + void setQuestTracked(uint32_t questId, bool tracked) { + if (tracked) trackedQuestIds_.insert(questId); + else trackedQuestIds_.erase(questId); + } + const std::unordered_set& getTrackedQuestIds() const { return trackedQuestIds_; } bool isQuestQueryPending(uint32_t questId) const { return pendingQuestQueryIds_.count(questId) > 0; } @@ -1981,6 +1987,7 @@ private: // Quest log std::vector questLog_; std::unordered_set pendingQuestQueryIds_; + std::unordered_set trackedQuestIds_; bool pendingLoginQuestResync_ = false; float pendingLoginQuestResyncTimeout_ = 0.0f; diff --git a/src/core/application.cpp b/src/core/application.cpp index f52be835..ca528bb2 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1714,6 +1714,21 @@ void Application::setupUICallbacks() { // (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before // the first frame at the new position. renderer->getTerrainManager()->processAllReadyTiles(); + + // Queue all remaining tiles within the load radius (8 tiles = 17x17) + // at the new position. precacheTiles skips already-loaded/pending tiles, + // so this only enqueues tiles that aren't yet in the pipeline. + // This ensures background workers immediately start loading everything + // visible from the new position (hearthstone may land far from old location). + { + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + std::vector> nearbyTiles; + nearbyTiles.reserve(289); + for (int dy = -8; dy <= 8; dy++) + for (int dx = -8; dx <= 8; dx++) + nearbyTiles.push_back({tileX + dx, tileY + dy}); + renderer->getTerrainManager()->precacheTiles(nearbyTiles); + } return; } @@ -1976,13 +1991,15 @@ void Application::setupUICallbacks() { if (mapId == loadedMapId_) { // Same map: pre-enqueue tiles around the bind point so workers start // loading them now. Uses render-space coords (canonicalToRender). + // Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time + // for workers to parse most of these before the player arrives. 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.reserve(81); + for (int dy = -4; dy <= 4; dy++) + for (int dx = -4; dx <= 4; dx++) tiles.push_back({tileX + dx, tileY + dy}); terrainMgr->precacheTiles(tiles); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 50c2a062..8e608990 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4549,13 +4549,34 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { constexpr float RIGHT_MARGIN = 10.0f; constexpr int MAX_QUESTS = 5; + // Build display list: tracked quests only, or all quests if none tracked + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + std::vector toShow; + toShow.reserve(MAX_QUESTS); + if (!trackedIds.empty()) { + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (trackedIds.count(q.questId)) toShow.push_back(&q); + if (static_cast(toShow.size()) >= MAX_QUESTS) break; + } + } + // Fallback: show all quests if nothing is tracked + if (toShow.empty()) { + for (const auto& q : questLog) { + if (q.questId == 0) continue; + toShow.push_back(&q); + if (static_cast(toShow.size()) >= MAX_QUESTS) break; + } + } + if (toShow.empty()) return; + float x = screenW - TRACKER_W - RIGHT_MARGIN; float y = 200.0f; // below minimap area ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; @@ -4564,15 +4585,23 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); if (ImGui::Begin("##QuestTracker", nullptr, flags)) { - int shown = 0; - for (const auto& q : questLog) { - if (q.questId == 0) continue; - if (shown >= MAX_QUESTS) break; + for (int i = 0; i < static_cast(toShow.size()); ++i) { + const auto& q = *toShow[i]; - // Quest title in yellow (gold) if complete, white if in progress + // Clickable quest title — opens quest log + ImGui::PushID(q.questId); ImVec4 titleCol = q.complete ? ImVec4(1.0f, 0.84f, 0.0f, 1.0f) : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); - ImGui::TextColored(titleCol, "%s", q.title.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, titleCol); + if (ImGui::Selectable(q.title.c_str(), false, + ImGuiSelectableFlags_None, ImVec2(TRACKER_W - 12.0f, 0))) { + questLogScreen.setOpen(true); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Click to open Quest Log"); + } + ImGui::PopStyleColor(); + ImGui::PopID(); // Objectives line (condensed) if (q.complete) { @@ -4599,7 +4628,6 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } } if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) { - // Show the raw objectives text, truncated if needed const std::string& obj = q.objectives; if (obj.size() > 40) { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), @@ -4611,10 +4639,9 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } } - if (shown < MAX_QUESTS - 1 && shown < static_cast(questLog.size()) - 1) { + if (i < static_cast(toShow.size()) - 1) { ImGui::Spacing(); } - ++shown; } } ImGui::End(); diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index ad7df081..4f7f0dec 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -373,11 +373,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { } } - // Abandon button + // Track / Abandon buttons + ImGui::Separator(); + bool isTracked = gameHandler.isQuestTracked(sel.questId); + if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) { + gameHandler.setQuestTracked(sel.questId, !isTracked); + } if (!sel.complete) { - ImGui::Separator(); + ImGui::SameLine(); if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) { gameHandler.abandonQuest(sel.questId); + gameHandler.setQuestTracked(sel.questId, false); selectedIndex = -1; } } From 962c640ff56ff5f827cc17faf1c8dd08ac9bcb29 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:26:16 -0700 Subject: [PATCH 17/67] ui: add raid frames, quest log scroll-to-quest, and quest tracking polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raid frames: - When groupType=1 (raid), render compact grid-style raid frames instead of the vertical party list that would overflow for 25/40-man groups - Members organized by subgroup (G1-G8), up to 5 rows per subgroup column - Each cell shows: name, health bar (green/yellow/red), power bar (class color) - Clicking a cell targets the member; border highlight for current target - Frames anchored above action bar area, centered horizontally Quest log scroll-to-quest: - openAndSelectQuest(questId) selects the quest AND scrolls the list pane to show it (SetScrollHereY on the first render frame after open) - One-shot scroll: scrollToSelected_ cleared after first use so normal scroll behavior is unaffected afterward Quest tracker: - Clicking a tracked quest now calls openAndSelectQuest() — opens the log AND jumps to that specific quest rather than just opening to top --- include/ui/quest_log_screen.hpp | 8 ++ src/ui/game_screen.cpp | 147 +++++++++++++++++++++++++++++++- src/ui/quest_log_screen.cpp | 17 ++++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index 7e289e92..d86abedc 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -13,11 +13,19 @@ public: bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } + // Open the log and scroll to the given quest (by questId) + void openAndSelectQuest(uint32_t questId) { + open = true; + pendingSelectQuestId_ = questId; + scrollToSelected_ = true; + } private: bool open = false; bool lKeyWasDown = false; int selectedIndex = -1; + uint32_t pendingSelectQuestId_ = 0; // non-zero: select this quest on next render + bool scrollToSelected_ = false; // true: call SetScrollHereY once after selection uint32_t lastDetailRequestQuestId_ = 0; double lastDetailRequestAt_ = 0.0; std::unordered_set questDetailQueryNoResponse_; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8e608990..a91cb1d1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4595,7 +4595,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_Text, titleCol); if (ImGui::Selectable(q.title.c_str(), false, ImGuiSelectableFlags_None, ImVec2(TRACKER_W - 12.0f, 0))) { - questLogScreen.setOpen(true); + questLogScreen.openAndSelectQuest(q.questId); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Click to open Quest Log"); @@ -4888,8 +4888,153 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (!gameHandler.isInGroup()) return; const auto& partyData = gameHandler.getPartyData(); + const bool isRaid = (partyData.groupType == 1); float frameY = 120.0f; + // ---- Raid frame layout ---- + if (isRaid) { + // Organize members by subgroup (0-7, up to 5 members each) + constexpr int MAX_SUBGROUPS = 8; + constexpr int MAX_PER_GROUP = 5; + std::vector subgroups[MAX_SUBGROUPS]; + for (const auto& m : partyData.members) { + int sg = m.subGroup < MAX_SUBGROUPS ? m.subGroup : 0; + if (static_cast(subgroups[sg].size()) < MAX_PER_GROUP) + subgroups[sg].push_back(&m); + } + + // Count non-empty subgroups to determine layout + int activeSgs = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) + if (!subgroups[sg].empty()) activeSgs++; + + // Compact raid cell: name + 2 narrow bars + constexpr float CELL_W = 90.0f; + constexpr float CELL_H = 42.0f; + constexpr float BAR_H = 7.0f; + constexpr float CELL_PAD = 3.0f; + + float winW = activeSgs * (CELL_W + CELL_PAD) + CELL_PAD + 8.0f; + float winH = MAX_PER_GROUP * (CELL_H + CELL_PAD) + CELL_PAD + 20.0f; + + 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 raidX = (screenW - winW) / 2.0f; + float raidY = screenH - winH - 120.0f; // above action bar area + + ImGui::SetNextWindowPos(ImVec2(raidX, raidY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Always); + + ImGuiWindowFlags raidFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(CELL_PAD, CELL_PAD)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.07f, 0.07f, 0.1f, 0.85f)); + + if (ImGui::Begin("##RaidFrames", nullptr, raidFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 winPos = ImGui::GetWindowPos(); + + int colIdx = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { + if (subgroups[sg].empty()) continue; + + float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); + + for (int row = 0; row < static_cast(subgroups[sg].size()); row++) { + const auto& m = *subgroups[sg][row]; + float cellY = winPos.y + CELL_PAD + 14.0f + row * (CELL_H + CELL_PAD); + + ImVec2 cellMin(colX, cellY); + ImVec2 cellMax(colX + CELL_W, cellY + CELL_H); + + // Cell background + bool isTarget = (gameHandler.getTargetGuid() == m.guid); + ImU32 bg = isTarget ? IM_COL32(60, 80, 120, 200) : IM_COL32(30, 30, 40, 180); + draw->AddRectFilled(cellMin, cellMax, bg, 3.0f); + if (isTarget) + draw->AddRect(cellMin, cellMax, IM_COL32(100, 150, 255, 200), 3.0f); + + // Dead/ghost overlay + bool isOnline = (m.onlineStatus & 0x0001) != 0; + bool isDead = (m.onlineStatus & 0x0020) != 0; + bool isGhost = (m.onlineStatus & 0x0010) != 0; + + // Name text (truncated) + char truncName[16]; + snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); + ImU32 nameCol = (!isOnline || isDead || isGhost) + ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); + draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); + + // Health bar + uint32_t hp = m.hasPartyStats ? m.curHealth : 0; + uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + float barY = cellMin.y + 16.0f; + ImVec2 barBg(cellMin.x + 3.0f, barY); + ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H); + draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); + ImVec2 barFill(barBg.x, barBg.y); + ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); + ImU32 hpCol = pct > 0.5f ? IM_COL32(60, 180, 60, 255) : + pct > 0.2f ? IM_COL32(200, 180, 50, 255) : + IM_COL32(200, 60, 60, 255); + draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); + } + + // Power bar + if (m.hasPartyStats && m.maxPower > 0) { + float pct = static_cast(m.curPower) / static_cast(m.maxPower); + float barY = cellMin.y + 16.0f + BAR_H + 2.0f; + ImVec2 barBg(cellMin.x + 3.0f, barY); + ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H - 2.0f); + draw->AddRectFilled(barBg, barBgEnd, IM_COL32(30, 30, 40, 200), 2.0f); + ImVec2 barFill(barBg.x, barBg.y); + ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); + ImU32 pwrCol; + switch (m.powerType) { + case 0: pwrCol = IM_COL32(50, 80, 220, 255); break; // Mana + case 1: pwrCol = IM_COL32(200, 50, 50, 255); break; // Rage + case 3: pwrCol = IM_COL32(220, 210, 50, 255); break; // Energy + case 6: pwrCol = IM_COL32(180, 30, 50, 255); break; // Runic Power + default: pwrCol = IM_COL32(80, 120, 80, 255); break; + } + draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); + } + + // Clickable invisible region over the whole cell + ImGui::SetCursorScreenPos(cellMin); + ImGui::PushID(static_cast(m.guid)); + if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { + gameHandler.setTarget(m.guid); + } + ImGui::PopID(); + } + colIdx++; + } + + // Subgroup header row + colIdx = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { + if (subgroups[sg].empty()) continue; + float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); + char sgLabel[8]; + snprintf(sgLabel, sizeof(sgLabel), "G%d", sg + 1); + draw->AddText(ImVec2(colX + CELL_W / 2 - 8.0f, winPos.y + CELL_PAD), IM_COL32(160, 160, 180, 200), sgLabel); + colIdx++; + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); + return; + } + + // ---- Party frame layout (5-man) ---- ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 4f7f0dec..00fbd173 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -261,6 +261,18 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { ImGui::BeginChild("QuestListPane", ImVec2(paneW, 0), true); ImGui::TextColored(ImVec4(0.85f, 0.82f, 0.74f, 1.0f), "Quest List"); ImGui::Separator(); + + // Resolve pending select from tracker click + if (pendingSelectQuestId_ != 0) { + for (size_t i = 0; i < quests.size(); i++) { + if (quests[i].questId == pendingSelectQuestId_) { + selectedIndex = static_cast(i); + break; + } + } + pendingSelectQuestId_ = 0; + } + for (size_t i = 0; i < quests.size(); i++) { const auto& q = quests[i]; ImGui::PushID(static_cast(i)); @@ -274,6 +286,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { if (rowW < 1.0f) rowW = 1.0f; bool clicked = ImGui::InvisibleButton("questRowBtn", ImVec2(rowW, rowH)); bool hovered = ImGui::IsItemHovered(); + // Scroll to selected quest on the first frame after openAndSelectQuest() + if (selected && scrollToSelected_) { + ImGui::SetScrollHereY(0.5f); + scrollToSelected_ = false; + } ImVec2 rowMin = ImGui::GetItemRectMin(); ImVec2 rowMax = ImGui::GetItemRectMax(); From 983c64720dff355200de935726ee65c9f64857de Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:33:21 -0700 Subject: [PATCH 18/67] ui: show party member dots on minimap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draw a dot for each online party member that has reported a position via SMSG_PARTY_MEMBER_STATS. Leader gets a gold dot, others get blue. A white outline ring is drawn around each dot, and hovering over it shows the member's name as a tooltip. Out-of-range members are silently skipped by the existing projectToMinimap clamp logic. Axis mapping follows the same convention as minimap pings: server posX (east/west) → canonical Y, server posY (north/south) → canonical X. --- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a91cb1d1..37af08a5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8354,6 +8354,38 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(sx, sy), 2.5f, col); } + // Party member dots on minimap + { + const auto& partyData = gameHandler.getPartyData(); + const uint64_t leaderGuid = partyData.leaderGuid; + for (const auto& member : partyData.members) { + if (!member.isOnline || !member.hasPartyStats) continue; + if (member.posX == 0 && member.posY == 0) continue; + + // posX/posY follow same server axis convention as minimap pings: + // server posX = east/west axis → canonical Y (west) + // server posY = north/south axis → canonical X (north) + float wowX = static_cast(member.posY); + float wowY = static_cast(member.posX); + glm::vec3 memberRender = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); + + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(memberRender, sx, sy)) continue; + + ImU32 dotColor = (member.guid == leaderGuid) + ? IM_COL32(255, 210, 0, 235) + : IM_COL32(100, 180, 255, 235); + drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); + drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); + + ImVec2 cursorPos = ImGui::GetMousePos(); + float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; + if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { + ImGui::SetTooltip("%s", member.name.c_str()); + } + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; From 65c4bd1a1796028d54384a46d1b270da8f4634f1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:36:59 -0700 Subject: [PATCH 19/67] ui: WoW-style clock-sweep cooldown overlay on action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain yellow text cooldown overlay with a proper clock-sweep: - Dark fan spanning the elapsed fraction of the cooldown, sweeping clockwise from 12 o'clock (matches WoW's native cooldown look) - White remaining-time text with drop-shadow centered on the icon - Minutes shown as "Xm" for cooldowns >= 60s, seconds otherwise - Fan radius set to 1.5× the icon half-width to cover corners on the square icon; works for both icon and empty (label-only) slots --- src/ui/game_screen.cpp | 51 +++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 37af08a5..b72363cb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4049,23 +4049,48 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } - // Cooldown overlay - if (onCooldown && iconTex) { - // Draw cooldown text centered over the icon + // Cooldown overlay: WoW-style clock-sweep + time text + if (onCooldown) { ImVec2 btnMin = ImGui::GetItemRectMin(); ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + + auto* dl = ImGui::GetWindowDrawList(); + + // Dark sweep over the elapsed fraction, starting at 12 o'clock + float total = (slot.cooldownTotal > 0.0f) ? slot.cooldownTotal : 1.0f; + float elapsed = total - slot.cooldownRemaining; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); + + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 32; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.5f; // reach the icon corners + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); + } + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170)); + } + + // Remaining-time text: white with drop-shadow char cdText[16]; - snprintf(cdText, sizeof(cdText), "%.0f", slot.cooldownRemaining); + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) { + snprintf(cdText, sizeof(cdText), "%dm", static_cast(cd) / 60); + } else { + snprintf(cdText, sizeof(cdText), "%.0f", cd); + } ImVec2 textSize = ImGui::CalcTextSize(cdText); - float cx = btnMin.x + (btnMax.x - btnMin.x - textSize.x) * 0.5f; - float cy = btnMin.y + (btnMax.y - btnMin.y - textSize.y) * 0.5f; - ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 0, 255), cdText); - } else if (onCooldown) { - char cdText[16]; - snprintf(cdText, sizeof(cdText), "%.0f", slot.cooldownRemaining); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - slotSize / 2 - 8); - ImGui::TextColored(ImVec4(1, 1, 0, 1), "%s", cdText); + float tx = cx - textSize.x * 0.5f; + float ty = cy - textSize.y * 0.5f; + dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText); + dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); } // Key label below From bbf0c0b22cf391c2f5673cdd85fd4454b99d112a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:38:52 -0700 Subject: [PATCH 20/67] ui: show nameplates for all nearby units, not just target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V toggle previously only rendered a nameplate for the currently targeted unit. Now all nearby units get a nameplate when nameplates are enabled, matching WoW's native behaviour: - Target: nameplate shown up to 40 units, gold border highlight - All other units: shown up to 20 units, dark border (no highlight) - Fade-out range, hostility colour, level label, and health bar logic are all unchanged — only the per-entity distance culling changes --- src/ui/game_screen.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b72363cb..a48e2353 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4824,17 +4824,17 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { auto* unit = dynamic_cast(entityPtr.get()); if (!unit || unit->getMaxHealth() == 0) continue; - // Only show nameplate for the currently targeted unit - if (guid != targetGuid) continue; + bool isTarget = (guid == targetGuid); // Convert canonical WoW position → render space, raise to head height glm::vec3 renderPos = core::coords::canonicalToRender( glm::vec3(unit->getX(), unit->getY(), unit->getZ())); renderPos.z += 2.3f; - // Cull if too far (render units ≈ WoW yards) + // Cull distance: target up to 40 units; others up to 20 units float dist = glm::length(renderPos - camPos); - if (dist > 40.0f) continue; + float cullDist = isTarget ? 40.0f : 20.0f; + if (dist > cullDist) continue; // Project to clip space glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); @@ -4863,7 +4863,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); } - ImU32 borderColor = (guid == targetGuid) + ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) : IM_COL32(20, 20, 20, A(180)); From f98cc329473ce051f09c608db610bdae88a2f569 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:40:53 -0700 Subject: [PATCH 21/67] docs: update Discord invite link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 54ae7eaa..7353ed15 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A native C++ World of Warcraft client with a custom Vulkan renderer. [![Sponsor](https://img.shields.io/github/sponsors/Kelsidavis?label=Sponsor&logo=GitHub)](https://github.com/sponsors/Kelsidavis) -[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/SDqjA79B) +[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/PSdMPS8uje) [![Watch the video](https://img.youtube.com/vi/B-jtpPmiXGM/maxresdefault.jpg)](https://youtu.be/B-jtpPmiXGM) From 7cd8e86d3b4140b566f83716159d75a0851813d6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:46:03 -0700 Subject: [PATCH 22/67] game,ui: add ContactEntry struct and Friends tab in social frame Store structured friend data (online status, level, area, class) that was previously discarded in handleFriendList/handleContactList. New ContactEntry struct lives in game_handler.hpp; getContacts() exposes it. UI: the O-key Social window (formerly guild-only) now has a Friends tab. - Shows online/offline status dot, name, level, and AFK/DND label - Pressing O when not in a guild opens Social directly on the Friends tab - The window title changed from "Guild" to "Social" for accuracy - Non-guild players no longer get a "not in a guild" rejection on O press --- include/game/game_handler.hpp | 22 +++++++++- include/ui/game_screen.hpp | 2 + src/game/game_handler.cpp | 47 ++++++++++++++++++---- src/ui/game_screen.cpp | 76 +++++++++++++++++++++++++++++------ 4 files changed, 125 insertions(+), 22 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a596c959..153324b6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -55,6 +55,24 @@ enum class QuestGiverStatus : uint8_t { REWARD = 10 // ? (yellow) }; +/** + * A single contact list entry (friend, ignore, or mute). + */ +struct ContactEntry { + uint64_t guid = 0; + std::string name; + std::string note; + uint32_t flags = 0; // 0x1=friend, 0x2=ignore, 0x4=mute + uint8_t status = 0; // 0=offline, 1=online, 2=AFK, 3=DND + uint32_t areaId = 0; + uint32_t level = 0; + uint32_t classId = 0; + + bool isFriend() const { return (flags & 0x1) != 0; } + bool isIgnored() const { return (flags & 0x2) != 0; } + bool isOnline() const { return status != 0; } +}; + /** * World connection state */ @@ -800,6 +818,7 @@ public: void leaveGroup(); bool isInGroup() const { return !partyData.isEmpty(); } const GroupListData& getPartyData() const { return partyData; } + const std::vector& getContacts() const { return contacts_; } bool hasPendingGroupInvite() const { return pendingGroupInvite; } const std::string& getPendingInviterName() const { return pendingInviterName; } @@ -1662,11 +1681,12 @@ private: std::unordered_map gameObjectInfoCache_; std::unordered_set pendingGameObjectQueries_; - // ---- Friend list cache ---- + // ---- Friend/contact 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; + std::vector contacts_; // structured contact list (friends + ignores) // ---- World state and faction initialization snapshots ---- uint32_t worldStateMapId_ = 0; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1496ea28..15e098e7 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -64,6 +64,7 @@ private: bool showChatWindow = true; bool showNameplates_ = true; // V key toggles nameplates bool showPlayerInfo = false; + bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; std::string selectedGuildMember_; bool showGuildNoteEdit_ = false; @@ -219,6 +220,7 @@ private: void renderSharedQuestPopup(game::GameHandler& gameHandler); void renderItemTextWindow(game::GameHandler& gameHandler); void renderBuffBar(game::GameHandler& gameHandler); + void renderSocialFrame(game::GameHandler& gameHandler); void renderLootWindow(game::GameHandler& gameHandler); void renderGossipWindow(game::GameHandler& gameHandler); void renderQuestDetailsWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4ee45df0..6e6e8d54 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -483,6 +483,7 @@ void GameHandler::disconnect() { playerNameCache.clear(); pendingNameQueries.clear(); friendGuids_.clear(); + contacts_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); requiresWarden_ = false; @@ -16425,6 +16426,11 @@ void GameHandler::handleFriendList(network::Packet& packet) { if (rem() < 1) return; uint8_t count = packet.readUInt8(); LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries"); + + // Rebuild friend contacts (keep ignores from previous contact_ entries) + contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), + [](const ContactEntry& e){ return e.isFriend(); }), contacts_.end()); + for (uint8_t i = 0; i < count && rem() >= 9; ++i) { uint64_t guid = packet.readUInt64(); uint8_t status = packet.readUInt8(); @@ -16434,18 +16440,28 @@ void GameHandler::handleFriendList(network::Packet& packet) { 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); + std::string name; if (nit != playerNameCache.end()) { - friendsCache[nit->second] = guid; - LOG_INFO(" Friend: ", nit->second, " status=", (int)status); + name = nit->second; + friendsCache[name] = guid; + LOG_INFO(" Friend: ", name, " status=", (int)status); } else { LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec, " status=", (int)status, " (name pending)"); queryPlayerName(guid); } + ContactEntry entry; + entry.guid = guid; + entry.name = name; + entry.flags = 0x1; // friend + entry.status = status; + entry.areaId = area; + entry.level = level; + entry.classId = classId; + contacts_.push_back(std::move(entry)); } } @@ -16469,19 +16485,23 @@ void GameHandler::handleContactList(network::Packet& packet) { } lastContactListMask_ = packet.readUInt32(); lastContactListCount_ = packet.readUInt32(); + contacts_.clear(); 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; + uint8_t status = 0; + uint32_t areaId = 0; + uint32_t level = 0; + uint32_t classId = 0; if (flags & 0x1) { // SOCIAL_FLAG_FRIEND if (rem() < 1) break; - uint8_t status = packet.readUInt8(); + status = packet.readUInt8(); if (status != 0 && rem() >= 12) { - packet.readUInt32(); // area - packet.readUInt32(); // level - packet.readUInt32(); // class + areaId = packet.readUInt32(); + level = packet.readUInt32(); + classId = packet.readUInt32(); } friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); @@ -16492,6 +16512,17 @@ void GameHandler::handleContactList(network::Packet& packet) { } } // ignore / mute entries: no additional fields beyond guid+flags+note + ContactEntry entry; + entry.guid = guid; + entry.flags = flags; + entry.note = std::move(note); + entry.status = status; + entry.areaId = areaId; + entry.level = level; + entry.classId = classId; + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) entry.name = nit->second; + contacts_.push_back(std::move(entry)); } LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, " count=", lastContactListCount_); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a48e2353..1a12493a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5530,21 +5530,20 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { + // Open friends tab directly if not in guild if (!gameHandler.isInGuild()) { - gameHandler.addLocalChatMessage(game::MessageChatData{ - game::ChatType::SYSTEM, game::ChatLanguage::UNIVERSAL, 0, "", 0, "", "You are not in a guild.", "", 0}); - showGuildRoster_ = false; - return; - } - // Re-query guild name if we have guildId but no name yet - if (gameHandler.getGuildName().empty()) { - const auto* ch = gameHandler.getActiveCharacter(); - if (ch && ch->hasGuild()) { - gameHandler.queryGuildInfo(ch->guildId); + guildRosterTab_ = 2; // Friends tab + } else { + // Re-query guild name if we have guildId but no name yet + if (gameHandler.getGuildName().empty()) { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && ch->hasGuild()) { + gameHandler.queryGuildInfo(ch->guildId); + } } + gameHandler.requestGuildRoster(); + gameHandler.requestGuildInfo(); } - gameHandler.requestGuildRoster(); - gameHandler.requestGuildInfo(); } } @@ -5595,7 +5594,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); - std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Guild") : "Guild"; + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social"; bool open = showGuildRoster_; if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { // Tab bar: Roster | Guild Info @@ -5899,6 +5898,57 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + guildRosterTab_ = 2; + const auto& contacts = gameHandler.getContacts(); + + // Filter to friends only + int friendCount = 0; + for (const auto& c : contacts) { + if (!c.isFriend()) continue; + ++friendCount; + + // Status dot + ImU32 dotColor = c.isOnline() + ? IM_COL32(80, 200, 80, 255) + : IM_COL32(120, 120, 120, 255); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor); + ImGui::Dummy(ImVec2(14.0f, 0.0f)); + ImGui::SameLine(); + + // Name + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); + + // Level and status on same line (right-aligned) + if (c.isOnline()) { + ImGui::SameLine(); + const char* statusLabel = + (c.status == 2) ? "(AFK)" : + (c.status == 3) ? "(DND)" : ""; + if (c.level > 0) { + ImGui::TextDisabled("Lv %u %s", c.level, statusLabel); + } else if (*statusLabel) { + ImGui::TextDisabled("%s", statusLabel); + } + } + } + + if (friendCount == 0) { + ImGui::TextDisabled("No friends online."); + } + + ImGui::Separator(); + ImGui::TextDisabled("Right-click a player's name in chat to add friends."); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } } From 3fce3adb39e353c8c40bdfef669a962c1dec2b2f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:48:37 -0700 Subject: [PATCH 23/67] game: keep contacts_ in sync with SMSG_FRIEND_STATUS updates When a friend goes online, offline, or is added/removed, update the contacts_ vector in addition to friendsCache. This ensures the Friends tab in the Social window always reflects the current state without needing a full SMSG_CONTACT_LIST/SMSG_FRIEND_LIST refresh. --- src/game/game_handler.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6e6e8d54..4c5d635e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16551,6 +16551,28 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { friendsCache.erase(playerName); } + // Mirror into contacts_: update existing entry or add/remove as needed + if (data.status == 0) { // Removed from friends list + contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }), contacts_.end()); + } else { + auto cit = std::find_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }); + if (cit != contacts_.end()) { + if (!playerName.empty() && playerName != "Unknown") cit->name = playerName; + // status: 2=online→1, 3=offline→0, 1=added→1 (online on add) + if (data.status == 2) cit->status = 1; + else if (data.status == 3) cit->status = 0; + } else { + ContactEntry entry; + entry.guid = data.guid; + entry.name = playerName; + entry.flags = 0x1; // friend + entry.status = (data.status == 2) ? 1 : 0; + contacts_.push_back(std::move(entry)); + } + } + // Status messages switch (data.status) { case 0: From 6f7363fbcb8b66a6a1563e9fcaec9cdc8b8d8319 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:50:26 -0700 Subject: [PATCH 24/67] ui: click-to-target via nameplate hit testing Left-clicking anywhere within a nameplate's bounding box (name text + health bar) calls setTarget() for that unit. Uses manual mouse position hit testing since nameplates are drawn on the background DrawList rather than as ImGui widgets. Click is ignored when ImGui has captured the mouse (e.g. when a window is open). --- src/ui/game_screen.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1a12493a..8467a5d1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4902,6 +4902,18 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { : IM_COL32(240, 200, 100, A(230)); drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + + // Click to target: detect left-click inside the combined nameplate region + if (!ImGui::GetIO().WantCaptureMouse && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + ImVec2 mouse = ImGui::GetIO().MousePos; + float nx0 = nameX - 2.0f; + float ny0 = nameY - 1.0f; + float nx1 = nameX + textSize.x + 2.0f; + float ny1 = sy + barH + 2.0f; + if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { + gameHandler.setTarget(guid); + } + } } } From 753790ae47db5cfcad81fa28d26a7e2505622a80 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 05:52:55 -0700 Subject: [PATCH 25/67] ui: show persistent zone name above minimap Draws the current zone name centered above the minimap circle using a gold-colored 12pt label with drop shadow. This gives players a constant location reference without needing to trigger the full-screen zone flash. Uses getForegroundDrawList so it renders above the minimap texture. --- src/ui/game_screen.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8467a5d1..e259ba2f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8510,6 +8510,22 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } }; + // Zone name label above the minimap (centered, WoW-style) + { + const std::string& zoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + if (!zoneName.empty()) { + auto* fgDl = ImGui::GetForegroundDrawList(); + float zoneTextY = centerY - mapRadius - 16.0f; + ImFont* font = ImGui::GetFont(); + ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + float tzx = centerX - tsz.x * 0.5f; + fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), + IM_COL32(0, 0, 0, 180), zoneName.c_str()); + fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), + IM_COL32(255, 220, 120, 230), zoneName.c_str()); + } + } + // Speaker mute button at the minimap top-right corner ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - 26.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); From 90b8cccac595bdd9f9c29ffb6b735ac2b0eff023 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:04:43 -0700 Subject: [PATCH 26/67] ui,game: add second action bar (Shift+1-12 keybinds, slots 12-23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand action bar from 12 to 24 slots (2 bars × 12). Bar 2 is rendered above bar 1 and loaded from SMSG_ACTION_BUTTONS slots 12-23. Pressing Shift+number activates the corresponding bar-2 slot. Drag-and-drop, cooldown overlays, and tooltips work identically on both bars. Bar 2 fades slightly when all its slots are empty to minimize visual noise. --- include/game/game_handler.hpp | 8 +- src/game/game_handler.cpp | 3 +- src/ui/game_screen.cpp | 468 +++++++++++++++++----------------- 3 files changed, 235 insertions(+), 244 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 153324b6..b5f397a9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -588,8 +588,10 @@ public: const std::unordered_map& getAllTalentTabs() const { return talentTabCache_; } void loadTalentDbc(); - // Action bar - static constexpr int ACTION_BAR_SLOTS = 12; + // Action bar — 2 bars × 12 slots = 24 total + static constexpr int SLOTS_PER_BAR = 12; + static constexpr int ACTION_BARS = 2; + static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24 std::array& getActionBar() { return actionBar; } const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); @@ -1839,7 +1841,7 @@ private: bool areaTriggerSuppressFirst_ = false; // suppress first check after map transfer float castTimeTotal = 0.0f; - std::array actionBar{}; + std::array actionBar{}; std::vector playerAuras; std::vector targetAuras; uint64_t petGuid_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4c5d635e..2b995906 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3333,12 +3333,11 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint8_t mode =*/ packet.readUInt8(); rem--; constexpr int SERVER_BAR_SLOTS = 144; - constexpr int OUR_BAR_SLOTS = 12; // our actionBar array size for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { if (rem < 4) break; uint32_t packed = packet.readUInt32(); rem -= 4; - if (i >= OUR_BAR_SLOTS) continue; // only load first bar + if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2 if (packed == 0) { // Empty slot — only clear if not already set to Attack/Hearthstone defaults // so we don't wipe hardcoded fallbacks when the server sends zeros. diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e259ba2f..e69cf7f9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1417,14 +1417,16 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS }; - for (int i = 0; i < 12; ++i) { + const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const auto& bar = gameHandler.getActionBar(); + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { if (input.isKeyJustPressed(actionBarKeys[i])) { - const auto& bar = gameHandler.getActionBar(); - if (bar[i].type == game::ActionBarSlot::SPELL && bar[i].isReady()) { + int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; + if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(bar[i].id, target); - } else if (bar[i].type == game::ActionBarSlot::ITEM && bar[i].id != 0) { - gameHandler.useItemById(bar[i].id); + gameHandler.castSpell(bar[slotIdx].id, target); + } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { + gameHandler.useItemById(bar[slotIdx].id); } } } @@ -3842,262 +3844,250 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - if (ImGui::Begin("##ActionBar", nullptr, flags)) { - const auto& bar = gameHandler.getActionBar(); - static const char* keyLabels[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; + // Per-slot rendering lambda — shared by both action bars + const auto& bar = gameHandler.getActionBar(); + static const char* keyLabels1[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; + // "⇧N" labels for bar 2 (UTF-8: E2 87 A7 = U+21E7 UPWARDS WHITE ARROW) + static const char* keyLabels2[] = { + "\xe2\x87\xa7" "1", "\xe2\x87\xa7" "2", "\xe2\x87\xa7" "3", + "\xe2\x87\xa7" "4", "\xe2\x87\xa7" "5", "\xe2\x87\xa7" "6", + "\xe2\x87\xa7" "7", "\xe2\x87\xa7" "8", "\xe2\x87\xa7" "9", + "\xe2\x87\xa7" "0", "\xe2\x87\xa7" "-", "\xe2\x87\xa7" "=" + }; - for (int i = 0; i < 12; ++i) { - if (i > 0) ImGui::SameLine(0, spacing); + auto renderBarSlot = [&](int absSlot, const char* keyLabel) { + ImGui::BeginGroup(); + ImGui::PushID(absSlot); - ImGui::BeginGroup(); - ImGui::PushID(i); + const auto& slot = bar[absSlot]; + bool onCooldown = !slot.isReady(); - const auto& slot = bar[i]; - bool onCooldown = !slot.isReady(); + auto getSpellName = [&](uint32_t spellId) -> std::string { + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) return name; + return "Spell #" + std::to_string(spellId); + }; - auto getSpellName = [&](uint32_t spellId) -> std::string { - std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); - if (!name.empty()) return name; - return "Spell #" + std::to_string(spellId); - }; - - // Try to get icon texture for this slot - VkDescriptorSet iconTex = VK_NULL_HANDLE; - const game::ItemDef* barItemDef = nullptr; - uint32_t itemDisplayInfoId = 0; - std::string itemNameFromQuery; - if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { - iconTex = getSpellIcon(slot.id, assetMgr); - } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - // Search backpack - auto& inv = gameHandler.getInventory(); - for (int bi = 0; bi < inv.getBackpackSize(); bi++) { - const auto& bs = inv.getBackpackSlot(bi); - if (!bs.empty() && bs.item.itemId == slot.id) { - barItemDef = &bs.item; - break; - } - } - // Search equipped slots - if (!barItemDef) { - for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) { - const auto& es = inv.getEquipSlot(static_cast(ei)); - if (!es.empty() && es.item.itemId == slot.id) { - barItemDef = &es.item; - break; - } - } - } - // Search extra bags - if (!barItemDef) { - for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) { - for (int si = 0; si < inv.getBagSize(bag); si++) { - const auto& bs = inv.getBagSlot(bag, si); - if (!bs.empty() && bs.item.itemId == slot.id) { - barItemDef = &bs.item; - break; - } - } - } - } - if (barItemDef && barItemDef->displayInfoId != 0) { - itemDisplayInfoId = barItemDef->displayInfoId; - } - // Fallback: use item info cache (from server query responses) - if (itemDisplayInfoId == 0) { - if (auto* info = gameHandler.getItemInfo(slot.id)) { - itemDisplayInfoId = info->displayInfoId; - if (itemNameFromQuery.empty() && !info->name.empty()) - itemNameFromQuery = info->name; - } - } - if (itemDisplayInfoId != 0) { - iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); + // Try to get icon texture for this slot + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const game::ItemDef* barItemDef = nullptr; + uint32_t itemDisplayInfoId = 0; + std::string itemNameFromQuery; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { + iconTex = getSpellIcon(slot.id, assetMgr); + } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + auto& inv = gameHandler.getInventory(); + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } + } + if (!barItemDef) { + for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) { + const auto& es = inv.getEquipSlot(static_cast(ei)); + if (!es.empty() && es.item.itemId == slot.id) { barItemDef = &es.item; break; } } } - - bool clicked = false; - if (iconTex) { - // Render icon-based button - ImVec4 tintColor(1, 1, 1, 1); - ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); - if (onCooldown) { - tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); - bgColor = ImVec4(0.1f, 0.1f, 0.1f, 0.8f); + if (!barItemDef) { + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } + } } - clicked = ImGui::ImageButton("##icon", - (ImTextureID)(uintptr_t)iconTex, - ImVec2(slotSize, slotSize), - ImVec2(0, 0), ImVec2(1, 1), - bgColor, tintColor); + } + if (barItemDef && barItemDef->displayInfoId != 0) + itemDisplayInfoId = barItemDef->displayInfoId; + if (itemDisplayInfoId == 0) { + if (auto* info = gameHandler.getItemInfo(slot.id)) { + itemDisplayInfoId = info->displayInfoId; + if (itemNameFromQuery.empty() && !info->name.empty()) + itemNameFromQuery = info->name; + } + } + if (itemDisplayInfoId != 0) + iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); + } + + bool clicked = false; + if (iconTex) { + ImVec4 tintColor(1, 1, 1, 1); + ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); + if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + clicked = ImGui::ImageButton("##icon", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(slotSize, slotSize), + ImVec2(0, 0), ImVec2(1, 1), + bgColor, tintColor); + } else { + if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + + char label[32]; + if (slot.type == game::ActionBarSlot::SPELL) { + std::string spellName = getSpellName(slot.id); + if (spellName.size() > 6) spellName = spellName.substr(0, 6); + snprintf(label, sizeof(label), "%s", spellName.c_str()); + } else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) { + std::string itemName = barItemDef->name; + if (itemName.size() > 6) itemName = itemName.substr(0, 6); + snprintf(label, sizeof(label), "%s", itemName.c_str()); + } else if (slot.type == game::ActionBarSlot::ITEM) { + snprintf(label, sizeof(label), "Item"); + } else if (slot.type == game::ActionBarSlot::MACRO) { + snprintf(label, sizeof(label), "Macro"); } else { - // Fallback to text button - if (onCooldown) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); - } else if (slot.isEmpty()) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); - } - - char label[32]; - if (slot.type == game::ActionBarSlot::SPELL) { - std::string spellName = getSpellName(slot.id); - if (spellName.size() > 6) spellName = spellName.substr(0, 6); - snprintf(label, sizeof(label), "%s", spellName.c_str()); - } else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) { - std::string itemName = barItemDef->name; - if (itemName.size() > 6) itemName = itemName.substr(0, 6); - snprintf(label, sizeof(label), "%s", itemName.c_str()); - } else if (slot.type == game::ActionBarSlot::ITEM) { - snprintf(label, sizeof(label), "Item"); - } else if (slot.type == game::ActionBarSlot::MACRO) { - snprintf(label, sizeof(label), "Macro"); - } else { - snprintf(label, sizeof(label), "--"); - } - - clicked = ImGui::Button(label, ImVec2(slotSize, slotSize)); - ImGui::PopStyleColor(); + snprintf(label, sizeof(label), "--"); } + clicked = ImGui::Button(label, ImVec2(slotSize, slotSize)); + ImGui::PopStyleColor(); + } - bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); - bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && - ImGui::IsMouseReleased(ImGuiMouseButton_Left); + bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); + bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + ImGui::IsMouseReleased(ImGuiMouseButton_Left); - // Drop dragged spell from spellbook onto this slot - // (mouse release over slot — button click won't fire since press was in spellbook) - if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) { - gameHandler.setActionBarSlot(i, game::ActionBarSlot::SPELL, - spellbookScreen.getDragSpellId()); - spellbookScreen.consumeDragSpell(); - } else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) { - // Drop held item from inventory onto action bar - const auto& held = inventoryScreen.getHeldItem(); - gameHandler.setActionBarSlot(i, game::ActionBarSlot::ITEM, held.itemId); - inventoryScreen.returnHeldItem(gameHandler.getInventory()); - } else if (clicked && actionBarDragSlot_ >= 0) { - // Dropping a dragged action bar slot onto another slot - swap or place - if (i != actionBarDragSlot_) { - const auto& dragSrc = bar[actionBarDragSlot_]; - auto srcType = dragSrc.type; - auto srcId = dragSrc.id; - gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id); - gameHandler.setActionBarSlot(i, srcType, srcId); - } - actionBarDragSlot_ = -1; - actionBarDragIcon_ = 0; - } else if (clicked && !slot.isEmpty()) { - // Left-click on non-empty slot: cast spell or use item - if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(slot.id, target); - } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - gameHandler.useItemById(slot.id); - } - } else if (rightClicked && !slot.isEmpty()) { - // Right-click on non-empty slot: pick up for dragging - actionBarDragSlot_ = i; - actionBarDragIcon_ = iconTex; + if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::SPELL, + spellbookScreen.getDragSpellId()); + spellbookScreen.consumeDragSpell(); + } else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) { + const auto& held = inventoryScreen.getHeldItem(); + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::ITEM, held.itemId); + inventoryScreen.returnHeldItem(gameHandler.getInventory()); + } else if (clicked && actionBarDragSlot_ >= 0) { + if (absSlot != actionBarDragSlot_) { + const auto& dragSrc = bar[actionBarDragSlot_]; + gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id); + gameHandler.setActionBarSlot(absSlot, dragSrc.type, dragSrc.id); } + actionBarDragSlot_ = -1; + actionBarDragIcon_ = 0; + } else if (clicked && !slot.isEmpty()) { + if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + gameHandler.useItemById(slot.id); + } + } else if (rightClicked && !slot.isEmpty()) { + actionBarDragSlot_ = absSlot; + actionBarDragIcon_ = iconTex; + } - // Tooltip - if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { - ImGui::BeginTooltip(); - if (slot.type == game::ActionBarSlot::SPELL) { - std::string fullName = getSpellName(slot.id); - ImGui::Text("%s", fullName.c_str()); - // Hearthstone: show bind point info - if (slot.id == 8690) { - uint32_t mapId = 0; - glm::vec3 pos; - if (gameHandler.getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; - } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), - "Home: %s", mapName); + // Tooltip + if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { + ImGui::BeginTooltip(); + if (slot.type == game::ActionBarSlot::SPELL) { + ImGui::Text("%s", getSpellName(slot.id).c_str()); + if (slot.id == 8690) { + uint32_t mapId = 0; glm::vec3 pos; + if (gameHandler.getHomeBind(mapId, pos)) { + const char* mapName = "Unknown"; + switch (mapId) { + case 0: mapName = "Eastern Kingdoms"; break; + case 1: mapName = "Kalimdor"; break; + case 530: mapName = "Outland"; break; + case 571: mapName = "Northrend"; break; } - ImGui::TextDisabled("Use: Teleport home"); - } - } else if (slot.type == game::ActionBarSlot::ITEM) { - if (barItemDef && !barItemDef->name.empty()) { - ImGui::Text("%s", barItemDef->name.c_str()); - } else if (!itemNameFromQuery.empty()) { - ImGui::Text("%s", itemNameFromQuery.c_str()); - } else { - ImGui::Text("Item #%u", slot.id); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); } + ImGui::TextDisabled("Use: Teleport home"); } - // Show cooldown time remaining - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) { - int mins = static_cast(cd) / 60; - int secs = static_cast(cd) % 60; - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %d min %d sec", mins, secs); - } else { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %.1f sec", cd); - } - } - ImGui::EndTooltip(); + } else if (slot.type == game::ActionBarSlot::ITEM) { + if (barItemDef && !barItemDef->name.empty()) + ImGui::Text("%s", barItemDef->name.c_str()); + else if (!itemNameFromQuery.empty()) + ImGui::Text("%s", itemNameFromQuery.c_str()); + else + ImGui::Text("Item #%u", slot.id); } - - // Cooldown overlay: WoW-style clock-sweep + time text if (onCooldown) { - ImVec2 btnMin = ImGui::GetItemRectMin(); - ImVec2 btnMax = ImGui::GetItemRectMax(); - float cx = (btnMin.x + btnMax.x) * 0.5f; - float cy = (btnMin.y + btnMax.y) * 0.5f; - float r = (btnMax.x - btnMin.x) * 0.5f; - - auto* dl = ImGui::GetWindowDrawList(); - - // Dark sweep over the elapsed fraction, starting at 12 o'clock - float total = (slot.cooldownTotal > 0.0f) ? slot.cooldownTotal : 1.0f; - float elapsed = total - slot.cooldownRemaining; - float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); - - if (elapsedFrac > 0.005f) { - constexpr int N_SEGS = 32; - float startAngle = -IM_PI * 0.5f; - float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; - float fanR = r * 1.5f; // reach the icon corners - ImVec2 pts[N_SEGS + 2]; - pts[0] = ImVec2(cx, cy); - for (int s = 0; s <= N_SEGS; ++s) { - float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); - pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); - } - dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170)); - } - - // Remaining-time text: white with drop-shadow - char cdText[16]; float cd = slot.cooldownRemaining; - if (cd >= 60.0f) { - snprintf(cdText, sizeof(cdText), "%dm", static_cast(cd) / 60); - } else { - snprintf(cdText, sizeof(cdText), "%.0f", cd); + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); + } + + // Cooldown overlay: WoW-style clock-sweep + time text + if (onCooldown) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + + float total = (slot.cooldownTotal > 0.0f) ? slot.cooldownTotal : 1.0f; + float elapsed = total - slot.cooldownRemaining; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 32; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.5f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); } - ImVec2 textSize = ImGui::CalcTextSize(cdText); - float tx = cx - textSize.x * 0.5f; - float ty = cy - textSize.y * 0.5f; - dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText); - dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170)); } - // Key label below - ImGui::TextDisabled("%s", keyLabels[i]); + char cdText[16]; + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm", (int)cd / 60); + else snprintf(cdText, sizeof(cdText), "%.0f", cd); + ImVec2 textSize = ImGui::CalcTextSize(cdText); + float tx = cx - textSize.x * 0.5f; + float ty = cy - textSize.y * 0.5f; + dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText); + dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); + } - ImGui::PopID(); - ImGui::EndGroup(); + // Key label below + ImGui::TextDisabled("%s", keyLabel); + + ImGui::PopID(); + ImGui::EndGroup(); + }; + + // Bar 2 (slots 12-23) — only show if at least one slot is populated + { + bool bar2HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } + + float bar2Y = barY - barH - 2.0f; + ImGui::SetNextWindowPos(ImVec2(barX, bar2Y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar2HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBar2", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + renderBarSlot(game::GameHandler::SLOTS_PER_BAR + i, keyLabels2[i]); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Bar 1 (slots 0-11) + if (ImGui::Begin("##ActionBar", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + renderBarSlot(i, keyLabels1[i]); } } ImGui::End(); From a2dd8ee5b55e71dfef3389684c56fb2d88928348 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:10:29 -0700 Subject: [PATCH 27/67] game,ui: implement MSG_RAID_TARGET_UPDATE and display raid marks Parse the full and single-update variants of MSG_RAID_TARGET_UPDATE to track which guid carries each of the 8 raid icons (Star/Circle/Diamond/ Triangle/Moon/Square/Cross/Skull). Marks are cleared on world transfer. The target frame now shows the Unicode symbol for the target's raid mark in its faction color to the left of the name. Nameplates show the same symbol to the left of the unit name for all nearby marked units. --- include/game/game_handler.hpp | 18 ++++++++++++++++ src/game/game_handler.cpp | 30 ++++++++++++++++++++++++-- src/ui/game_screen.cpp | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b5f397a9..42481376 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -888,6 +888,21 @@ public: return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0; } + // Raid target markers (MSG_RAID_TARGET_UPDATE) + // Icon indices 0-7: Star, Circle, Diamond, Triangle, Moon, Square, Cross, Skull + static constexpr uint32_t kRaidMarkCount = 8; + // Returns the GUID marked with the given icon (0 = no mark) + uint64_t getRaidMarkGuid(uint32_t icon) const { + return (icon < kRaidMarkCount) ? raidTargetGuids_[icon] : 0; + } + // Returns the raid mark icon for a given guid (0xFF = no mark) + uint8_t getEntityRaidMark(uint64_t guid) const { + if (guid == 0) return 0xFF; + for (uint32_t i = 0; i < kRaidMarkCount; ++i) + if (raidTargetGuids_[i] == guid) return static_cast(i); + return 0xFF; + } + // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { None = 0, @@ -1864,6 +1879,9 @@ private: uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; + // Raid target markers (icon 0-7 -> guid; 0 = empty slot) + std::array raidTargetGuids_ = {}; + // Mirror timers (0=fatigue, 1=breath, 2=feigndeath) MirrorTimer mirrorTimers_[3]; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2b995906..20da8625 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3652,8 +3652,33 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::MSG_RAID_TARGET_UPDATE: + case Opcode::MSG_RAID_TARGET_UPDATE: { + // uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)), + // 1 = single update (uint8 icon + uint64 guid) + size_t remRTU = packet.getSize() - packet.getReadPos(); + if (remRTU < 1) break; + uint8_t rtuType = packet.readUInt8(); + if (rtuType == 0) { + // Full update: always 8 entries + for (uint32_t i = 0; i < kRaidMarkCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 9) break; + uint8_t icon = packet.readUInt8(); + uint64_t guid = packet.readUInt64(); + if (icon < kRaidMarkCount) + raidTargetGuids_[icon] = guid; + } + } else { + // Single update + if (packet.getSize() - packet.getReadPos() >= 9) { + uint8_t icon = packet.readUInt8(); + uint64_t guid = packet.readUInt64(); + if (icon < kRaidMarkCount) + raidTargetGuids_[icon] = guid; + } + } + LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); break; + } case Opcode::SMSG_BUY_ITEM: { // uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount // Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT. @@ -5985,8 +6010,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { mountCallback_(0); } - // Clear boss encounter unit slots on world transfer + // Clear boss encounter unit slots and raid marks on world transfer encounterUnitGuids_.fill(0); + raidTargetGuids_.fill(0); // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e69cf7f9..bded7481 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2065,11 +2065,31 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_Border, borderColor); if (ImGui::Begin("##TargetFrame", nullptr, flags)) { + // Raid mark icon (Star/Circle/Diamond/Triangle/Moon/Square/Cross/Skull) + static const struct { const char* sym; ImU32 col; } kRaidMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star (yellow) + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle (orange) + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple) + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green) + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue) + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal) + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red) + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white) + }; + uint8_t mark = gameHandler.getEntityRaidMark(target->getGuid()); + if (mark < game::GameHandler::kRaidMarkCount) { + ImGui::GetWindowDrawList()->AddText( + ImGui::GetCursorScreenPos(), + kRaidMarks[mark].col, kRaidMarks[mark].sym); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); + } + // Entity name and type std::string name = getEntityName(target); ImVec4 nameColor = hostileColor; + ImGui::SameLine(0.0f, 0.0f); ImGui::TextColored(nameColor, "%s", name.c_str()); // Level (for units/players) — colored by difficulty @@ -4893,6 +4913,26 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + // Raid mark (if any) to the left of the name + { + static const struct { const char* sym; ImU32 col; } kNPMarks[] = { + { "\xe2\x98\x85", IM_COL32(255,220, 50,230) }, // Star + { "\xe2\x97\x8f", IM_COL32(255,140, 0,230) }, // Circle + { "\xe2\x97\x86", IM_COL32(160, 32,240,230) }, // Diamond + { "\xe2\x96\xb2", IM_COL32( 50,200, 50,230) }, // Triangle + { "\xe2\x97\x8c", IM_COL32( 80,160,255,230) }, // Moon + { "\xe2\x96\xa0", IM_COL32( 50,200,220,230) }, // Square + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80,230) }, // Cross + { "\xe2\x98\xa0", IM_COL32(255,255,255,230) }, // Skull + }; + uint8_t raidMark = gameHandler.getEntityRaidMark(guid); + if (raidMark < game::GameHandler::kRaidMarkCount) { + float markX = nameX - 14.0f; + drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym); + drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym); + } + } + // Click to target: detect left-click inside the combined nameplate region if (!ImGui::GetIO().WantCaptureMouse && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { ImVec2 mouse = ImGui::GetIO().MousePos; From 463e3f5ed158d1e222d3a70989eb465d9b793dbe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:21:05 -0700 Subject: [PATCH 28/67] game: fix party frame duplication and player name on entity re-creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_GROUP_LIST is a full replacement packet, not a delta. handleGroupList() was not resetting partyData before parsing, so repeated GROUP_LIST packets pushed duplicate members onto the existing vector — a 2-player party would show the same name 5 times if the packet was sent 5 times. Fix: reset partyData = GroupListData{} before calling GroupListParser::parse(). Also fix player names staying "Unknown" when an entity moves out of range and comes back: queryPlayerName() now applies the cached name to the new entity object immediately instead of skipping when the name is already in cache. This was causing other players' names to appear as unknown after zoning or crossing render distance boundaries. --- src/game/game_handler.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 20da8625..d530de95 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -9855,7 +9855,20 @@ void GameHandler::addLocalChatMessage(const MessageChatData& msg) { // ============================================================ void GameHandler::queryPlayerName(uint64_t guid) { - if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return; + // If already cached, apply the name to the entity (handles entity recreation after + // moving out/in range — the entity object is new but the cached name is valid). + auto cacheIt = playerNameCache.find(guid); + if (cacheIt != playerNameCache.end()) { + auto entity = entityManager.getEntity(guid); + if (entity && entity->getType() == ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) { + player->setName(cacheIt->second); + } + } + return; + } + if (pendingNameQueries.count(guid)) return; if (state != WorldState::IN_WORLD || !socket) { LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec, " state=", worldStateName(state), " socket=", (socket ? "yes" : "no")); @@ -13291,6 +13304,9 @@ void GameHandler::handleGroupList(network::Packet& packet) { // 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"); + // Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta. + // Without this, repeated GROUP_LIST packets push duplicate members. + partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles)) return; if (partyData.isEmpty()) { From ea9c7e68e71318814f7214c6355cd58ef7b97231 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:33:44 -0700 Subject: [PATCH 29/67] rendering,ui: sync selection circle to renderer instance position The selection circle was positioned using the entity's game-logic interpolator (entity->getX/Y/Z), while the actual M2 model is positioned by CharacterRenderer's independent interpolator (moveInstanceTo). These two systems can drift apart during movement, causing the circle to appear under the wrong position relative to the visible model. Fix: add CharacterRenderer::getInstancePosition / Application::getRenderPositionForGuid and use the renderer's inst.position for XY (with footZ override for Z) so the circle always tracks the rendered model exactly. Falls back to the entity game-logic position when no CharacterRenderer instance exists. --- include/core/application.hpp | 1 + include/rendering/character_renderer.hpp | 1 + src/core/application.cpp | 20 ++++++++++++++++++++ src/rendering/character_renderer.cpp | 7 +++++++ src/ui/game_screen.cpp | 24 +++++++++++++++++++----- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 4d10acc7..7587fb7b 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -78,6 +78,7 @@ public: // Render bounds lookup (for click targeting / selection) bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const; bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const; + bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const; // Character skin composite state (saved at spawn for re-compositing on equipment change) const std::string& getBodySkinPath() const { return bodySkinPath_; } diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 7a01c0d7..f516b3a4 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -91,6 +91,7 @@ public: bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const; bool getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const; bool getInstanceFootZ(uint32_t instanceId, float& outFootZ) const; + bool getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const; /** Debug: Log all available animations for an instance */ void dumpAnimations(uint32_t instanceId) const; diff --git a/src/core/application.cpp b/src/core/application.cpp index ca528bb2..9f32c66b 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4879,6 +4879,26 @@ bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const { return renderer->getCharacterRenderer()->getInstanceFootZ(instanceId, outFootZ); } +bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const { + if (!renderer || !renderer->getCharacterRenderer()) return false; + uint32_t instanceId = 0; + + if (gameHandler && guid == gameHandler->getPlayerGuid()) { + instanceId = renderer->getCharacterInstanceId(); + } + if (instanceId == 0) { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId == 0) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) instanceId = it->second; + } + if (instanceId == 0) return false; + + return renderer->getCharacterRenderer()->getInstancePosition(instanceId, outPos); +} + pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) { auto m2Data = assetManager->readFile(m2Path); if (m2Data.empty()) { diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index f69ae75c..59965ec8 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -3175,6 +3175,13 @@ bool CharacterRenderer::getInstanceFootZ(uint32_t instanceId, float& outFootZ) c return true; } +bool CharacterRenderer::getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const { + auto it = instances.find(instanceId); + if (it == instances.end()) return false; + outPos = it->second.position; + return true; +} + void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) { auto charIt = instances.find(charInstanceId); if (charIt == instances.end()) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bded7481..0eef9b83 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -522,11 +522,25 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (gameHandler.hasTarget()) { auto target = gameHandler.getTarget(); if (target) { - targetGLPos = core::coords::canonicalToRender( - glm::vec3(target->getX(), target->getY(), target->getZ())); - float footZ = 0.0f; - if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) { - targetGLPos.z = footZ; + // Prefer the renderer's actual instance position so the selection + // circle tracks the rendered model (not a parallel entity-space + // interpolator that can drift from the visual position). + glm::vec3 instPos; + if (core::Application::getInstance().getRenderPositionForGuid(target->getGuid(), instPos)) { + targetGLPos = instPos; + // Override Z with foot position to sit the circle on the ground. + float footZ = 0.0f; + if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) { + targetGLPos.z = footZ; + } + } else { + // Fallback: entity game-logic position (no CharacterRenderer instance yet) + targetGLPos = core::coords::canonicalToRender( + glm::vec3(target->getX(), target->getY(), target->getZ())); + float footZ = 0.0f; + if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) { + targetGLPos.z = footZ; + } } renderer->setTargetPosition(&targetGLPos); From 1e533698696b746de9b128f356b19436d0363fd0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:40:07 -0700 Subject: [PATCH 30/67] game: fix player phantom model on SMSG_DESTROY_OBJECT handleDestroyObject invoked creatureDespawnCallback_ and gameObjectDespawnCallback_ but not playerDespawnCallback_ for PLAYER entities. This caused the CharacterRenderer instance for nearby players to remain alive after they received a DESTROY_OBJECT packet (e.g. when they teleported or went out of range via server-forced despawn), leaving phantom models in the world. Mirror the same despawn logic used for out-of-range removal: call playerDespawnCallback_ and clean up the per-player bookkeeping maps so the renderer cleans up the character instance correctly. --- src/game/game_handler.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d530de95..78089bd7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8399,6 +8399,14 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { if (entity) { if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { creatureDespawnCallback_(data.guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. + playerDespawnCallback_(data.guid); + otherPlayerVisibleItemEntries_.erase(data.guid); + otherPlayerVisibleDirty_.erase(data.guid); + otherPlayerMoveTimeMs_.erase(data.guid); + inspectedPlayerItemEntries_.erase(data.guid); + pendingAutoInspect_.erase(data.guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(data.guid); } From ac0fe1bd615c9b2c7f29038865bb456212d48807 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:43:16 -0700 Subject: [PATCH 31/67] game: clear target auras when switching targets setTarget() was not clearing targetAuras, leaving stale buff/debuff icons from the previous target visible on the buff bar until the server sent SMSG_AURA_UPDATE_ALL for the new target. Reset all slots to empty on target change so the display is immediately correct. --- 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 78089bd7..75440ff7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8760,6 +8760,10 @@ void GameHandler::setTarget(uint64_t guid) { targetGuid = guid; + // Clear stale aura data from the previous target so the buff bar shows + // an empty state until the server sends SMSG_AURA_UPDATE_ALL for the new target. + for (auto& slot : targetAuras) slot = AuraSlot{}; + // Clear previous target's cast bar on target change // (the new target's cast state is naturally fetched from unitCastStates_ by GUID) From dc2aab5e906adc1cd7cfea4ae2d2dd605f65d10a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 06:47:33 -0700 Subject: [PATCH 32/67] perf: limit NPC composite texture processing to 2ms per frame processAsyncNpcCompositeResults() had no per-frame budget cap, so when many NPCs finished async skin compositing simultaneously (e.g. right after world load), all results were finalized in a single frame causing up to 284ms frame stalls. Apply the same 2ms budget pattern used by processAsyncCreatureResults. Load screen still processes all pending composites without the cap (unlimited=true). --- include/core/application.hpp | 2 +- src/core/application.cpp | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 7587fb7b..d2ef3f36 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -361,7 +361,7 @@ private: std::future future; }; std::vector asyncNpcCompositeLoads_; - void processAsyncNpcCompositeResults(); + void processAsyncNpcCompositeResults(bool unlimited = false); // Cache base player model geometry by (raceId, genderId) std::unordered_map playerModelCache_; // key=(race<<8)|gender → modelId struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; }; diff --git a/src/core/application.cpp b/src/core/application.cpp index 9f32c66b..9ffc1302 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4397,7 +4397,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // During load screen warmup: lift per-frame budgets so GPU uploads // and spawns happen in bulk while the loading screen is still visible. processCreatureSpawnQueue(true); - processAsyncNpcCompositeResults(); + processAsyncNpcCompositeResults(true); // Process equipment queue more aggressively during warmup (multiple per iteration) for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { processDeferredEquipmentQueue(); @@ -7072,11 +7072,21 @@ void Application::processAsyncCreatureResults(bool unlimited) { } } -void Application::processAsyncNpcCompositeResults() { +void Application::processAsyncNpcCompositeResults(bool unlimited) { auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; if (!charRenderer) return; + // Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing + // simultaneously. In unlimited mode (load screen), process everything without cap. + static constexpr float kCompositeBudgetMs = 2.0f; + auto startTime = std::chrono::steady_clock::now(); + for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) { + if (!unlimited) { + float elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsed >= kCompositeBudgetMs) break; + } if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; From 3cdaf78369124f5f61e7c2f8a0e9bda1a7f4fded Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 07:00:43 -0700 Subject: [PATCH 33/67] game,warden,assets: fix unknown player names, warden heap overlap, and Vanilla Item.dbc - game: clear pendingNameQueries on player out-of-range and DESTROY_OBJECT so re-entering players get a fresh name query instead of being silently skipped - game: add 5s periodic name resync scan that re-queries players with empty names and no pending query, recovering from dropped CMSG_NAME_QUERY responses - warden: fix UC_ERR_MAP by moving HEAP_BASE from 0x200000 to 0x20000000; the old heap [0x200000, 0x1200000) overlapped the module at 0x400000, causing Unicorn to reject the heap mapping and abort emulator initialisation - warden: add early overlap check between module and heap regions to catch future layout bugs at init time - assets: add loadDBCOptional() which logs at DEBUG level when a DBC is absent, for files that are not distributed on all expansions - assets: use loadDBCOptional for Item.dbc (absent on Vanilla 1.12 clients) and fall back to server-sent itemInfoCache displayInfoId for NPC weapon resolution --- include/pipeline/asset_manager.hpp | 8 +++++ src/core/application.cpp | 17 +++++++++-- src/game/game_handler.cpp | 28 +++++++++++++++++ src/game/warden_emulator.cpp | 15 ++++++++- src/pipeline/asset_manager.cpp | 49 ++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 3 deletions(-) diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index a32895d6..2ad9e6c0 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -66,6 +66,14 @@ public: */ std::shared_ptr loadDBC(const std::string& name); + /** + * Load a DBC file that is optional (not all expansions ship it). + * Returns nullptr quietly (debug-level log only) when the file is absent. + * @param name DBC file name (e.g., "Item.dbc") + * @return Loaded DBC file, or nullptr if not available + */ + std::shared_ptr loadDBCOptional(const std::string& name); + /** * Get a cached DBC file * @param name DBC file name diff --git a/src/core/application.cpp b/src/core/application.cpp index 9ffc1302..90c02172 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3365,7 +3365,9 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); if (!itemDisplayDbc) return false; - auto itemDbc = assetManager->loadDBC("Item.dbc"); + // Item.dbc is not distributed to clients in Vanilla 1.12; on those expansions + // item display IDs are resolved via the server-sent item cache instead. + auto itemDbc = assetManager->loadDBCOptional("Item.dbc"); const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; const auto* itemL = pipeline::getActiveDBCLayout() @@ -3373,7 +3375,7 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan auto resolveDisplayInfoId = [&](uint32_t rawId) -> uint32_t { if (rawId == 0) return 0; - // AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID. + // Primary path: AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID. // Resolve strictly through Item.dbc entry -> DisplayID to avoid // accidental ItemDisplayInfo ID collisions (staff/hilt mismatches). if (itemDbc) { @@ -3386,6 +3388,17 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan } } } + // Fallback: Vanilla 1.12 does not distribute Item.dbc to clients. + // Items arrive via SMSG_ITEM_QUERY_SINGLE_RESPONSE and are cached in + // itemInfoCache_. Use the server-sent displayInfoId when available. + if (!itemDbc && gameHandler) { + if (const auto* info = gameHandler->getItemInfo(rawId)) { + uint32_t displayIdB = info->displayInfoId; + if (displayIdB != 0 && itemDisplayDbc->findRecordById(displayIdB) >= 0) { + return displayIdB; + } + } + } return 0; }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 75440ff7..c6355e9c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -659,6 +659,30 @@ void GameHandler::update(float deltaTime) { } } + // Periodically re-query names for players whose initial CMSG_NAME_QUERY was + // lost (server didn't respond) or whose entity was recreated while the query + // was still pending. Runs every 5 seconds to keep overhead minimal. + if (state == WorldState::IN_WORLD && socket) { + static float nameResyncTimer = 0.0f; + nameResyncTimer += deltaTime; + if (nameResyncTimer >= 5.0f) { + nameResyncTimer = 0.0f; + for (const auto& [guid, entity] : entityManager.getEntities()) { + if (!entity || entity->getType() != ObjectType::PLAYER) continue; + if (guid == playerGuid) continue; + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) continue; + if (playerNameCache.count(guid)) continue; + if (pendingNameQueries.count(guid)) continue; + // Player entity exists with empty name and no pending query — resend. + LOG_DEBUG("Name resync: re-querying guid=0x", std::hex, guid, std::dec); + pendingNameQueries.insert(guid); + auto pkt = NameQueryPacket::build(guid); + socket->send(pkt); + } + } + } + if (pendingLootMoneyNotifyTimer_ > 0.0f) { pendingLootMoneyNotifyTimer_ -= deltaTime; if (pendingLootMoneyNotifyTimer_ <= 0.0f) { @@ -7436,6 +7460,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { otherPlayerMoveTimeMs_.erase(guid); inspectedPlayerItemEntries_.erase(guid); pendingAutoInspect_.erase(guid); + // Clear pending name query so the query is re-sent when this player + // comes back into range (entity is recreated as a new object). + pendingNameQueries.erase(guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(guid); } @@ -8407,6 +8434,7 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { otherPlayerMoveTimeMs_.erase(data.guid); inspectedPlayerItemEntries_.erase(data.guid); pendingAutoInspect_.erase(data.guid); + pendingNameQueries.erase(data.guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(data.guid); } diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index 1d43768b..570f063b 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -14,9 +14,11 @@ namespace game { #ifdef HAVE_UNICORN // Memory layout for emulated environment +// Note: heap must not overlap the module region (typically loaded at 0x400000) +// or the stack. Keep heap above 0x02000000 (32MB) to leave space for module + padding. constexpr uint32_t STACK_BASE = 0x00100000; // 1MB constexpr uint32_t STACK_SIZE = 0x00100000; // 1MB stack -constexpr uint32_t HEAP_BASE = 0x00200000; // 2MB +constexpr uint32_t HEAP_BASE = 0x02000000; // 32MB — well above typical module base (0x400000) constexpr uint32_t HEAP_SIZE = 0x01000000; // 16MB heap constexpr uint32_t API_STUB_BASE = 0x70000000; // API stub area (high memory) @@ -58,6 +60,17 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 moduleBase_ = baseAddress; moduleSize_ = (moduleSize + 0xFFF) & ~0xFFF; // Align to 4KB + // Detect overlap between module and heap/stack regions early. + uint32_t modEnd = moduleBase_ + moduleSize_; + if (modEnd > heapBase_ && moduleBase_ < heapBase_ + heapSize_) { + std::cerr << "[WardenEmulator] Module [0x" << std::hex << moduleBase_ + << ", 0x" << modEnd << ") overlaps heap [0x" << heapBase_ + << ", 0x" << (heapBase_ + heapSize_) << ") — adjust HEAP_BASE\n" << std::dec; + uc_close(uc_); + uc_ = nullptr; + return false; + } + // Map module memory (code + data) err = uc_mem_map(uc_, moduleBase_, moduleSize_, UC_PROT_ALL); if (err != UC_ERR_OK) { diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 19fa13f8..74b46219 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -296,6 +296,55 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) { return dbc; } +std::shared_ptr AssetManager::loadDBCOptional(const std::string& name) { + // Check cache first + auto it = dbcCache.find(name); + if (it != dbcCache.end()) return it->second; + + // Try binary DBC + std::vector dbcData; + { + std::string dbcPath = "DBFilesClient\\" + name; + dbcData = readFile(dbcPath); + } + + // Fall back to expansion-specific CSV + if (dbcData.empty() && !expansionDataPath_.empty()) { + std::string baseName = name; + auto dot = baseName.rfind('.'); + if (dot != std::string::npos) baseName = baseName.substr(0, dot); + std::string csvPath = expansionDataPath_ + "/db/" + baseName + ".csv"; + if (std::filesystem::exists(csvPath)) { + std::ifstream f(csvPath, std::ios::binary | std::ios::ate); + if (f) { + auto size = f.tellg(); + if (size > 0) { + f.seekg(0); + dbcData.resize(static_cast(size)); + f.read(reinterpret_cast(dbcData.data()), size); + LOG_INFO("Binary DBC not found, using CSV fallback: ", csvPath); + } + } + } + } + + if (dbcData.empty()) { + // Expected on some expansions — log at debug level only. + LOG_DEBUG("Optional DBC not found (expected on some expansions): ", name); + return nullptr; + } + + auto dbc = std::make_shared(); + if (!dbc->load(dbcData)) { + LOG_ERROR("Failed to load DBC: ", name); + return nullptr; + } + + dbcCache[name] = dbc; + LOG_INFO("Loaded optional DBC: ", name, " (", dbc->getRecordCount(), " records)"); + return dbc; +} + std::shared_ptr AssetManager::getDBC(const std::string& name) const { auto it = dbcCache.find(name); if (it != dbcCache.end()) { From 0ea8e55ad44c9fcd2ee50f5de3ae583f64df499d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 07:25:04 -0700 Subject: [PATCH 34/67] ui,game,pipeline: player nameplates always-on, level-up ring effect, vanilla tile fallback, warden null guard - Nameplates: player names always rendered regardless of V-key toggle; separate cull distance 40u (players/target) vs 20u (NPCs); cyan name color for other players; fade alpha scales with cull distance - Level-up: add expanding golden ring burst (3 staggered waves, 420u max radius) + full-screen flash to renderDingEffect(); M2 LevelUp.m2 is still attempted as a bonus on top - Vanilla tile loading: add AssetManager::setBaseFallbackPath() so that when the primary manifest is an expansion-specific DBC-only subset (e.g. Data/expansions/vanilla/), world terrain files fall back to the base Data/ extraction; wired in Application::initialize() - Warden: map a null guard page at address 0x0 in the Unicorn emulator so NULL-pointer reads in the module don't crash with UC_ERR_MAP; execution continues past the NULL read for better diagnostics --- include/pipeline/asset_manager.hpp | 14 ++++++++ src/core/application.cpp | 5 +++ src/game/warden_emulator.cpp | 9 +++++ src/pipeline/asset_manager.cpp | 27 ++++++++++++-- src/ui/game_screen.cpp | 56 +++++++++++++++++++++++++----- 5 files changed, 100 insertions(+), 11 deletions(-) diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index 2ad9e6c0..869b87a3 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -59,6 +59,15 @@ public: */ void setExpansionDataPath(const std::string& path); + /** + * Set a base data path to fall back to when the primary manifest + * does not contain a requested file. Call this when the primary + * dataPath is an expansion-specific subset (e.g. Data/expansions/vanilla/) + * that only holds DBC overrides, not the full world asset set. + * @param basePath Path to the base extraction (Data/) that has a manifest.json + */ + void setBaseFallbackPath(const std::string& basePath); + /** * Load a DBC file * @param name DBC file name (e.g., "Map.dbc") @@ -144,6 +153,11 @@ private: AssetManifest manifest_; LooseFileReader looseReader_; + // Optional base-path fallback: used when manifest_ doesn't contain a file. + // Populated by setBaseFallbackPath(); ignored if baseFallbackDataPath_ is empty. + std::string baseFallbackDataPath_; + AssetManifest baseFallbackManifest_; + /** * Resolve filesystem path: check override dir first, then base manifest. * Returns empty string if not found. diff --git a/src/core/application.cpp b/src/core/application.cpp index 90c02172..45ac82b9 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -292,6 +292,11 @@ bool Application::initialize() { if (std::filesystem::exists(expansionManifest)) { assetPath = profile->dataPath; LOG_INFO("Using expansion-specific asset path: ", assetPath); + // Register base Data/ as fallback so world terrain files are found + // even when the expansion path only contains DBC overrides. + if (assetPath != dataPath) { + assetManager->setBaseFallbackPath(dataPath); + } } } } diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index 570f063b..5fadc408 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -121,6 +121,15 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 return false; } + // Map a null guard page at address 0 (read-only, zeroed) so that NULL-pointer + // dereferences in the module don't crash the emulator with UC_ERR_MAP. + // This allows execution to continue past NULL reads, making diagnostics easier. + err = uc_mem_map(uc_, 0x0, 0x1000, UC_PROT_READ); + if (err != UC_ERR_OK) { + // Non-fatal — just log it; the emulator will still function + std::cerr << "[WardenEmulator] Note: could not map null guard page: " << uc_strerror(err) << '\n'; + } + // Add hooks for debugging and invalid memory access uc_hook hh; uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0); diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 74b46219..89b063c5 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -137,8 +137,31 @@ std::string AssetManager::resolveFile(const std::string& normalizedPath) const { } } } - // Fall back to base manifest - return manifest_.resolveFilesystemPath(normalizedPath); + // Primary manifest + std::string primaryPath = manifest_.resolveFilesystemPath(normalizedPath); + if (!primaryPath.empty()) return primaryPath; + + // If a base-path fallback is configured (expansion-specific primary that only + // holds DBC overrides), retry against the base extraction. + if (!baseFallbackDataPath_.empty()) { + return baseFallbackManifest_.resolveFilesystemPath(normalizedPath); + } + return {}; +} + +void AssetManager::setBaseFallbackPath(const std::string& basePath) { + if (basePath.empty() || basePath == dataPath) return; // nothing to do + std::string manifestPath = basePath + "/manifest.json"; + if (!std::filesystem::exists(manifestPath)) { + LOG_DEBUG("AssetManager: base fallback manifest not found at ", manifestPath, + " — fallback disabled"); + return; + } + if (baseFallbackManifest_.load(manifestPath)) { + baseFallbackDataPath_ = basePath; + LOG_INFO("AssetManager: base fallback path set to '", basePath, + "' (", baseFallbackManifest_.getEntryCount(), " files)"); + } } BLPImage AssetManager::loadTexture(const std::string& path) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0eef9b83..f5a1bc1d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -400,7 +400,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderCastBar(gameHandler); renderMirrorTimers(gameHandler); renderQuestObjectiveTracker(gameHandler); - if (showNameplates_) renderNameplates(gameHandler); + renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); @@ -4848,16 +4848,20 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { auto* unit = dynamic_cast(entityPtr.get()); if (!unit || unit->getMaxHealth() == 0) continue; + bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); bool isTarget = (guid == targetGuid); + // Player nameplates are always shown; NPC nameplates respect the V-key toggle + if (!isPlayer && !showNameplates_) continue; + // Convert canonical WoW position → render space, raise to head height glm::vec3 renderPos = core::coords::canonicalToRender( glm::vec3(unit->getX(), unit->getY(), unit->getZ())); renderPos.z += 2.3f; - // Cull distance: target up to 40 units; others up to 20 units + // Cull distance: target or other players up to 40 units; NPC others up to 20 units float dist = glm::length(renderPos - camPos); - float cullDist = isTarget ? 40.0f : 20.0f; + float cullDist = (isTarget || isPlayer) ? 40.0f : 20.0f; if (dist > cullDist) continue; // Project to clip space @@ -4874,8 +4878,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float sx = (ndc.x * 0.5f + 0.5f) * screenW; float sy = (ndc.y * 0.5f + 0.5f) * screenH; - // Fade out in the last 5 units of range - float alpha = dist < 35.0f ? 1.0f : 1.0f - (dist - 35.0f) / 5.0f; + // Fade out in the last 5 units of cull range + float alpha = dist < (cullDist - 5.0f) ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f; auto A = [&](int v) { return static_cast(v * alpha); }; // Bar colour by hostility @@ -4920,10 +4924,12 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; float nameY = sy - barH - 12.0f; - // Name color: hostile=red, non-hostile=yellow (WoW convention) - ImU32 nameColor = unit->isHostile() - ? IM_COL32(220, 80, 80, A(230)) - : IM_COL32(240, 200, 100, A(230)); + // Name color: other player=cyan, hostile=red, non-hostile=yellow (WoW convention) + ImU32 nameColor = isPlayer + ? IM_COL32( 80, 200, 255, A(230)) // cyan — other players + : unit->isHostile() + ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC + : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); @@ -10242,6 +10248,7 @@ void GameScreen::renderDingEffect() { if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; float alpha = dingTimer_ < 0.8f ? (dingTimer_ / 0.8f) : 1.0f; // fade out last 0.8s + float elapsed = DING_DURATION - dingTimer_; // 0 → DING_DURATION ImGuiIO& io = ImGui::GetIO(); float cx = io.DisplaySize.x * 0.5f; @@ -10249,6 +10256,37 @@ void GameScreen::renderDingEffect() { ImDrawList* draw = ImGui::GetForegroundDrawList(); + // ---- Golden radial ring burst (3 waves staggered by 0.45s) ---- + { + constexpr float kMaxRadius = 420.0f; + constexpr float kRingWidth = 18.0f; + constexpr float kWaveLen = 1.4f; // each wave lasts 1.4s + constexpr int kNumWaves = 3; + constexpr float kStagger = 0.45f; // seconds between waves + + for (int w = 0; w < kNumWaves; ++w) { + float waveElapsed = elapsed - w * kStagger; + if (waveElapsed <= 0.0f || waveElapsed >= kWaveLen) continue; + + float t = waveElapsed / kWaveLen; // 0 → 1 + float radius = t * kMaxRadius; + float ringAlpha = (1.0f - t) * alpha; // fades as it expands + + ImU32 outerCol = IM_COL32(255, 215, 60, (int)(ringAlpha * 200)); + ImU32 innerCol = IM_COL32(255, 255, 150, (int)(ringAlpha * 120)); + + draw->AddCircle(ImVec2(cx, cy), radius, outerCol, 64, kRingWidth); + draw->AddCircle(ImVec2(cx, cy), radius * 0.92f, innerCol, 64, kRingWidth * 0.5f); + } + } + + // ---- Full-screen golden flash on first frame ---- + if (elapsed < 0.15f) { + float flashA = (1.0f - elapsed / 0.15f) * 0.45f; + draw->AddRectFilled(ImVec2(0, 0), io.DisplaySize, + IM_COL32(255, 200, 50, (int)(flashA * 255))); + } + // "LEVEL X!" text — visible for first 2.2s if (dingTimer_ > 0.8f) { ImFont* font = ImGui::GetFont(); From 2a9d26e1eacaf081625a985bc3de61953685588f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 07:35:30 -0700 Subject: [PATCH 35/67] game,ui: add rest state tracking and rested XP bar overlay - Track PLAYER_REST_STATE_EXPERIENCE update field for all expansions (WotLK=636, Classic=718, TBC=928, Turtle=718) - Set isResting_ flag from SMSG_SET_REST_START packet - XP bar shows rested bonus as a lighter purple overlay extending beyond the current fill to (currentXp + restedXp) position - Tooltip text changes to "%u / %u XP (+%u rested)" when bonus exists - "zzz" indicator shown at bar right edge while resting --- Data/expansions/classic/update_fields.json | 1 + Data/expansions/tbc/update_fields.json | 1 + Data/expansions/turtle/update_fields.json | 3 +- Data/expansions/wotlk/update_fields.json | 1 + include/game/game_handler.hpp | 4 +++ include/game/update_field_table.hpp | 1 + src/game/game_handler.cpp | 7 ++-- src/ui/game_screen.cpp | 39 +++++++++++++++++++--- 8 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 5f97f29f..4549a48c 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, + "PLAYER_REST_STATE_EXPERIENCE": 718, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index bbcedec5..bee972ca 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 238, "PLAYER_XP": 926, "PLAYER_NEXT_LEVEL_XP": 927, + "PLAYER_REST_STATE_EXPERIENCE": 928, "PLAYER_FIELD_COINAGE": 1441, "PLAYER_QUEST_LOG_START": 244, "PLAYER_FIELD_INV_SLOT_HEAD": 650, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 5f97f29f..393694a0 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, + "PLAYER_REST_STATE_EXPERIENCE": 718, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, @@ -35,4 +36,4 @@ "ITEM_FIELD_STACK_COUNT": 14, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 -} +} \ No newline at end of file diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index f308cf0d..fa4b9ada 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 154, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, + "PLAYER_REST_STATE_EXPERIENCE": 636, "PLAYER_FIELD_COINAGE": 1170, "PLAYER_QUEST_LOG_START": 158, "PLAYER_FIELD_INV_SLOT_HEAD": 324, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 42481376..dc6c06e6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -640,6 +640,8 @@ public: // XP tracking uint32_t getPlayerXp() const { return playerXp_; } uint32_t getPlayerNextLevelXp() const { return playerNextLevelXp_; } + uint32_t getPlayerRestedXp() const { return playerRestedXp_; } + bool isPlayerResting() const { return isResting_; } uint32_t getPlayerLevel() const { return serverPlayerLevel_; } const std::vector& getPlayerExploredZoneMasks() const { return playerExploredZones_; } bool hasPlayerExploredZoneMasks() const { return hasPlayerExploredZones_; } @@ -2199,6 +2201,8 @@ private: // ---- XP tracking ---- uint32_t playerXp_ = 0; uint32_t playerNextLevelXp_ = 0; + uint32_t playerRestedXp_ = 0; + bool isResting_ = false; uint32_t serverPlayerLevel_ = 1; static uint32_t xpForLevel(uint32_t level); diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index b841925e..fd208554 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -41,6 +41,7 @@ enum class UF : uint16_t { PLAYER_BYTES_2, PLAYER_XP, PLAYER_NEXT_LEVEL_XP, + PLAYER_REST_STATE_EXPERIENCE, PLAYER_FIELD_COINAGE, PLAYER_QUEST_LOG_START, PLAYER_FIELD_INV_SLOT_HEAD, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c6355e9c..877afa64 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4644,8 +4644,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SET_REST_START: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t restTrigger = packet.readUInt32(); - addSystemChatMessage(restTrigger > 0 ? "You are now resting." - : "You are no longer resting."); + isResting_ = (restTrigger > 0); + addSystemChatMessage(isResting_ ? "You are now resting." + : "You are no longer resting."); } break; } @@ -7835,6 +7836,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { bool slotsChanged = false; const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); @@ -7842,6 +7844,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } + else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } else if (key == ufPlayerLevel) { serverPlayerLevel_ = val; for (auto& ch : characters) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f5a1bc1d..dffffbdc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4409,7 +4409,9 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) - uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t restedXp = gameHandler.getPlayerRestedXp(); + bool isResting = gameHandler.isPlayerResting(); auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; @@ -4449,9 +4451,10 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); auto* drawList = ImGui::GetWindowDrawList(); - ImU32 bg = IM_COL32(15, 15, 20, 220); - ImU32 fg = IM_COL32(148, 51, 238, 255); - ImU32 seg = IM_COL32(35, 35, 45, 255); + ImU32 bg = IM_COL32(15, 15, 20, 220); + ImU32 fg = IM_COL32(148, 51, 238, 255); + ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion + ImU32 seg = IM_COL32(35, 35, 45, 255); drawList->AddRectFilled(barMin, barMax, bg, 2.0f); drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); @@ -4460,6 +4463,19 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f); } + // Rested XP overlay: draw from current XP fill to (currentXp + restedXp) fill + if (restedXp > 0) { + float restedEndPct = std::min(1.0f, static_cast(currentXp + restedXp) + / static_cast(nextLevelXp)); + float restedStartX = barMin.x + fillW; + float restedEndX = barMin.x + barSize.x * restedEndPct; + if (restedEndX > restedStartX) { + drawList->AddRectFilled(ImVec2(restedStartX, barMin.y), + ImVec2(restedEndX, barMax.y), + fgRest, 2.0f); + } + } + const int segments = 20; float segW = barSize.x / static_cast(segments); for (int i = 1; i < segments; ++i) { @@ -4467,8 +4483,21 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f); } + // Rest indicator "zzz" to the right of the bar when resting + if (isResting) { + const char* zzz = "zzz"; + ImVec2 zSize = ImGui::CalcTextSize(zzz); + float zx = barMax.x - zSize.x - 4.0f; + float zy = barMin.y + (barSize.y - zSize.y) * 0.5f; + drawList->AddText(ImVec2(zx, zy), IM_COL32(180, 150, 255, 220), zzz); + } + char overlay[96]; - snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + if (restedXp > 0) { + snprintf(overlay, sizeof(overlay), "%u / %u XP (+%u rested)", currentXp, nextLevelXp, restedXp); + } else { + snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + } ImVec2 textSize = ImGui::CalcTextSize(overlay); float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; From 03c4d595926cdfea11b02c75512044f9f32905c0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 07:41:27 -0700 Subject: [PATCH 36/67] game: fix talent state not resetting across login sessions Replace static-local firstSpecReceived with talentsInitialized_ member variable, reset in handleLoginVerifyWorld alongside other per-session state. Also clear learnedTalents_, unspentTalentPoints_, and activeTalentSpec_ at world entry so reconnects and character switches start from a clean talent state instead of carrying over stale data. --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index dc6c06e6..269f018f 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1841,6 +1841,7 @@ private: std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; + bool talentsInitialized_ = false; // Reset on world entry; guards first-spec selection // ---- Area trigger detection ---- struct AreaTriggerEntry { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 877afa64..21bc04c0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6039,6 +6039,15 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { encounterUnitGuids_.fill(0); raidTargetGuids_.fill(0); + // Reset talent initialization so the first SMSG_TALENTS_INFO after login + // correctly sets the active spec (static locals don't reset across logins) + talentsInitialized_ = false; + learnedTalents_[0].clear(); + learnedTalents_[1].clear(); + unspentTalentPoints_[0] = 0; + unspentTalentPoints_[1] = 0; + activeTalentSpec_ = 0; + // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. activeAreaTriggers_.clear(); @@ -13220,10 +13229,9 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) { " unspent=", (int)unspentTalentPoints_[data.talentSpec], " learned=", learnedTalents_[data.talentSpec].size()); - // If this is the first spec received, set it as active - static bool firstSpecReceived = false; - if (!firstSpecReceived) { - firstSpecReceived = true; + // If this is the first spec received after login, set it as the active spec + if (!talentsInitialized_) { + talentsInitialized_ = true; activeTalentSpec_ = data.talentSpec; // Show message to player about active spec From 846ba58d2e45f71a28edb1f568c439671bcf9c17 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 07:45:53 -0700 Subject: [PATCH 37/67] ui,game: show creature names in quest kill count tracker and progress messages Quest kill count tracker in the HUD now resolves creature names from the cached creature query results and displays them as "Name: x/y" instead of bare "x/y". The system chat progress message on kill also includes the creature name when available, matching retail client behavior. --- src/game/game_handler.cpp | 9 ++++++--- src/ui/game_screen.cpp | 11 +++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 21bc04c0..23231ae7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3994,9 +3994,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } quest.killCounts[entry] = {count, reqCount}; - std::string progressMsg = quest.title + ": " + - std::to_string(count) + "/" + - std::to_string(reqCount); + std::string creatureName = getCachedCreatureName(entry); + std::string progressMsg = quest.title + ": "; + if (!creatureName.empty()) { + progressMsg += creatureName + " "; + } + progressMsg += std::to_string(count) + "/" + std::to_string(reqCount); addSystemChatMessage(progressMsg); LOG_INFO("Updated kill count for quest ", questId, ": ", diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dffffbdc..ce441435 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4687,8 +4687,15 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } else { // Kill counts for (const auto& [entry, progress] : q.killCounts) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %u/%u", progress.first, progress.second); + std::string creatureName = gameHandler.getCachedCreatureName(entry); + if (!creatureName.empty()) { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %s: %u/%u", creatureName.c_str(), + progress.first, progress.second); + } else { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %u/%u", progress.first, progress.second); + } } // Item counts for (const auto& [itemId, count] : q.itemCounts) { From 0a157d3255145f5aba97b95203308ddeee46e659 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 08:06:21 -0700 Subject: [PATCH 38/67] game: add area name cache from WorldMapArea.dbc for /who zone display and exploration messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load WorldMapArea.dbc lazily on first use to build areaId→name lookup - /who results now show [Zone Name] alongside level: 'Name - Level 70 [Stormwind City]' - SMSG_EXPLORATION_EXPERIENCE now shows 'Discovered Elwynn Forest! Gained X experience.' instead of generic 'Discovered new area!' message when the zone name is available - Cache is populated once per session and shared across both callsites --- include/game/game_handler.hpp | 6 ++++ src/game/game_handler.cpp | 68 +++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 269f018f..68f2d878 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2147,6 +2147,12 @@ private: std::unordered_map achievementNameCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); + + // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) + std::unordered_map areaNameCache_; + bool areaNameCacheLoaded_ = false; + void loadAreaNameCache(); + std::string getAreaName(uint32_t areaId) const; 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 23231ae7..447c8a71 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1605,13 +1605,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_EXPLORATION_EXPERIENCE: { // uint32 areaId + uint32 xpGained if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t areaId =*/ packet.readUInt32(); + uint32_t areaId = packet.readUInt32(); uint32_t xpGained = packet.readUInt32(); if (xpGained > 0) { - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Discovered new area! Gained %u experience.", xpGained); - addSystemChatMessage(buf); + std::string areaName = getAreaName(areaId); + std::string msg; + if (!areaName.empty()) { + msg = "Discovered " + areaName + "! Gained " + + std::to_string(xpGained) + " experience."; + } else { + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Discovered new area! Gained %u experience.", xpGained); + msg = buf; + } + addSystemChatMessage(msg); // XP is updated via PLAYER_XP update fields from the server. } } @@ -16494,16 +16502,23 @@ void GameHandler::handleWho(network::Packet& packet) { uint32_t raceId = packet.readUInt32(); if (hasGender && packet.getSize() - packet.getReadPos() >= 1) packet.readUInt8(); // gender (WotLK only, unused) + uint32_t zoneId = 0; if (packet.getSize() - packet.getReadPos() >= 4) - packet.readUInt32(); // zoneId (unused) + zoneId = packet.readUInt32(); std::string msg = " " + playerName; if (!guildName.empty()) msg += " <" + guildName + ">"; msg += " - Level " + std::to_string(level); + if (zoneId != 0) { + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + msg += " [" + zoneName + "]"; + } addSystemChatMessage(msg); - LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId); + LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, + " Race:", raceId, " Zone:", zoneId); } } @@ -18272,6 +18287,45 @@ const std::string& GameHandler::getFactionNamePublic(uint32_t factionId) const { return empty; } +// --------------------------------------------------------------------------- +// Area name cache (lazy-loaded from WorldMapArea.dbc) +// --------------------------------------------------------------------------- + +void GameHandler::loadAreaNameCache() { + if (areaNameCacheLoaded_) return; + areaNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("WorldMapArea.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("WorldMapArea") : nullptr; + const uint32_t areaIdField = layout ? (*layout)["AreaID"] : 2; + const uint32_t areaNameField = layout ? (*layout)["AreaName"] : 3; + + if (dbc->getFieldCount() <= areaNameField) return; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t areaId = dbc->getUInt32(i, areaIdField); + if (areaId == 0) continue; + std::string name = dbc->getString(i, areaNameField); + if (!name.empty() && !areaNameCache_.count(areaId)) { + areaNameCache_[areaId] = std::move(name); + } + } + LOG_INFO("WorldMapArea.dbc: loaded ", areaNameCache_.size(), " area names"); +} + +std::string GameHandler::getAreaName(uint32_t areaId) const { + if (areaId == 0) return {}; + const_cast(this)->loadAreaNameCache(); + auto it = areaNameCache_.find(areaId); + return (it != areaNameCache_.end()) ? it->second : std::string{}; +} + // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- From baab997da8726639da6748e0a66d79dfe6c1b13c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 08:28:48 -0700 Subject: [PATCH 39/67] ui: fix XP bar overlapping second action bar by positioning above both bars --- src/ui/game_screen.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ce441435..1a7ac0e1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4416,18 +4416,18 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - // Position just above the action bar + // Position just above both action bars (bar1 at screenH-barH, bar2 above that) float slotSize = 48.0f; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; float barH = slotSize + 24.0f; - float actionBarY = screenH - barH; float xpBarH = 20.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; - float xpBarY = actionBarY - xpBarH - 2.0f; + // bar1 is at screenH-barH, bar2 is at screenH-2*barH-2; XP bar sits above bar2 + float xpBarY = screenH - 2.0f * barH - 2.0f - xpBarH - 2.0f; ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); From 54246345bb8768d6cd21ffd3a00188bd526447a7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 08:35:36 -0700 Subject: [PATCH 40/67] game: fix NPCs not spawning on reconnect to same map On disconnect/reconnect to the same map, entityManager was not cleared and creatureInstances_ still held old entries from the previous session. When the server re-sent CREATE_OBJECT for the same GUIDs, the spawn callback's early-return guard (creatureInstances_.count(guid)) silently dropped every NPC re-spawn, leaving the world empty. Fixes: - disconnect() now calls entityManager.clear() to purge stale entities - WorldEntryCallback gains a bool isInitialEntry parameter (true on first login or reconnect, false on in-world teleport/flight landing) - Same-map optimization path skipped when isInitialEntry=true, so loadOnlineWorldTerrain runs its full cleanup and properly despawns old creature/player instances before the server refreshes them --- include/game/game_handler.hpp | 4 ++-- src/core/application.cpp | 9 ++++++--- src/game/game_handler.cpp | 8 +++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 68f2d878..4633249c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -666,8 +666,8 @@ public: uint32_t getSkillCategory(uint32_t skillId) const; // World entry callback (online mode - triggered when entering world) - // Parameters: mapId, x, y, z (canonical WoW coordinates) - using WorldEntryCallback = std::function; + // Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect + using WorldEntryCallback = std::function; void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } // Unstuck callback (resets player Z to floor height) diff --git a/src/core/application.cpp b/src/core/application.cpp index 45ac82b9..5fe635f6 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1692,13 +1692,16 @@ void Application::setupUICallbacks() { }); // World entry callback (online mode) - load terrain when entering world - gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z) { - LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) { + LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" + " initial=", isInitialEntry); // Same-map teleport (taxi landing, GM teleport on same continent): // just update position, let terrain streamer handle tile loading incrementally. // A full reload is only needed on first entry or map change. - if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager()) { + // Exception: on reconnect to the same map (isInitialEntry=true), all online entities + // are stale and must be cleared so the server's fresh CREATE_OBJECTs re-spawn them. + if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && !isInitialEntry) { LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload"); glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 447c8a71..e0838a3c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -499,6 +499,8 @@ void GameHandler::disconnect() { wardenModuleSize_ = 0; wardenModuleData_.clear(); wardenLoadedModule_.reset(); + // Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects. + entityManager.clear(); setState(WorldState::DISCONNECTED); LOG_INFO("Disconnected from world server"); } @@ -6074,7 +6076,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { // Notify application to load terrain for this map/position (online mode) if (worldEntryCallback_) { - worldEntryCallback_(data.mapId, data.x, data.y, data.z); + worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); } // Auto-join default chat channels @@ -15582,7 +15584,7 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { // Notify application of teleport — the callback decides whether to do // a full world reload (map change) or just update position (same map). if (worldEntryCallback_) { - worldEntryCallback_(currentMapId_, serverX, serverY, serverZ); + worldEntryCallback_(currentMapId_, serverX, serverY, serverZ, false); } } @@ -15689,7 +15691,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { // Reload terrain at new position if (worldEntryCallback_) { - worldEntryCallback_(mapId, serverX, serverY, serverZ); + worldEntryCallback_(mapId, serverX, serverY, serverZ, false); } } From d22f4b30ac05f7c64d22dfca373b0d113f3399e3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 08:38:39 -0700 Subject: [PATCH 41/67] game: process partial UPDATE_OBJECT packets when a block parse fails Previously, if any single block in an SMSG_UPDATE_OBJECT packet failed to parse (e.g. unusual spline flags), the entire packet was dropped and all entities in it were lost. On busy zones with many CREATE_OBJECTs in one packet, one bad NPC movement block would silently suppress all NPCs that followed it in the same packet. - parseUpdateObject: break instead of return false on block failure, so already-parsed blocks are returned to the caller - handleUpdateObject: fall through to process partial data when parsing returns false but some blocks were successfully parsed --- src/game/game_handler.cpp | 3 ++- src/game/world_packets.cpp | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e0838a3c..76b4eb04 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7327,7 +7327,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { static int updateObjErrors = 0; if (++updateObjErrors <= 5) LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); - return; + if (data.blocks.empty()) return; + // Fall through: process any blocks that were successfully parsed before the failure. } auto extractPlayerAppearance = [&](const std::map& fields, diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 69427728..7587cd65 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1252,11 +1252,14 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) if (!parseUpdateBlock(packet, block)) { static int parseBlockErrors = 0; if (++parseBlockErrors <= 5) { - LOG_ERROR("Failed to parse update block ", i + 1); + LOG_ERROR("Failed to parse update block ", i + 1, " of ", data.blockCount, + " (", i, " blocks parsed successfully before failure)"); if (parseBlockErrors == 5) LOG_ERROR("(suppressing further update block parse errors)"); } - return false; + // Cannot reliably re-sync to the next block after a parse failure, + // but still return true so the blocks already parsed are processed. + break; } data.blocks.emplace_back(std::move(block)); From a06ac018eaefc00f7d1c5749600021ab03c1c86f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 08:50:25 -0700 Subject: [PATCH 42/67] game: use targeted entity cleanup on reconnect to same map, preserving terrain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous reconnect fix caused loadOnlineWorldTerrain to run, which cleared and reloaded all terrain tiles — unnecessarily heavy for a reconnect where the map hasn't changed. New path: when isInitialEntry=true and mapId==loadedMapId_, despawn all tracked creature/player/GO instances from the renderer (proper cleanup), clear all pending spawn queues, update player position, and return — the terrain stays loaded and the server's fresh CREATE_OBJECTs repopulate entities normally. --- src/core/application.cpp | 65 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 5fe635f6..acbf1971 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1696,12 +1696,71 @@ void Application::setupUICallbacks() { LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" " initial=", isInitialEntry); + // Reconnect to the same map: terrain stays loaded but all online entities are stale. + // Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world. + if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) { + LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); + + // Pending spawn queues + pendingCreatureSpawns_.clear(); + pendingCreatureSpawnGuids_.clear(); + creatureSpawnRetryCounts_.clear(); + pendingPlayerSpawns_.clear(); + pendingPlayerSpawnGuids_.clear(); + pendingOnlinePlayerEquipment_.clear(); + deferredEquipmentQueue_.clear(); + pendingGameObjectSpawns_.clear(); + + // Properly despawn all tracked instances from the renderer + { + std::vector guids; + guids.reserve(creatureInstances_.size()); + for (const auto& [g, _] : creatureInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlineCreature(g); + } + { + std::vector guids; + guids.reserve(playerInstances_.size()); + for (const auto& [g, _] : playerInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlinePlayer(g); + } + { + std::vector guids; + guids.reserve(gameObjectInstances_.size()); + for (const auto& [g, _] : gameObjectInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlineGameObject(g); + } + + // Update player position and re-queue nearby tiles (same logic as teleport) + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer->getCharacterPosition() = renderPos; + if (renderer->getCameraController()) { + auto* ft = renderer->getCameraController()->getFollowTargetMutable(); + if (ft) *ft = renderPos; + renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(1.0f); + } + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + renderer->getTerrainManager()->processAllReadyTiles(); + { + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + std::vector> nearbyTiles; + nearbyTiles.reserve(289); + for (int dy = -8; dy <= 8; dy++) + for (int dx = -8; dx <= 8; dx++) + nearbyTiles.push_back({tileX + dx, tileY + dy}); + renderer->getTerrainManager()->precacheTiles(nearbyTiles); + } + return; + } + // Same-map teleport (taxi landing, GM teleport on same continent): // just update position, let terrain streamer handle tile loading incrementally. // A full reload is only needed on first entry or map change. - // Exception: on reconnect to the same map (isInitialEntry=true), all online entities - // are stale and must be cleared so the server's fresh CREATE_OBJECTs re-spawn them. - if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && !isInitialEntry) { + if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager()) { LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload"); glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); From 4f51103cdba0ad29d55ac9b9d957d361360baa89 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 08:55:23 -0700 Subject: [PATCH 43/67] game: clear permanent-failure and dead-creature caches on same-map reconnect After reconnect, `creaturePermanentFailureGuids_` and `deadCreatureGuids_` could retain stale entries for GUIDs not tracked in `creatureInstances_` (creatures that failed to load or died before being spawned). These stale entries would silently block re-spawning or cause wrong death state on the fresh CREATE_OBJECTs the server sends after reconnect. Clear both caches in the reconnect-to-same-map path so server state is authoritative after every reconnect. --- src/core/application.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index acbf1971..11a5063f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1701,10 +1701,12 @@ void Application::setupUICallbacks() { if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) { LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); - // Pending spawn queues + // Pending spawn queues and failure caches pendingCreatureSpawns_.clear(); pendingCreatureSpawnGuids_.clear(); creatureSpawnRetryCounts_.clear(); + creaturePermanentFailureGuids_.clear(); // Clear so previously-failed GUIDs can retry + deadCreatureGuids_.clear(); // Will be re-populated from fresh server state pendingPlayerSpawns_.clear(); pendingPlayerSpawnGuids_.clear(); pendingOnlinePlayerEquipment_.clear(); From 6763cfcda015fb37e475207fa64e32bc476fed5a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:00:17 -0700 Subject: [PATCH 44/67] =?UTF-8?q?game:=20fix=20log=20level=20for=20GameObj?= =?UTF-8?q?ect=20CREATE=20messages=20(WARNING=20=E2=86=92=20DEBUG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every GameObject CREATE block was logged at WARNING level, spamming the warning log with doors, chests, and other world objects. Demote to DEBUG since this is routine spawn traffic; keep transport detection at INFO since those are noteworthy. --- 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 76b4eb04..38841221 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7797,13 +7797,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { queryGameObjectInfo(itEntry->second, block.guid); } // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) - LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, " entry=", go->getEntry(), " displayId=", go->getDisplayId(), " updateFlags=0x", std::hex, block.updateFlags, std::dec, " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); if (block.updateFlags & 0x0002) { transportGuids_.insert(block.guid); - LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, " entry=", go->getEntry(), " displayId=", go->getDisplayId(), " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); From c622e547c9b3bd8c38bc9ba354c22a937b180a34 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:01:34 -0700 Subject: [PATCH 45/67] game: clear in-flight NPC/GO query sets on disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pendingCreatureQueries and pendingGameObjectQueries_ were never cleared on disconnect. If a query was sent but the response lost (e.g. server disconnect mid-flight), the entry would remain in the pending set after reconnect, causing queryCreatureInfo/queryGameObjectInfo to silently skip re-issuing the query — leaving NPC and GO names unpopulated for those entries. Clear both sets on disconnect so reconnect sees them as unqueried and re-sends the queries as needed. creatureInfoCache/gameObjectInfoCache_ are intentionally preserved across sessions to avoid re-querying entries whose responses did arrive. --- 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 38841221..76815c3a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -486,6 +486,10 @@ void GameHandler::disconnect() { contacts_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); + // Clear in-flight query sets so reconnect can re-issue queries for any + // entries whose responses were lost during the disconnect. + pendingCreatureQueries.clear(); + pendingGameObjectQueries_.clear(); requiresWarden_ = false; wardenGateSeen_ = false; wardenGateElapsed_ = 0.0f; From b2dccca58ce6e3a1ada11eb25cd8a464f8c3cdbd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:13:31 -0700 Subject: [PATCH 46/67] rendering: re-enable WMO camera collision with asymmetric smoothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously disabled because the per-frame raycast caused erratic zoom snapping at doorway transitions. Re-enable using an asymmetrically- smoothed collision limit: pull-in reacts quickly (τ≈60 ms) to prevent the camera from ever visibly clipping through walls, while recovery is slow (τ≈400 ms) so walking through a doorway zooms back out gradually instead of snapping. Uses wmoRenderer->raycastBoundingBoxes() which already has strict wall filters (|normal.z|<0.20, surface-alignment check, ±0.9 height band) to ignore floors, ramps, and arch geometry. --- include/rendering/camera_controller.hpp | 1 + src/rendering/camera_controller.cpp | 32 +++++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 34600b47..1ccf7f63 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -156,6 +156,7 @@ private: static constexpr float MAX_PITCH = 35.0f; // Limited upward look glm::vec3* followTarget = nullptr; glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement + float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised) // Gravity / grounding float verticalVelocity = 0.0f; diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 891d53ba..4e0d6ff2 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1316,12 +1316,36 @@ void CameraController::update(float deltaTime) { } } - // ===== Camera collision (sphere sweep approximation) ===== - // Find max safe distance using raycast + sphere radius + // ===== Camera collision (WMO raycast) ===== + // Cast a ray from the pivot toward the camera direction to find the + // nearest WMO wall. Uses asymmetric smoothing: pull-in is fast (so + // the camera never visibly clips through a wall) but recovery is slow + // (so passing through a doorway doesn't cause a zoom-out snap). collisionDistance = currentDistance; - // WMO/M2 camera collision disabled — was pulling camera through - // geometry at doorway transitions and causing erratic zoom behaviour. + if (wmoRenderer && currentDistance > MIN_DISTANCE) { + float rawHitDist = wmoRenderer->raycastBoundingBoxes(pivot, camDir, currentDistance); + // rawHitDist == currentDistance means no hit (function returns maxDistance on miss) + float rawLimit = (rawHitDist < currentDistance) + ? std::max(MIN_DISTANCE, rawHitDist - CAM_SPHERE_RADIUS - CAM_EPSILON) + : currentDistance; + + // Initialise smoothed state on first use. + if (smoothedCollisionDist_ < 0.0f) { + smoothedCollisionDist_ = rawLimit; + } + + // Asymmetric smoothing: + // • Pull-in: τ ≈ 60 ms — react quickly to prevent clipping + // • Recover: τ ≈ 400 ms — zoom out slowly after leaving geometry + const float tau = (rawLimit < smoothedCollisionDist_) ? 0.06f : 0.40f; + float alpha = 1.0f - std::exp(-deltaTime / tau); + smoothedCollisionDist_ += (rawLimit - smoothedCollisionDist_) * alpha; + + collisionDistance = std::min(collisionDistance, smoothedCollisionDist_); + } else { + smoothedCollisionDist_ = -1.0f; // Reset when wmoRenderer unavailable + } // Camera collision: terrain-only floor clamping auto getTerrainFloorAt = [&](float x, float y) -> std::optional { From 55895340e9c03fdb8a27bdd14767ebab57417081 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:25:58 -0700 Subject: [PATCH 47/67] game: connect emote animation callback to creature/player renderers SMSG_EMOTE packets for NPCs and other players were received but the emoteAnimCallback_ was never wired to the rendering layer. Register the callback in application.cpp so that when the server sends an emote animation ID, the corresponding CharacterRenderer instance plays it as a one-shot animation (loop=false), falling back to idle on completion. Lookups check creatureInstances_ first, then playerInstances_ so both NPCs and other online players respond to server emote packets. --- src/core/application.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index 11a5063f..2493d4a4 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2741,6 +2741,27 @@ void Application::setupUICallbacks() { } }); + // Emote animation callback — play server-driven emote animations on NPCs and other players + gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) { + if (!renderer || emoteAnim == 0) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + // Look up creature instance first, then online players + { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) { + cr->playAnimation(it->second, emoteAnim, false); + return; + } + } + { + auto it = playerInstances_.find(guid); + if (it != playerInstances_.end()) { + cr->playAnimation(it->second, emoteAnim, false); + } + } + }); + // NPC greeting callback - play voice line gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { From 60b93cdfd9cfc1271ad361dbcb843258e25bf74b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:30:59 -0700 Subject: [PATCH 48/67] rendering/game: remove leftover debug dump I/O from hot paths Remove active file-I/O debug block in character_renderer.cpp that wrote composite textures as raw binary files to /tmp on every texture composite generation. Remove the now-unused include. Remove the 10-shot hex dump of decompressed SMSG_MONSTER_MOVE payloads in game_handler.cpp (dumpCount static); format has been confirmed. --- src/game/game_handler.cpp | 10 ---------- src/rendering/character_renderer.cpp | 15 --------------- 2 files changed, 25 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 76815c3a..5eda7143 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12280,16 +12280,6 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { return; } decompressed.resize(destLen); - // Dump ALL bytes for format diagnosis (remove once confirmed) - static int dumpCount = 0; - if (dumpCount < 10) { - ++dumpCount; - std::string hex; - for (size_t i = 0; i < destLen; ++i) { - char buf[4]; snprintf(buf, sizeof(buf), "%02X ", decompressed[i]); hex += buf; - } - LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex); - } std::vector stripped; bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 59965ec8..c8fbdc79 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -38,7 +38,6 @@ #include #include #include -#include #include #include @@ -1061,19 +1060,6 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector& } } - // Debug: dump composite to temp dir for visual inspection - { - std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" + - std::to_string(width) + "x" + std::to_string(height) + ".raw")).string(); - std::ofstream dump(dumpPath, std::ios::binary); - if (dump) { - dump.write(reinterpret_cast(composite.data()), - static_cast(composite.size())); - core::Logger::getInstance().info("Composite debug dump: ", dumpPath, - " (", width, "x", height, ", ", composite.size(), " bytes)"); - } - } - // Upload composite to GPU via VkTexture auto tex = std::make_unique(); tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true); @@ -2207,7 +2193,6 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, return whiteTexture_.get(); }; - // One-time debug dump of rendered batches per model // Draw batches (submeshes) with per-batch textures for (const auto& batch : gpuModel.data.batches) { if (applyGeosetFilter) { From c20d7c26383902ea63f28d3361b08a8d8a8a0d11 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:36:58 -0700 Subject: [PATCH 49/67] rendering: return to Stand after one-shot emote animations complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-looping animation (e.g. wave, cheer, laugh emote) reaches its end, transition back to Stand (animation 0) rather than freezing on the last frame. Death (animation 1) is excluded — it stays on the final frame as expected. Fixes NPCs and players getting stuck in emote poses after SMSG_EMOTE. --- src/rendering/character_renderer.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index c8fbdc79..c16fc99c 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1659,7 +1659,13 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { if (inst.animationLoop) { inst.animationTime = std::fmod(inst.animationTime, static_cast(seq.duration)); } else { - inst.animationTime = static_cast(seq.duration); + // One-shot animation finished: return to Stand (0) unless dead + if (inst.currentAnimationId != 1 /*Death*/) { + playAnimation(pair.first, 0, true); + } else { + // Stay on last frame of death + inst.animationTime = static_cast(seq.duration); + } } } } From 59c50e3beb52bc38d75957f3039c47f0912fa8da Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:42:17 -0700 Subject: [PATCH 50/67] game/rendering: play SpellCast animation during SMSG_SPELL_START Add SpellCastAnimCallback to GameHandler, triggered on SMSG_SPELL_START (start=true) and cleared on SMSG_SPELL_GO / SMSG_SPELL_FAILURE (start=false) for both the player and other units. Connect the callback in Application to play animation 3 (SpellCast) on the player character, NPCs, and other players when they begin a cast. The cast animation is one-shot (loop=false) so it auto-returns to Stand when complete via the existing return-to-idle logic. Also fire stop-cast on spell failure to cancel any stuck cast pose. --- include/game/game_handler.hpp | 6 ++++++ src/core/application.cpp | 32 ++++++++++++++++++++++++++++++++ src/game/game_handler.cpp | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4633249c..d9e69992 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -623,6 +623,11 @@ public: using MeleeSwingCallback = std::function; void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); } + // Spell cast animation callbacks — true=start cast/channel, false=finish/cancel + // guid: caster (may be player or another unit), isChannel: channel vs regular cast + using SpellCastAnimCallback = std::function; + void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } + // NPC swing callback (plays attack animation on NPC) using NpcSwingCallback = std::function; void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); } @@ -2246,6 +2251,7 @@ private: NpcAggroCallback npcAggroCallback_; NpcRespawnCallback npcRespawnCallback_; MeleeSwingCallback meleeSwingCallback_; + SpellCastAnimCallback spellCastAnimCallback_; NpcSwingCallback npcSwingCallback_; NpcGreetingCallback npcGreetingCallback_; NpcFarewellCallback npcFarewellCallback_; diff --git a/src/core/application.cpp b/src/core/application.cpp index 2493d4a4..a710d7e8 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2762,6 +2762,38 @@ void Application::setupUICallbacks() { } }); + // Spell cast animation callback — play cast animation on caster (player or NPC/other player) + gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + // Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer) + const uint32_t castAnim = 3; + // Check player character + { + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) { + if (start) cr->playAnimation(charInstId, castAnim, false); + // On finish: playAnimation(castAnim, loop=false) will auto-return to Stand + return; + } + } + // Check creatures and other online players + { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) { + if (start) cr->playAnimation(it->second, castAnim, false); + return; + } + } + { + auto it = playerInstances_.find(guid); + if (it != playerInstances_.end()) { + if (start) cr->playAnimation(it->second, castAnim, false); + } + } + }); + // NPC greeting callback - play voice line gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5eda7143..5aaeb021 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2605,9 +2605,15 @@ void GameHandler::handlePacket(network::Packet& packet) { ssm->stopPrecast(); } } + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, false, false); + } } else { // Another unit's cast failed — clear their tracked cast bar unitCastStates_.erase(failGuid); + if (spellCastAnimCallback_) { + spellCastAnimCallback_(failGuid, false, false); + } } break; } @@ -12949,6 +12955,10 @@ void GameHandler::handleSpellStart(network::Packet& packet) { s.spellId = data.spellId; s.timeTotal = data.castTime / 1000.0f; s.timeRemaining = s.timeTotal; + // Trigger cast animation on the casting unit + if (spellCastAnimCallback_) { + spellCastAnimCallback_(data.casterUnit, true, false); + } } // If this is the player's own cast, start cast bar @@ -12970,6 +12980,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) { } } + // Trigger cast animation on player character + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, true, false); + } + // 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 @@ -13021,6 +13036,14 @@ void GameHandler::handleSpellGo(network::Packet& packet) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + + // End cast animation on player character + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, false, false); + } + } else if (spellCastAnimCallback_) { + // End cast animation on other unit + spellCastAnimCallback_(data.casterUnit, false, false); } // Clear unit cast bar when the spell lands (for any tracked unit) From 9f3c236c48c868a6dd961b164284d03e860ee4f4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:46:46 -0700 Subject: [PATCH 51/67] game/rendering: drive player stand-state animation from SMSG_STANDSTATE_UPDATE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add StandStateCallback to GameHandler, fired when the server confirms a stand state change (SMSG_STANDSTATE_UPDATE). Connect in Application to map the WoW stand state (0-8) to M2 animation IDs on the player character model: - 0 = Stand → anim 0 (Stand) - 1-6 = Sit variants → anim 27 (SitGround) - 7 = Dead → anim 1 (Death) - 8 = Kneel → anim 72 (Kneel) Sit and Kneel animations are looped so the held-pose frame stays visible; Death stays on the final frame. --- include/game/game_handler.hpp | 6 ++++++ src/core/application.cpp | 24 ++++++++++++++++++++++++ src/game/game_handler.cpp | 3 +++ 3 files changed, 33 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d9e69992..3824dbf9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -619,6 +619,11 @@ public: using NpcRespawnCallback = std::function; void setNpcRespawnCallback(NpcRespawnCallback cb) { npcRespawnCallback_ = std::move(cb); } + // Stand state animation callback — fired when SMSG_STANDSTATE_UPDATE confirms a new state + // standState: 0=stand, 1-6=sit variants, 7=dead, 8=kneel + using StandStateCallback = std::function; + void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); } + // Melee swing callback (for driving animation/SFX) using MeleeSwingCallback = std::function; void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); } @@ -2250,6 +2255,7 @@ private: NpcDeathCallback npcDeathCallback_; NpcAggroCallback npcAggroCallback_; NpcRespawnCallback npcRespawnCallback_; + StandStateCallback standStateCallback_; MeleeSwingCallback meleeSwingCallback_; SpellCastAnimCallback spellCastAnimCallback_; NpcSwingCallback npcSwingCallback_; diff --git a/src/core/application.cpp b/src/core/application.cpp index a710d7e8..a3c12e98 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2794,6 +2794,30 @@ void Application::setupUICallbacks() { } }); + // Stand state animation callback — map server stand state to M2 animation on player + gameHandler->setStandStateCallback([this](uint8_t standState) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId == 0) return; + // WoW stand state → M2 animation ID mapping + // 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72 + uint32_t animId = 0; + if (standState == 0) { + animId = 0; // Stand + } else if (standState >= 1 && standState <= 6) { + animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height) + } else if (standState == 7) { + animId = 1; // Death + } else if (standState == 8) { + animId = 72; // Kneel + } + // Non-looping sit/kneel looks wrong frozen; loop them so the held-pose frame shows + const bool loop = (animId != 1); + cr->playAnimation(charInstId, animId, loop); + }); + // NPC greeting callback - play voice line gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5aaeb021..eb459ebd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4253,6 +4253,9 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Stand state updated: ", static_cast(standState_), " (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit" : standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")"); + if (standStateCallback_) { + standStateCallback_(standState_); + } } break; case Opcode::SMSG_NEW_TAXI_PATH: From 366321042fe4e200200c11a50382345cf3216452 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:51:15 -0700 Subject: [PATCH 52/67] rendering/game: sync camera sit state from server-confirmed stand state Add CameraController::setSitting() and call it from the StandStateCallback so the camera blocks movement when the server confirms the player is sitting or kneeling (stand states 1-6, 8). This prevents the player from sliding across the ground after sitting. Death (state 7) deliberately leaves sitting=false so the player can still respawn/move after death without input being blocked. --- include/rendering/camera_controller.hpp | 1 + src/core/application.cpp | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 1ccf7f63..79a7d622 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -82,6 +82,7 @@ public: bool isSwimming() const { return swimming; } bool isInsideWMO() const { return cachedInsideWMO; } void setGrounded(bool g) { grounded = g; } + void setSitting(bool s) { sitting = s; } bool isOnTaxi() const { return externalFollow_; } const glm::vec3* getFollowTarget() const { return followTarget; } glm::vec3* getFollowTargetMutable() { return followTarget; } diff --git a/src/core/application.cpp b/src/core/application.cpp index a3c12e98..0d7e1e8c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2795,8 +2795,15 @@ void Application::setupUICallbacks() { }); // Stand state animation callback — map server stand state to M2 animation on player + // and sync camera sit flag so movement is blocked while sitting gameHandler->setStandStateCallback([this](uint8_t standState) { if (!renderer) return; + + // Sync camera controller sitting flag: block movement while sitting/kneeling + if (auto* cc = renderer->getCameraController()) { + cc->setSitting(standState >= 1 && standState <= 8 && standState != 7); + } + auto* cr = renderer->getCharacterRenderer(); if (!cr) return; uint32_t charInstId = renderer->getCharacterInstanceId(); @@ -2813,7 +2820,7 @@ void Application::setupUICallbacks() { } else if (standState == 8) { animId = 72; // Kneel } - // Non-looping sit/kneel looks wrong frozen; loop them so the held-pose frame shows + // Loop sit/kneel (not death) so the held-pose frame stays visible const bool loop = (animId != 1); cr->playAnimation(charInstId, animId, loop); }); From c8d9d6b792b046c8a9814de7cdea5b9ee4855a19 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 09:57:24 -0700 Subject: [PATCH 53/67] rendering/game: make player model semi-transparent in ghost form Add GhostStateCallback to GameHandler, fired when PLAYER_FLAGS_GHOST transitions on or off in UPDATE_OBJECT / login detection. Add setInstanceOpacity() to CharacterRenderer to directly set opacity without disturbing fade-in state. Application wires the callback to set opacity 0.5 on ghost entry and 1.0 on resurrect. --- include/game/game_handler.hpp | 5 +++++ include/rendering/character_renderer.hpp | 1 + src/core/application.cpp | 10 ++++++++++ src/game/game_handler.cpp | 3 +++ src/rendering/character_renderer.cpp | 9 +++++++++ 5 files changed, 28 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3824dbf9..944c1e63 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -624,6 +624,10 @@ public: using StandStateCallback = std::function; void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); } + // Ghost state callback — fired when player enters or leaves ghost (spirit) form + using GhostStateCallback = std::function; + void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); } + // Melee swing callback (for driving animation/SFX) using MeleeSwingCallback = std::function; void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); } @@ -2256,6 +2260,7 @@ private: NpcAggroCallback npcAggroCallback_; NpcRespawnCallback npcRespawnCallback_; StandStateCallback standStateCallback_; + GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; SpellCastAnimCallback spellCastAnimCallback_; NpcSwingCallback npcSwingCallback_; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index f516b3a4..2b400998 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -78,6 +78,7 @@ public: void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation); void moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds); void startFadeIn(uint32_t instanceId, float durationSeconds); + void setInstanceOpacity(uint32_t instanceId, float opacity); const pipeline::M2Model* getModelData(uint32_t modelId) const; void setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets); void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture); diff --git a/src/core/application.cpp b/src/core/application.cpp index 0d7e1e8c..5065cc68 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2794,6 +2794,16 @@ void Application::setupUICallbacks() { } }); + // Ghost state callback — make player semi-transparent when in spirit form + gameHandler->setGhostStateCallback([this](bool isGhost) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId == 0) return; + cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f); + }); + // Stand state animation callback — map server stand state to M2 animation on player // and sync camera sit flag so movement is blocked while sitting gameHandler->setStandStateCallback([this](uint8_t standState) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index eb459ebd..13a2f708 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7749,6 +7749,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { releasedSpirit_ = true; playerDead_ = true; LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); } } // Determine hostility from faction template for online creatures @@ -8212,12 +8213,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (!wasGhost && nowGhost) { releasedSpirit_ = true; LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); } else if (wasGhost && !nowGhost) { releasedSpirit_ = false; playerDead_ = false; repopPending_ = false; resurrectPending_ = false; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); + if (ghostStateCallback_) ghostStateCallback_(false); } } } diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index c16fc99c..5683af91 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -2876,6 +2876,15 @@ void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) it->second.fadeInDuration = durationSeconds; } +void CharacterRenderer::setInstanceOpacity(uint32_t instanceId, float opacity) { + auto it = instances.find(instanceId); + if (it != instances.end()) { + it->second.opacity = std::clamp(opacity, 0.0f, 1.0f); + // Cancel any fade-in in progress to avoid overwriting the new opacity + it->second.fadeInDuration = 0.0f; + } +} + void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets) { auto it = instances.find(instanceId); if (it != instances.end()) { From 4e137c4061b12045726f48da2dcf75a8113ac893 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:06:56 -0700 Subject: [PATCH 54/67] rendering: drive Run/Stand animations from actual movement state CameraController now transitions the player character to Run (anim 4) on movement start and back to Stand (anim 0) on stop, guarded by a prevPlayerMoving_ flag so animation time is not reset every frame. Death animation (anim 1) is never overridden. Application creature sync similarly switches creature models to Run (4) when they move between server positions and Stand (0) when they stop, with per-guid creatureWasMoving_ tracking to avoid per-frame resets. --- include/core/application.hpp | 1 + include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 20 ++++++++++++++++++-- src/rendering/camera_controller.cpp | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index d2ef3f36..61951d7d 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -187,6 +187,7 @@ private: std::unordered_map creatureInstances_; // guid → render instanceId std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position + std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 79a7d622..fc513117 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -226,6 +226,9 @@ private: bool autoRunning = false; bool tildeWasDown = false; + // Movement animation state tracking + bool prevPlayerMoving_ = false; + // Movement state tracking (for sending opcodes on state change) bool wasMovingForward = false; bool wasMovingBackward = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 5065cc68..b707e3d6 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -749,6 +749,7 @@ void Application::logoutToLogin() { creatureRenderPosCache_.clear(); creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); + creatureWasMoving_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -1466,14 +1467,28 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); + const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); - } else if (planarDist > 0.03f || dz > 0.08f) { - // Use movement interpolation so step/run animation can play. + } else if (isMovingNow) { float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } posIt->second = renderPos; + + // Drive movement animation: Run (4) when moving, Stand (0) when idle. + // Only switch on transitions to avoid resetting animation time. + // Don't override Death (1) animation. + bool prevMoving = creatureWasMoving_[guid]; + if (isMovingNow != prevMoving) { + creatureWasMoving_[guid] = isMovingNow; + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + charRenderer->playAnimation(instanceId, + isMovingNow ? 4u : 0u, /*loop=*/true); + } + } } float renderYaw = entity->getOrientation() + glm::radians(90.0f); charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); @@ -8449,6 +8464,7 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureRenderPosCache_.erase(guid); creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); + creatureWasMoving_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 4e0d6ff2..21626f52 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1445,6 +1445,21 @@ void CameraController::update(float deltaTime) { // Honor first-person intent even if anti-clipping pushes camera back slightly. bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f); characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer); + + // Drive movement animation: Run (4) when moving, Stand (0) when idle. + // Only transition on state changes to avoid resetting animation time every frame. + // Skip if current animation is Death (1) — death pose must persist. + bool nowMoving = isMoving(); + if (nowMoving != prevPlayerMoving_) { + prevPlayerMoving_ = nowMoving; + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + bool gotState = characterRenderer->getAnimationState( + playerInstanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + characterRenderer->playAnimation(playerInstanceId, + nowMoving ? 4u : 0u, /*loop=*/true); + } + } } } else { // Free-fly camera mode (original behavior) From baaa0633427cd408eefa244117295d1cec242208 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:09:02 -0700 Subject: [PATCH 55/67] rendering: play Run animation during SMSG_MONSTER_MOVE spline paths creatureMoveCallback now plays anim 4 (Run) when a spline move begins (durationMs > 0), mirroring the per-frame sync logic so NPC/player characters animate correctly during server-driven path moves as well as position-sync moves. --- src/core/application.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index b707e3d6..adb454a0 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2442,8 +2442,9 @@ void Application::setupUICallbacks() { gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = 0; + bool isPlayer = false; auto pit = playerInstances_.find(guid); - if (pit != playerInstances_.end()) instanceId = pit->second; + if (pit != playerInstances_.end()) { instanceId = pit->second; isPlayer = true; } else { auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; @@ -2452,6 +2453,18 @@ void Application::setupUICallbacks() { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float durationSec = static_cast(durationMs) / 1000.0f; renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); + // Play Run animation for the duration of the spline move (anim 4). + // Don't override Death animation (1). The per-frame sync loop will return to + // Stand when movement stops. + if (durationMs > 0) { + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + auto* cr = renderer->getCharacterRenderer(); + bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + cr->playAnimation(instanceId, 4u, /*loop=*/true); + } + if (!isPlayer) creatureWasMoving_[guid] = true; + } } }); From 279c30367a67acba9d845f395c360763628d332b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:10:46 -0700 Subject: [PATCH 56/67] rendering: use Walk (anim 5) vs Run (anim 4) based on movement pace CameraController now plays Walk (5) for backpedal/slow pace and Run (4) for forward running (runPace), matching WoW's animation convention. Also handles transitions between Walk and Run while already moving. --- src/rendering/camera_controller.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 21626f52..0eeb7c50 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1446,10 +1446,12 @@ void CameraController::update(float deltaTime) { bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f); characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer); - // Drive movement animation: Run (4) when moving, Stand (0) when idle. + // Drive movement animation: Run (4) / Walk (5) when moving, Stand (0) when idle. // Only transition on state changes to avoid resetting animation time every frame. // Skip if current animation is Death (1) — death pose must persist. bool nowMoving = isMoving(); + // Use Run (4) when running forward; Walk (5) for backpedal or slow pace. + uint32_t movingAnim = runPace ? 4u : 5u; if (nowMoving != prevPlayerMoving_) { prevPlayerMoving_ = nowMoving; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; @@ -1457,7 +1459,14 @@ void CameraController::update(float deltaTime) { playerInstanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { characterRenderer->playAnimation(playerInstanceId, - nowMoving ? 4u : 0u, /*loop=*/true); + nowMoving ? movingAnim : 0u, /*loop=*/true); + } + } else if (nowMoving) { + // Also switch between Run and Walk if pace changes while moving. + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + characterRenderer->getAnimationState(playerInstanceId, curAnimId, curT, curDur); + if (curAnimId != movingAnim && curAnimId != 1 /*Death*/) { + characterRenderer->playAnimation(playerInstanceId, movingAnim, /*loop=*/true); } } } From 137b25f3188a45113ff4b1bfe058a7bb70dc3223 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:19:13 -0700 Subject: [PATCH 57/67] rendering: fix NPC movement animation IDs and remove redundant player anim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer's CharAnimState machine already drives player character animations (Run=5, Walk=4, Jump, Swim, etc.) — remove the conflicting camera controller code added in the previous commit. Fix creature movement animations to use the correct WoW M2 IDs: 4=Walk, 5=Run. Both the per-frame sync loop and the SMSG_MONSTER_MOVE spline callback now use Run (5) for NPC movement. --- include/rendering/camera_controller.hpp | 3 --- src/core/application.cpp | 10 ++++++---- src/rendering/camera_controller.cpp | 25 ++----------------------- 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index fc513117..79a7d622 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -226,9 +226,6 @@ private: bool autoRunning = false; bool tildeWasDown = false; - // Movement animation state tracking - bool prevPlayerMoving_ = false; - // Movement state tracking (for sending opcodes on state change) bool wasMovingForward = false; bool wasMovingBackward = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index adb454a0..00d4ef81 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1476,7 +1476,8 @@ void Application::update(float deltaTime) { } posIt->second = renderPos; - // Drive movement animation: Run (4) when moving, Stand (0) when idle. + // Drive movement animation: Run (anim 5) when moving, Stand (0) when idle. + // WoW M2 animation IDs: 4=Walk, 5=Run. Use Run for all server-driven NPC movement. // Only switch on transitions to avoid resetting animation time. // Don't override Death (1) animation. bool prevMoving = creatureWasMoving_[guid]; @@ -1486,7 +1487,7 @@ void Application::update(float deltaTime) { bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { charRenderer->playAnimation(instanceId, - isMovingNow ? 4u : 0u, /*loop=*/true); + isMovingNow ? 5u : 0u, /*loop=*/true); } } } @@ -2453,7 +2454,8 @@ void Application::setupUICallbacks() { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float durationSec = static_cast(durationMs) / 1000.0f; renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); - // Play Run animation for the duration of the spline move (anim 4). + // Play Run animation (anim 5) for the duration of the spline move. + // WoW M2 animation IDs: 4=Walk, 5=Run. // Don't override Death animation (1). The per-frame sync loop will return to // Stand when movement stops. if (durationMs > 0) { @@ -2461,7 +2463,7 @@ void Application::setupUICallbacks() { auto* cr = renderer->getCharacterRenderer(); bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { - cr->playAnimation(instanceId, 4u, /*loop=*/true); + cr->playAnimation(instanceId, 5u, /*loop=*/true); } if (!isPlayer) creatureWasMoving_[guid] = true; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 0eeb7c50..cd6f7c27 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1446,29 +1446,8 @@ void CameraController::update(float deltaTime) { bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f); characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer); - // Drive movement animation: Run (4) / Walk (5) when moving, Stand (0) when idle. - // Only transition on state changes to avoid resetting animation time every frame. - // Skip if current animation is Death (1) — death pose must persist. - bool nowMoving = isMoving(); - // Use Run (4) when running forward; Walk (5) for backpedal or slow pace. - uint32_t movingAnim = runPace ? 4u : 5u; - if (nowMoving != prevPlayerMoving_) { - prevPlayerMoving_ = nowMoving; - uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; - bool gotState = characterRenderer->getAnimationState( - playerInstanceId, curAnimId, curT, curDur); - if (!gotState || curAnimId != 1 /*Death*/) { - characterRenderer->playAnimation(playerInstanceId, - nowMoving ? movingAnim : 0u, /*loop=*/true); - } - } else if (nowMoving) { - // Also switch between Run and Walk if pace changes while moving. - uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; - characterRenderer->getAnimationState(playerInstanceId, curAnimId, curT, curDur); - if (curAnimId != movingAnim && curAnimId != 1 /*Death*/) { - characterRenderer->playAnimation(playerInstanceId, movingAnim, /*loop=*/true); - } - } + // Note: the Renderer's CharAnimState machine drives player character animations + // (Run, Walk, Jump, Swim, etc.) — no additional animation driving needed here. } } else { // Free-fly camera mode (original behavior) From 14c2bc97b199bba54c8da7a9791cecb29b92c701 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:30:50 -0700 Subject: [PATCH 58/67] rendering/game: fix other-player movement animations and add jump/swim hints - MSG_MOVE_STOP/STOP_STRAFE/STOP_TURN/STOP_SWIM/FALL_LAND: snap entity to stop position (duration=0) and pass durationMs=0 to renderer so the Run-animation flash is suppressed; per-frame sync plays Stand on next frame - MSG_MOVE_JUMP: fire new UnitAnimHintCallback with anim 38 (JumpMid) so other players and NPCs visually leave the ground during jumps - MSG_MOVE_START_SWIM: fire UnitAnimHintCallback with anim 42 (Swim) - Wire up UnitAnimHintCallback in application.cpp; skips Death (anim 1) --- include/game/game_handler.hpp | 5 +++++ src/core/application.cpp | 23 +++++++++++++++++++++++ src/game/game_handler.cpp | 29 ++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 944c1e63..457f9870 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -637,6 +637,10 @@ public: using SpellCastAnimCallback = std::function; void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } + // Unit animation hint: signal jump (animId=38) or swim (animId=42) for other players/NPCs + using UnitAnimHintCallback = std::function; + void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); } + // NPC swing callback (plays attack animation on NPC) using NpcSwingCallback = std::function; void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); } @@ -2263,6 +2267,7 @@ private: GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; SpellCastAnimCallback spellCastAnimCallback_; + UnitAnimHintCallback unitAnimHintCallback_; NpcSwingCallback npcSwingCallback_; NpcGreetingCallback npcGreetingCallback_; NpcFarewellCallback npcFarewellCallback_; diff --git a/src/core/application.cpp b/src/core/application.cpp index 00d4ef81..cb311c27 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2771,6 +2771,29 @@ void Application::setupUICallbacks() { } }); + // Unit animation hint callback — play jump (38) or swim (42) on other players/NPCs + // when MSG_MOVE_JUMP or MSG_MOVE_START_SWIM arrives. The per-frame sync handles the + // return to Stand/Run once the unit lands or exits water. + gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + uint32_t instanceId = 0; + { + auto it = playerInstances_.find(guid); + if (it != playerInstances_.end()) instanceId = it->second; + } + if (instanceId == 0) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) instanceId = it->second; + } + if (instanceId == 0) return; + // Don't override Death animation (1) + uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f; + if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) return; + cr->playAnimation(instanceId, animId, /*loop=*/true); + }); + // Emote animation callback — play server-driven emote animations on NPCs and other players gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) { if (!renderer || emoteAnim == 0) return; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 13a2f708..a1098fc8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12165,11 +12165,34 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { } otherPlayerMoveTimeMs_[moverGuid] = info.time; - entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, durationMs / 1000.0f); + // Classify the opcode so we can drive the correct entity update and animation. + const uint16_t wireOp = packet.getOpcode(); + const bool isStopOpcode = + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_TURN)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_FALL_LAND)); + const bool isJumpOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_JUMP)); + const bool isSwimOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_START_SWIM)); - // Notify renderer + // For stop opcodes snap the entity position (duration=0) so it doesn't keep interpolating, + // and pass durationMs=0 to the renderer so the Run-anim flash is suppressed. + // The per-frame sync will detect no movement and play Stand on the next frame. + const float entityDuration = isStopOpcode ? 0.0f : (durationMs / 1000.0f); + entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, entityDuration); + + // Notify renderer of position change if (creatureMoveCallback_) { - creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, durationMs); + const uint32_t notifyDuration = isStopOpcode ? 0u : durationMs; + creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration); + } + + // Signal specific animation transitions that the per-frame sync can't detect reliably. + // WoW M2 animation IDs: 38=JumpMid (loops during airborne), 42=Swim + if (unitAnimHintCallback_) { + if (isJumpOpcode) unitAnimHintCallback_(moverGuid, 38u); + else if (isSwimOpcode) unitAnimHintCallback_(moverGuid, 42u); } } From 333ada8eb6b7b3cc1c7bb6870c215f8f93f9fa36 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:36:45 -0700 Subject: [PATCH 59/67] rendering/game: track per-entity swim state for correct water animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add creatureSwimmingState_ map to track which units are swimming - unitAnimHintCallback with animId=42 (Swim): marks entity as swimming - unitAnimHintCallback with animId=0 (MSG_MOVE_STOP_SWIM): clears swim state - Per-frame sync: uses Swim(42)/SwimIdle(41) when swimming, Run(5)/Stand(0) otherwise — creatures/players now show SwimIdle when standing still in water - Clear creatureSwimmingState_ on creature/player despawn and world reset --- include/core/application.hpp | 1 + src/core/application.cpp | 32 +++++++++++++++++++++++++------- src/game/game_handler.cpp | 7 +++++-- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 61951d7d..bf8eba67 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -188,6 +188,7 @@ private: std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check diff --git a/src/core/application.cpp b/src/core/application.cpp index cb311c27..15e4d326 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -750,6 +750,7 @@ void Application::logoutToLogin() { creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); creatureWasMoving_.clear(); + creatureSwimmingState_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -1476,18 +1477,23 @@ void Application::update(float deltaTime) { } posIt->second = renderPos; - // Drive movement animation: Run (anim 5) when moving, Stand (0) when idle. - // WoW M2 animation IDs: 4=Walk, 5=Run. Use Run for all server-driven NPC movement. + // Drive movement animation: Run/Swim (5/42) when moving, Stand/SwimIdle (0/41) when idle. + // WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim. // Only switch on transitions to avoid resetting animation time. // Don't override Death (1) animation. + const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; bool prevMoving = creatureWasMoving_[guid]; if (isMovingNow != prevMoving) { creatureWasMoving_[guid] = isMovingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { - charRenderer->playAnimation(instanceId, - isMovingNow ? 5u : 0u, /*loop=*/true); + uint32_t targetAnim; + if (isMovingNow) + targetAnim = isSwimmingNow ? 42u : 5u; // Swim vs Run + else + targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand + charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); } } } @@ -2771,10 +2777,20 @@ void Application::setupUICallbacks() { } }); - // Unit animation hint callback — play jump (38) or swim (42) on other players/NPCs - // when MSG_MOVE_JUMP or MSG_MOVE_START_SWIM arrives. The per-frame sync handles the - // return to Stand/Run once the unit lands or exits water. + // Unit animation hint callback — play jump (38) or swim (42) on other players/NPCs. + // animId=42 (Swim): marks entity as swimming; per-frame sync will use SwimIdle(41) when stopped. + // animId=0: clears swim state (MSG_MOVE_STOP_SWIM); per-frame sync reverts to Stand(0). + // animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync. gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) { + // Track swim state regardless of whether the instance is visible yet. + if (animId == 42u) { + creatureSwimmingState_[guid] = true; + } else if (animId == 0u) { + creatureSwimmingState_.erase(guid); + // Don't play Stand here — per-frame sync will do it when movement ceases. + return; + } + if (!renderer) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; @@ -6910,6 +6926,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) { playerInstances_.erase(it); onlinePlayerAppearance_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid); + creatureSwimmingState_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -8503,6 +8520,7 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); creatureWasMoving_.erase(guid); + creatureSwimmingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a1098fc8..66a37b7b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12190,9 +12190,12 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Signal specific animation transitions that the per-frame sync can't detect reliably. // WoW M2 animation IDs: 38=JumpMid (loops during airborne), 42=Swim + // animId=0 signals "exit swim mode" (MSG_MOVE_STOP_SWIM) so per-frame sync reverts to Stand. + const bool isStopSwimOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)); if (unitAnimHintCallback_) { - if (isJumpOpcode) unitAnimHintCallback_(moverGuid, 38u); - else if (isSwimOpcode) unitAnimHintCallback_(moverGuid, 42u); + if (isJumpOpcode) unitAnimHintCallback_(moverGuid, 38u); + else if (isSwimOpcode) unitAnimHintCallback_(moverGuid, 42u); + else if (isStopSwimOpcode) unitAnimHintCallback_(moverGuid, 0u); } } From d7ebc5c8c70567c14d4aeaa177a59a57bba8349f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 10:55:23 -0700 Subject: [PATCH 60/67] game/rendering: drive Walk(4) and swim state from movement flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UnitMoveFlagsCallback fired on every MSG_MOVE_* with the raw movement flags field. Application.cpp uses it to update swimming and walking state from any packet, not just explicit START_SWIM/ STOP_SWIM opcodes — fixing cold-join cases where a player is already swimming when we enter the world. Per-frame animation sync now selects Walk(4) when the WALKING flag is set, Run(5) otherwise, and Swim(42)/SwimIdle(41) when swimming. UnitAnimHintCallback is simplified to jump (38=JumpMid) only. --- include/core/application.hpp | 3 ++- include/game/game_handler.hpp | 8 +++++++- src/core/application.cpp | 36 +++++++++++++++++++++-------------- src/game/game_handler.cpp | 18 ++++++++++-------- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index bf8eba67..0c7ca61e 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -188,7 +188,8 @@ private: std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state - std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode + std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) + std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 457f9870..8e3420c5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -637,10 +637,15 @@ public: using SpellCastAnimCallback = std::function; void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } - // Unit animation hint: signal jump (animId=38) or swim (animId=42) for other players/NPCs + // Unit animation hint: signal jump (animId=38) for other players/NPCs using UnitAnimHintCallback = std::function; void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); } + // Unit move-flags callback: fired on every MSG_MOVE_* for other players with the raw flags field. + // Drives Walk(4) vs Run(5) selection and swim state initialization from heartbeat packets. + using UnitMoveFlagsCallback = std::function; + void setUnitMoveFlagsCallback(UnitMoveFlagsCallback cb) { unitMoveFlagsCallback_ = std::move(cb); } + // NPC swing callback (plays attack animation on NPC) using NpcSwingCallback = std::function; void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); } @@ -2268,6 +2273,7 @@ private: MeleeSwingCallback meleeSwingCallback_; SpellCastAnimCallback spellCastAnimCallback_; UnitAnimHintCallback unitAnimHintCallback_; + UnitMoveFlagsCallback unitMoveFlagsCallback_; NpcSwingCallback npcSwingCallback_; NpcGreetingCallback npcGreetingCallback_; NpcFarewellCallback npcFarewellCallback_; diff --git a/src/core/application.cpp b/src/core/application.cpp index 15e4d326..ca692dd6 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -751,6 +751,7 @@ void Application::logoutToLogin() { creatureWeaponAttachAttempts_.clear(); creatureWasMoving_.clear(); creatureSwimmingState_.clear(); + creatureWalkingState_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -1477,11 +1478,13 @@ void Application::update(float deltaTime) { } posIt->second = renderPos; - // Drive movement animation: Run/Swim (5/42) when moving, Stand/SwimIdle (0/41) when idle. + // Drive movement animation: Walk/Run/Swim (4/5/42) when moving, + // Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set. // WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim. // Only switch on transitions to avoid resetting animation time. // Don't override Death (1) animation. const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; + const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; bool prevMoving = creatureWasMoving_[guid]; if (isMovingNow != prevMoving) { creatureWasMoving_[guid] = isMovingNow; @@ -1490,7 +1493,7 @@ void Application::update(float deltaTime) { if (!gotState || curAnimId != 1 /*Death*/) { uint32_t targetAnim; if (isMovingNow) - targetAnim = isSwimmingNow ? 42u : 5u; // Swim vs Run + targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run else targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); @@ -2777,20 +2780,10 @@ void Application::setupUICallbacks() { } }); - // Unit animation hint callback — play jump (38) or swim (42) on other players/NPCs. - // animId=42 (Swim): marks entity as swimming; per-frame sync will use SwimIdle(41) when stopped. - // animId=0: clears swim state (MSG_MOVE_STOP_SWIM); per-frame sync reverts to Stand(0). + // Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs. + // Swim/walking state is now authoritative from the move-flags callback below. // animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync. gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) { - // Track swim state regardless of whether the instance is visible yet. - if (animId == 42u) { - creatureSwimmingState_[guid] = true; - } else if (animId == 0u) { - creatureSwimmingState_.erase(guid); - // Don't play Stand here — per-frame sync will do it when movement ceases. - return; - } - if (!renderer) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; @@ -2810,6 +2803,19 @@ void Application::setupUICallbacks() { cr->playAnimation(instanceId, animId, /*loop=*/true); }); + // Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet. + // This is more reliable than opcode-based hints for cold joins and heartbeats: + // a player already swimming when we join will have SWIMMING set on the first heartbeat. + // Walking(4) vs Running(5) is also driven here from the WALKING flag. + gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) { + const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; + const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; + if (isSwimming) creatureSwimmingState_[guid] = true; + else creatureSwimmingState_.erase(guid); + if (isWalking) creatureWalkingState_[guid] = true; + else creatureWalkingState_.erase(guid); + }); + // Emote animation callback — play server-driven emote animations on NPCs and other players gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) { if (!renderer || emoteAnim == 0) return; @@ -6927,6 +6933,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) { onlinePlayerAppearance_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid); creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -8521,6 +8528,7 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureWeaponAttachAttempts_.erase(guid); creatureWasMoving_.erase(guid); creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 66a37b7b..8825cb25 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12174,7 +12174,6 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)) || (wireOp == wireOpcode(Opcode::MSG_MOVE_FALL_LAND)); const bool isJumpOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_JUMP)); - const bool isSwimOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_START_SWIM)); // For stop opcodes snap the entity position (duration=0) so it doesn't keep interpolating, // and pass durationMs=0 to the renderer so the Run-anim flash is suppressed. @@ -12189,13 +12188,16 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { } // Signal specific animation transitions that the per-frame sync can't detect reliably. - // WoW M2 animation IDs: 38=JumpMid (loops during airborne), 42=Swim - // animId=0 signals "exit swim mode" (MSG_MOVE_STOP_SWIM) so per-frame sync reverts to Stand. - const bool isStopSwimOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)); - if (unitAnimHintCallback_) { - if (isJumpOpcode) unitAnimHintCallback_(moverGuid, 38u); - else if (isSwimOpcode) unitAnimHintCallback_(moverGuid, 42u); - else if (isStopSwimOpcode) unitAnimHintCallback_(moverGuid, 0u); + // WoW M2 animation ID 38=JumpMid (loops during airborne). + // Swim/walking state is now authoritative from the movement flags field via unitMoveFlagsCallback_. + if (unitAnimHintCallback_ && isJumpOpcode) { + unitAnimHintCallback_(moverGuid, 38u); + } + + // Fire move-flags callback so application.cpp can update swimming/walking state + // from the flags field embedded in every movement packet (covers heartbeats and cold joins). + if (unitMoveFlagsCallback_) { + unitMoveFlagsCallback_(moverGuid, info.flags); } } From 3439df0333f232b73ff00d9654fd0c931302a163 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:03:33 -0700 Subject: [PATCH 61/67] net: fix UPDATEFLAG_LIVING pitch misalignment for swimming entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseMovementBlock was checking moveFlags & 0x02000000 for the pitch field, but SWIMMING is 0x00200000 in WotLK 3.3.5a. Swimming NPCs and players in SMSG_UPDATE_OBJECT packets never triggered the pitch read, so the fallTime/jumpData/splineElevation fields were read from the wrong offsets, producing incorrect positions and orientations. Fix: check both SWIMMING (0x00200000) and FLYING (0x02000000), matching the WotLK format — same condition used in the write path. --- src/game/world_packets.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 7587cd65..694a61cd 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -865,7 +865,14 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } // Swimming/flying pitch - if ((moveFlags & 0x02000000) || (moveFlags2 & 0x0010)) { // MOVEMENTFLAG_SWIMMING or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING + // WotLK 3.3.5a flags: SWIMMING=0x00200000, FLYING=0x02000000 + // Pitch is present when SWIMMING or FLYING are set, or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING. + // The original check (0x02000000 only) missed SWIMMING, causing misaligned reads for + // swimming NPCs/players — all subsequent fields (fallTime, jumpData, splineElevation) + // would be read from the wrong offsets. + if ((moveFlags & 0x00200000) /* SWIMMING */ || + (moveFlags & 0x02000000) /* FLYING(WotLK) */ || + (moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) { /*float pitch =*/ packet.readFloat(); } From 863ea742f674874075bd6c5bc3d36052d6a9359a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:14:58 -0700 Subject: [PATCH 62/67] net/game: initialise entity swim/walk state from spawn packet and SPLINE_MOVE opcodes UpdateBlock now stores moveFlags from the LIVING movement block so the cold-join problem is fixed: entities already swimming or walking when the client joins get their animation state correctly initialised from the SMSG_UPDATE_OBJECT CREATE_OBJECT packet rather than waiting for the next MSG_MOVE_* heartbeat. Additionally, SMSG_SPLINE_MOVE_START_SWIM, SMSG_SPLINE_MOVE_STOP_SWIM, SMSG_SPLINE_MOVE_SET_WALK_MODE, SMSG_SPLINE_MOVE_SET_RUN_MODE, and SMSG_SPLINE_MOVE_SET_FLYING now fire unitMoveFlagsCallback_ with synthesised flags so explicit server-driven mode transitions update animation state immediately without waiting for a heartbeat. --- include/game/world_packets.hpp | 4 ++++ src/game/game_handler.cpp | 37 +++++++++++++++++++++++++++------- src/game/world_packets.cpp | 1 + 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 7f62b622..c13659c3 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -481,6 +481,10 @@ struct UpdateBlock { // Update flags from movement block (for detecting transports, etc.) uint16_t updateFlags = 0; + // Raw movement flags from LIVING block (SWIMMING=0x200000, WALKING=0x100, CAN_FLY=0x800000, FLYING=0x1000000) + // Used to initialise swim/walk/fly state on entity spawn (cold-join). + uint32_t moveFlags = 0; + // Transport data from LIVING movement block (MOVEMENTFLAG_ONTRANSPORT) bool onTransport = false; uint64_t transportGuid = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8825cb25..7bb5ec6a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2347,24 +2347,40 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_MONSTER_MOVE_TRANSPORT: handleMonsterMoveTransport(packet); break; - case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE: - case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: case Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL: case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE: case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE: case Opcode::SMSG_SPLINE_MOVE_LAND_WALK: case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL: case Opcode::SMSG_SPLINE_MOVE_ROOT: - case Opcode::SMSG_SPLINE_MOVE_SET_FLYING: - case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: - case Opcode::SMSG_SPLINE_MOVE_START_SWIM: - case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: { - // Minimal parse: PackedGuid only — entity state flag change. + case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: { + // Minimal parse: PackedGuid only — no animation-relevant state change. if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); } break; } + case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE: + case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: + case Opcode::SMSG_SPLINE_MOVE_SET_FLYING: + case Opcode::SMSG_SPLINE_MOVE_START_SWIM: + case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: { + // PackedGuid + synthesised move-flags → drives animation state in application layer. + // SWIMMING=0x00200000, WALKING=0x00000100, CAN_FLY=0x00800000, FLYING=0x01000000 + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; + uint32_t synthFlags = 0; + if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_START_SWIM) + synthFlags = 0x00200000u; // SWIMMING + else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE) + synthFlags = 0x00000100u; // WALKING + else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_FLYING) + synthFlags = 0x01000000u | 0x00800000u; // FLYING | CAN_FLY + // STOP_SWIM and SET_RUN_MODE: synthFlags stays 0 → clears swim/walk + unitMoveFlagsCallback_(guid, synthFlags); + break; + } case Opcode::SMSG_SPLINE_SET_RUN_SPEED: case Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_SWIM_SPEED: { @@ -7786,6 +7802,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { npcDeathCallback_(block.guid); } } + // Initialise swim/walk state from spawn-time movement flags (cold-join fix). + // Without this, an entity already swimming/walking when the client joins + // won't get its animation state set until the next MSG_MOVE_* heartbeat. + if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && + block.guid != playerGuid) { + unitMoveFlagsCallback_(block.guid, block.moveFlags); + } // Query quest giver status for NPCs with questgiver flag (0x02) if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 694a61cd..b736391e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -904,6 +904,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*float pitchRate =*/ packet.readFloat(); block.runSpeed = runSpeed; + block.moveFlags = moveFlags; // Spline data if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED From 48d21f97bd1a4585865276c22608c105decd9f13 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:16:40 -0700 Subject: [PATCH 63/67] net: correct pitch condition to use FLYING=0x01000000 not SPLINE_ELEVATION=0x02000000 The previous fix added SWIMMING (0x00200000) correctly but kept 0x02000000 which is SPLINE_ELEVATION (smooth vertical spline offset), not the FLYING flag. WotLK 3.3.5a FLYING = 0x01000000; pitch should be read when SWIMMING or FLYING are active. This corrects the condition and updates the comment. --- src/game/world_packets.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index b736391e..d47c568d 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -865,13 +865,18 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } // Swimming/flying pitch - // WotLK 3.3.5a flags: SWIMMING=0x00200000, FLYING=0x02000000 - // Pitch is present when SWIMMING or FLYING are set, or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING. - // The original check (0x02000000 only) missed SWIMMING, causing misaligned reads for - // swimming NPCs/players — all subsequent fields (fallTime, jumpData, splineElevation) - // would be read from the wrong offsets. + // WotLK 3.3.5a movement flags relevant here: + // SWIMMING = 0x00200000 + // FLYING = 0x01000000 (player/creature actively flying) + // SPLINE_ELEVATION = 0x02000000 (smooth vertical spline offset — no pitch field) + // MovementFlags2: + // MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING = 0x0010 + // + // Pitch is present when SWIMMING or FLYING are set, or the always-allow flag is set. + // The original code checked 0x02000000 (SPLINE_ELEVATION) which neither covers SWIMMING + // nor FLYING, causing misaligned reads for swimming/flying entities in SMSG_UPDATE_OBJECT. if ((moveFlags & 0x00200000) /* SWIMMING */ || - (moveFlags & 0x02000000) /* FLYING(WotLK) */ || + (moveFlags & 0x01000000) /* FLYING */ || (moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) { /*float pitch =*/ packet.readFloat(); } From ec665bae25c4cb93d50184fa212a47ef17e6d443 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:21:13 -0700 Subject: [PATCH 64/67] net: store moveFlags in UpdateBlock for Classic and TBC parsers Extends the cold-join fix (block.moveFlags) to the Classic and TBC parseMovementBlock implementations so that SMSG_UPDATE_OBJECT CREATE packets on Classic/TBC servers also initialise entity swim/walk state from the spawn-time movement flags via unitMoveFlagsCallback_. --- src/game/packet_parsers_classic.cpp | 1 + src/game/packet_parsers_tbc.cpp | 1 + 2 files changed, 2 insertions(+) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 03e0c5a0..33d39b77 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -103,6 +103,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo /*float turnRate =*/ packet.readFloat(); block.runSpeed = runSpeed; + block.moveFlags = moveFlags; // Spline data (Classic: SPLINE_ENABLED=0x00400000) if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) { diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ffc7d3cd..d4cad578 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -116,6 +116,7 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& /*float turnRate =*/ packet.readFloat(); block.runSpeed = runSpeed; + block.moveFlags = moveFlags; // Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000) if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) { From d96a87aafc651ab69bd6dc7751e5bef2d2515d39 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:24:15 -0700 Subject: [PATCH 65/67] net: dispatch MSG_MOVE_SET_WALK_MODE and MSG_MOVE_SET_RUN_MODE through handleOtherPlayerMovement These two opcodes were defined in the opcode table but never routed to handleOtherPlayerMovement. The server sends them when another player explicitly toggles walk/run mode. Without dispatch, the WALKING flag from these packets was never processed, so other players appeared to always run even after switching to walk mode (until the next heartbeat). --- src/game/game_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7bb5ec6a..bd94fc0f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4473,6 +4473,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_HEARTBEAT: case Opcode::MSG_MOVE_START_SWIM: case Opcode::MSG_MOVE_STOP_SWIM: + case Opcode::MSG_MOVE_SET_WALK_MODE: + case Opcode::MSG_MOVE_SET_RUN_MODE: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } From 274419584e94e024c782cd789078e1d77e66869e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:25:58 -0700 Subject: [PATCH 66/67] net: add MSG_MOVE_SET_WALK/RUN_MODE to compressed-moves batch dispatch handleCompressedMoves uses a hardcoded opcode array to recognise which sub-packets should be routed to handleOtherPlayerMovement. The two newly dispatched opcodes were not in this list, so walk/run mode transitions embedded in SMSG_COMPRESSED_MOVES / SMSG_MULTIPLE_MOVES batches were silently dropped. --- src/game/game_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index bd94fc0f..52591c7c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12240,7 +12240,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12256,6 +12256,8 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_HEARTBEAT), wireOpcode(Opcode::MSG_MOVE_START_SWIM), wireOpcode(Opcode::MSG_MOVE_STOP_SWIM), + wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE), + wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) From 785df23f1bb9ad877827a3599ce821e7d4cdf4ba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:29:13 -0700 Subject: [PATCH 67/67] net: dispatch flying movement opcodes (pitch up/down, ascend/descend) for other players MSG_MOVE_START_PITCH_UP, MSG_MOVE_START_PITCH_DOWN, MSG_MOVE_STOP_PITCH, MSG_MOVE_START_ASCEND, MSG_MOVE_STOP_ASCEND were defined in the opcode table but never routed. The server relays these when another player pitches or ascends/descends while flying. Without dispatch, position updates embedded in these packets were silently dropped, causing flying players to appear to not move vertically. Also adds these to the compressed-moves opcode recognition array. --- src/game/game_handler.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 52591c7c..747a557d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4475,6 +4475,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_STOP_SWIM: case Opcode::MSG_MOVE_SET_WALK_MODE: case Opcode::MSG_MOVE_SET_RUN_MODE: + case Opcode::MSG_MOVE_START_PITCH_UP: + case Opcode::MSG_MOVE_START_PITCH_DOWN: + case Opcode::MSG_MOVE_STOP_PITCH: + case Opcode::MSG_MOVE_START_ASCEND: + case Opcode::MSG_MOVE_STOP_ASCEND: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } @@ -12240,7 +12245,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12258,6 +12263,11 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_STOP_SWIM), wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE), wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE), + wireOpcode(Opcode::MSG_MOVE_START_PITCH_UP), + wireOpcode(Opcode::MSG_MOVE_START_PITCH_DOWN), + wireOpcode(Opcode::MSG_MOVE_STOP_PITCH), + wireOpcode(Opcode::MSG_MOVE_START_ASCEND), + wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam)