diff --git a/.gitignore b/.gitignore index 4ddf59ab..013f805b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,10 @@ node_modules/ # Python cache artifacts tools/__pycache__/ *.pyc + +# artifacts +.codex-loop/ + +# Local agent instructions +AGENTS.md +codex-loop.sh diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1f04eb22..7ce07326 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2369,6 +2369,7 @@ private: void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0, uint64_t srcGuid = 0, uint64_t dstGuid = 0); + bool shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId); void addSystemChatMessage(const std::string& message); /** @@ -2575,7 +2576,15 @@ private: std::unordered_set hostileAttackers_; std::vector combatText; static constexpr size_t MAX_COMBAT_LOG = 500; + struct RecentSpellstealLogEntry { + uint64_t casterGuid = 0; + uint64_t victimGuid = 0; + uint32_t spellId = 0; + std::chrono::steady_clock::time_point timestamp{}; + }; + static constexpr size_t MAX_RECENT_SPELLSTEAL_LOGS = 32; std::deque combatLog_; + std::deque recentSpellstealLogs_; std::deque areaTriggerMsgs_; // unitGuid → sorted threat list (descending by threat value) std::unordered_map> threatLists_; diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index a3944a0e..ffaf6bb2 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,16 +51,16 @@ struct ActionBarSlot { struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, - CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER, - DISPEL, INTERRUPT + EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, + ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, + DISPEL, STEAL, INTERRUPT, INSTAKILL }; Type type; int32_t amount = 0; uint32_t spellId = 0; float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this - uint8_t powerType = 0; // For ENERGIZE: 0=mana,1=rage,2=focus,3=energy,6=runicpower + uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index d864b57e..f29eecb7 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1858,7 +1858,7 @@ public: /** SMSG_SPELL_GO data (simplified) */ struct SpellGoMissEntry { uint64_t targetGuid = 0; - uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST + uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST 11=REFLECT }; struct SpellGoData { diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp index 154a4f98..654729b3 100644 --- a/include/rendering/vk_context.hpp +++ b/include/rendering/vk_context.hpp @@ -57,6 +57,15 @@ public: void pollUploadBatches(); // Check completed async uploads, free staging buffers void waitAllUploads(); // Block until all in-flight uploads complete + // Defer resource destruction until it is safe with multiple frames in flight. + // + // This queues work to run after the fence for the *current frame slot* has + // signaled the next time we enter beginFrame() for that slot (i.e. after + // MAX_FRAMES_IN_FLIGHT submissions). Use this for resources that may still + // be referenced by command buffers submitted in the previous frame(s), + // such as descriptor sets and buffers freed during streaming/unload. + void deferAfterFrameFence(std::function&& fn); + // Accessors VkInstance getInstance() const { return instance; } VkPhysicalDevice getPhysicalDevice() const { return physicalDevice; } @@ -173,6 +182,9 @@ private: }; std::vector inFlightBatches_; + void runDeferredCleanup(uint32_t frameIndex); + std::vector> deferredCleanup_[MAX_FRAMES_IN_FLIGHT]; + // Depth buffer (shared across all framebuffers) VkImage depthImage = VK_NULL_HANDLE; VkImageView depthImageView = VK_NULL_HANDLE; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 13e6171b..efaee286 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -100,6 +100,22 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { raw[0] == 'n' || raw[0] == 'N'); } +bool hasFullPackedGuid(const network::Packet& packet) { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; +} + std::string formatCopperAmount(uint32_t amount) { uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; @@ -123,6 +139,40 @@ std::string formatCopperAmount(uint32_t amount) { return oss.str(); } +std::string displaySpellName(GameHandler& handler, uint32_t spellId) { + if (spellId == 0) return {}; + const std::string& name = handler.getSpellName(spellId); + if (!name.empty()) return name; + return "spell " + std::to_string(spellId); +} + +std::string formatSpellNameList(GameHandler& handler, + const std::vector& spellIds, + size_t maxShown = 3) { + if (spellIds.empty()) return {}; + + const size_t shownCount = std::min(spellIds.size(), maxShown); + std::ostringstream oss; + for (size_t i = 0; i < shownCount; ++i) { + if (i > 0) { + if (shownCount == 2) { + oss << " and "; + } else if (i == shownCount - 1) { + oss << ", and "; + } else { + oss << ", "; + } + } + oss << displaySpellName(handler, spellIds[i]); + } + + if (spellIds.size() > shownCount) { + oss << ", and " << (spellIds.size() - shownCount) << " more"; + } + + return oss.str(); +} + bool readCStringAt(const std::vector& data, size_t start, std::string& out, size_t& nextPos) { out.clear(); if (start >= data.size()) return false; @@ -781,6 +831,13 @@ void GameHandler::update(float deltaTime) { it->timer -= deltaTime; if (it->timer <= 0.0f) { if (state == WorldState::IN_WORLD && socket) { + // Avoid sending CMSG_LOOT while a timed cast is active (e.g. gathering). + // handleSpellGo will trigger loot after the cast completes. + if (casting && currentCastSpellId != 0) { + it->timer = 0.20f; + ++it; + continue; + } lootTarget(it->guid); } it = pendingGameObjectLootOpens_.erase(it); @@ -2052,22 +2109,31 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { - // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + ... - // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + ... - const bool prTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + ... + // TBC: uint64 caster + uint64 victim + uint32 spellId + ... + const bool prUsesFullGuid = isActiveExpansion("tbc"); auto readPrGuid = [&]() -> uint64_t { - if (prTbcLike) + if (prUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) + || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t caster = readPrGuid(); - if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) + || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); - if (victim == playerGuid) + if (victim == playerGuid) { addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); + } else if (caster == playerGuid) { + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); + } packet.setReadPos(packet.getSize()); break; } @@ -2643,33 +2709,40 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { // All expansions: uint32 spellId first. - // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count - // + count × (packed_guid victim + uint8 missInfo) - // [missInfo==11(REFLECT): + uint32 reflectSpellId + uint8 reflectResult] - // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count - // + count × (uint64 victim + uint8 missInfo) - const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic: spellId(4) + packed_guid caster + uint8 unk + uint32 count + // + count × (packed_guid victim + uint8 missInfo) + // TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count + // + count × (uint64 victim + uint8 missInfo) + // All expansions append uint32 reflectSpellId + uint8 reflectResult when + // missInfo==11 (REFLECT). + const bool spellMissUsesFullGuid = isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { - if (spellMissTbcLike) + if (spellMissUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; // spellId prefix present in all expansions if (packet.getSize() - packet.getReadPos() < 4) break; - /*uint32_t spellId =*/ packet.readUInt32(); - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + uint32_t spellId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u) + || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u) + || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult - if (missInfo == 11 && !spellMissTbcLike) { + if (missInfo == 11) { if (packet.getSize() - packet.getReadPos() >= 5) { /*uint32_t reflectSpellId =*/ packet.readUInt32(); /*uint8_t reflectResult =*/ packet.readUInt8(); @@ -2683,19 +2756,20 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::EVADE, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; + CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] + : (missInfo == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); if (casterGuid == playerGuid) { // We cast a spell and it missed the target - addCombatText(ct, 0, 0, true, 0, casterGuid, victimGuid); + addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) - addCombatText(ct, 0, 0, false, 0, casterGuid, victimGuid); + addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid); } } break; @@ -3200,12 +3274,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DISPEL_FAILED: { // WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim // [+ count × uint32 failedSpellId] - // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + // Classic: uint32 dispelSpellId + packed_guid caster + packed_guid victim // [+ count × uint32 failedSpellId] - const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // TBC: uint64 caster + uint64 victim + uint32 spellId + // [+ count × uint32 failedSpellId] + const bool dispelUsesFullGuid = isActiveExpansion("tbc"); uint32_t dispelSpellId = 0; uint64_t dispelCasterGuid = 0; - if (dispelTbcLike) { + if (dispelUsesFullGuid) { if (packet.getSize() - packet.getReadPos() < 20) break; dispelCasterGuid = packet.readUInt64(); /*uint64_t victim =*/ packet.readUInt64(); @@ -3213,9 +3289,13 @@ void GameHandler::handlePacket(network::Packet& packet) { } else { if (packet.getSize() - packet.getReadPos() < 4) break; dispelSpellId = packet.readUInt32(); - if (packet.getSize() - packet.getReadPos() < 1) break; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 1) break; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); } // Only show failure to the player who attempted the dispel @@ -3954,13 +4034,20 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; - /*uint32_t powerType =*/ packet.readUInt32(); + uint8_t powerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); - /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) - // Show as periodic damage from victim's perspective (mana drained) + float multiplier = packet.readFloat(); if (isPlayerVictim && amount > 0) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), - spellId, false, 0, casterGuid, victimGuid); + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(amount), + spellId, false, powerType, casterGuid, victimGuid); + if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(amount) * static_cast(multiplier))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), + spellId, true, powerType, casterGuid, casterGuid); + } + } } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -3975,14 +4062,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount // Classic/Vanilla: packed_guid (same as WotLK) const bool energizeTbc = isActiveExpansion("tbc"); - size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - rem = packet.getSize() - packet.getReadPos(); - if (rem < 6) { packet.setReadPos(packet.getSize()); break; } + auto readEnergizeGuid = [&]() -> uint64_t { + if (energizeTbc) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } uint32_t spellId = packet.readUInt32(); uint8_t energizePowerType = packet.readUInt8(); int32_t amount = static_cast(packet.readUInt32()); @@ -6087,26 +6182,35 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Spell combat logs (consume) ---- - case Opcode::SMSG_AURACASTLOG: - case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { - // Classic/TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) - // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) - const bool shieldClassicLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - const size_t shieldMinSz = shieldClassicLike ? 24u : 2u; + // Classic: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + schoolMask(4) + // TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) + // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) + const bool shieldTbc = isActiveExpansion("tbc"); + const bool shieldWotlkLike = !isClassicLikeExpansion() && !shieldTbc; + const auto shieldRem = [&]() { return packet.getSize() - packet.getReadPos(); }; + const size_t shieldMinSz = shieldTbc ? 24u : 2u; if (packet.getSize() - packet.getReadPos() < shieldMinSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = shieldClassicLike + if (!shieldTbc && (!hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = shieldClassicLike + if (packet.getSize() - packet.getReadPos() < (shieldTbc ? 8u : 1u) + || (!shieldTbc && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 12) { + const size_t shieldTailSize = shieldWotlkLike ? 16u : 12u; + if (shieldRem() < shieldTailSize) { packet.setReadPos(packet.getSize()); break; } uint32_t shieldSpellId = packet.readUInt32(); uint32_t damage = packet.readUInt32(); - if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) + if (shieldWotlkLike) /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect @@ -6119,18 +6223,30 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_AURACASTLOG: + case Opcode::SMSG_SPELLBREAKLOG: + // These packets are not damage-shield events. Consume them without + // synthesizing reflected damage entries or misattributing GUIDs. + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { - // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType - // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 - const bool immuneTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - const size_t minSz = immuneTbcLike ? 21u : 2u; + // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType + // TBC: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 + const bool immuneUsesFullGuid = isActiveExpansion("tbc"); + const size_t minSz = immuneUsesFullGuid ? 21u : 2u; if (packet.getSize() - packet.getReadPos() < minSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = immuneTbcLike + if (!immuneUsesFullGuid && !hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (immuneTbcLike ? 8u : 2u)) break; - uint64_t victimGuid = immuneTbcLike + if (packet.getSize() - packet.getReadPos() < (immuneUsesFullGuid ? 8u : 2u) + || (!immuneUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t immuneSpellId = packet.readUInt32(); @@ -6144,53 +6260,88 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLDISPELLOG: { - // WotLK: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen - // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + ... + // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // TBC: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) - const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) { + const bool dispelUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) + || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = dispelTbcLike + uint64_t casterGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) break; - uint64_t victimGuid = dispelTbcLike + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) + || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) break; /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); uint32_t count = packet.readUInt32(); - // Collect first dispelled spell id/name; process all entries for combat log - // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) - uint32_t firstDispelledId = 0; - std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { + // Preserve every dispelled aura in the combat log instead of collapsing + // multi-aura packets down to the first entry only. + const size_t dispelEntrySize = dispelUsesFullGuid ? 8u : 5u; + std::vector dispelledIds; + dispelledIds.reserve(count); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) { uint32_t dispelledId = packet.readUInt32(); - /*uint8_t isPositive =*/ packet.readUInt8(); - if (i == 0) { - firstDispelledId = dispelledId; - const std::string& nm = getSpellName(dispelledId); - firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + if (dispelUsesFullGuid) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPositive =*/ packet.readUInt8(); + } + if (dispelledId != 0) { + dispelledIds.push_back(dispelledId); } } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { - const char* verb = isStolen ? "stolen" : "dispelled"; - if (!firstSpellName.empty()) { + std::vector loggedIds; + if (isStolen) { + loggedIds.reserve(dispelledIds.size()); + for (uint32_t dispelledId : dispelledIds) { + if (shouldLogSpellstealAura(casterGuid, victimGuid, dispelledId)) + loggedIds.push_back(dispelledId); + } + } else { + loggedIds = dispelledIds; + } + + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; - if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); - else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); - else - std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); + const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were"; + if (isStolen) { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); + } else { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); + } addSystemChatMessage(buf); } - // Add dispel event to combat log - if (firstDispelledId != 0) { + // Preserve stolen auras as spellsteal events so the log wording stays accurate. + if (!loggedIds.empty()) { bool isPlayerCaster = (casterGuid == playerGuid); - addCombatText(CombatTextEntry::DISPEL, 0, firstDispelledId, isPlayerCaster, 0, - casterGuid, victimGuid); + for (uint32_t dispelledId : loggedIds) { + addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, + 0, dispelledId, isPlayerCaster, 0, + casterGuid, victimGuid); + } } } packet.setReadPos(packet.getSize()); @@ -6199,19 +6350,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPELLSTEALLOG: { // Sent to the CASTER (Mage) when Spellsteal succeeds. // Wire format mirrors SPELLDISPELLOG: - // WotLK: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count - // + count × (uint32 stolenSpellId + uint8 isPositive) - // TBC/Classic: full uint64 victim + full uint64 caster + same tail - const bool stealTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + // WotLK/Classic/Turtle: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count + // + count × (uint32 stolenSpellId + uint8 isPositive) + // TBC: full uint64 victim + full uint64 caster + same tail + const bool stealUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) + || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } - uint64_t stealVictim = stealTbcLike + uint64_t stealVictim = stealUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) + || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } - uint64_t stealCaster = stealTbcLike + uint64_t stealCaster = stealUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; @@ -6219,47 +6372,71 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t stealSpellId =*/ packet.readUInt32(); /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); - // Collect stolen spell info; show feedback when we are caster or victim - uint32_t firstStolenId = 0; - std::string stolenName; - for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + // Preserve every stolen aura in the combat log instead of only the first. + const size_t stealEntrySize = stealUsesFullGuid ? 8u : 5u; + std::vector stolenIds; + stolenIds.reserve(stealCount); + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) { uint32_t stolenId = packet.readUInt32(); - /*uint8_t isPos =*/ packet.readUInt8(); - if (i == 0) { - firstStolenId = stolenId; - const std::string& nm = getSpellName(stolenId); - stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; + if (stealUsesFullGuid) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPos =*/ packet.readUInt8(); + } + if (stolenId != 0) { + stolenIds.push_back(stolenId); } } if (stealCaster == playerGuid || stealVictim == playerGuid) { - if (!stolenName.empty()) { + std::vector loggedIds; + loggedIds.reserve(stolenIds.size()); + for (uint32_t stolenId : stolenIds) { + if (shouldLogSpellstealAura(stealCaster, stealVictim, stolenId)) + loggedIds.push_back(stolenId); + } + + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; if (stealCaster == playerGuid) - std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellNames.c_str()); else - std::snprintf(buf, sizeof(buf), "%s was stolen.", stolenName.c_str()); + std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), + loggedIds.size() == 1 ? "was" : "were"); addSystemChatMessage(buf); } - // Add dispel/steal to combat log using DISPEL type (isStolen=true for steals) - if (firstStolenId != 0) { + // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG + // for the same aura. Keep the first event and suppress the duplicate. + if (!loggedIds.empty()) { bool isPlayerCaster = (stealCaster == playerGuid); - addCombatText(CombatTextEntry::DISPEL, 0, firstStolenId, isPlayerCaster, 0, - stealCaster, stealVictim); + for (uint32_t stolenId : loggedIds) { + addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, + stealCaster, stealVictim); + } } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { - // Format (all expansions): PackedGuid target + PackedGuid caster + uint32 spellId + ... - if (packet.getSize() - packet.getReadPos() < 3) { + // WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ... + // TBC: uint64 target + uint64 caster + uint32 spellId + ... + const bool procChanceUsesFullGuid = isActiveExpansion("tbc"); + auto readProcChanceGuid = [&]() -> uint64_t { + if (procChanceUsesFullGuid) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) + || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } - uint64_t procTargetGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) { + uint64_t procTargetGuid = readProcChanceGuid(); + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) + || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } - uint64_t procCasterGuid = UpdateObjectParser::readPackedGuid(packet); + uint64_t procCasterGuid = readProcChanceGuid(); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } @@ -6273,24 +6450,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_SPELLINSTAKILLLOG: { // Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.) - // WotLK: packed_guid caster + packed_guid victim + uint32 spellId - // TBC/Classic: full uint64 caster + full uint64 victim + uint32 spellId - const bool ikTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + // TBC: full uint64 caster + full uint64 victim + uint32 spellId + const bool ikUsesFullGuid = isActiveExpansion("tbc"); auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t ikCaster = ikTbcLike + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) + || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t ikCaster = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t ikVictim = ikTbcLike + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) + || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t ikVictim = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; // Show kill/death feedback for the local player if (ikCaster == playerGuid) { - // We killed a target instantly — show a KILL combat text hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true, 0, ikCaster, ikVictim); + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { - // We were instantly killed — show a large incoming hit - addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false, 0, ikCaster, ikVictim); + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, @@ -6299,8 +6480,8 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLLOGEXECUTE: { - // WotLK: packed_guid caster + uint32 spellId + uint32 effectCount - // TBC/Classic: uint64 caster + uint32 spellId + uint32 effectCount + // WotLK/Classic/Turtle: packed_guid caster + uint32 spellId + uint32 effectCount + // TBC: uint64 caster + uint32 spellId + uint32 effectCount // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data // Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier // Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier @@ -6308,11 +6489,14 @@ void GameHandler::handlePacket(network::Packet& packet) { // Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id // Effect 49 = FEED_PET: uint32 itemEntry // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) - const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { + const bool exeUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t exeCaster = exeTbcLike + if (!exeUsesFullGuid && !hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t exeCaster = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; @@ -6330,44 +6514,73 @@ void GameHandler::handlePacket(network::Packet& packet) { if (effectType == 10) { // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; - uint64_t drainTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t drainTarget = exeUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint32_t drainAmount = packet.readUInt32(); uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic - /*float drainMult =*/ packet.readFloat(); + float drainMult = packet.readFloat(); if (drainAmount > 0) { if (drainTarget == playerGuid) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false, 0, + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, + static_cast(drainPower), exeCaster, drainTarget); - else if (isPlayerCaster) - addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true, - static_cast(drainPower), exeCaster, drainTarget); + if (isPlayerCaster) { + if (drainTarget != playerGuid) { + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, drainTarget); + } + if (drainMult > 0.0f && std::isfinite(drainMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(drainAmount) * static_cast(drainMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, exeCaster); + } + } + } } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, - " power=", drainPower, " amount=", drainAmount); + " power=", drainPower, " amount=", drainAmount, + " multiplier=", drainMult); } } else if (effectType == 11) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; - uint64_t leechTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t leechTarget = exeUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } uint32_t leechAmount = packet.readUInt32(); - /*float leechMult =*/ packet.readFloat(); + float leechMult = packet.readFloat(); if (leechAmount > 0) { - if (leechTarget == playerGuid) + if (leechTarget == playerGuid) { addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, exeCaster, leechTarget); - else if (isPlayerCaster) - addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true, 0, + } else if (isPlayerCaster) { + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, true, 0, exeCaster, leechTarget); + } + if (isPlayerCaster && leechMult > 0.0f && std::isfinite(leechMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(leechAmount) * static_cast(leechMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::HEAL, static_cast(gainedAmount), exeSpellId, true, 0, + exeCaster, exeCaster); + } + } } - LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, + " amount=", leechAmount, " multiplier=", leechMult); } } else if (effectType == 24 || effectType == 114) { // SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry @@ -6394,9 +6607,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } else if (effectType == 26) { // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id for (uint32_t li = 0; li < effectLogCount; ++li) { - if (packet.getSize() - packet.getReadPos() < 1) break; - uint64_t icTarget = exeTbcLike - ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t icTarget = exeUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t icSpellId = packet.readUInt32(); @@ -6844,27 +7060,33 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { - // WotLK: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId - // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... - // TBC/Classic: same but full uint64 GUIDs + // WotLK/Classic/Turtle: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId + // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... + // TBC: same layout but full uint64 GUIDs // Show RESIST combat text when player resists an incoming spell. - const bool rlTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const bool rlUsesFullGuid = isActiveExpansion("tbc"); auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } /*uint32_t hitInfo =*/ packet.readUInt32(); - if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t attackerGuid = rlTbcLike + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) + || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t attackerGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = rlTbcLike + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) + || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); - // Show RESIST when player is the victim; show as caster-side MISS when player is attacker + // Show RESIST when the player is involved on either side. if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, false, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, attackerGuid, victimGuid); } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, true, 0, attackerGuid, victimGuid); + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; @@ -14061,6 +14283,31 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint combatLog_.push_back(std::move(log)); } +bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { + if (spellId == 0) return false; + + const auto now = std::chrono::steady_clock::now(); + constexpr auto kRecentWindow = std::chrono::seconds(1); + while (!recentSpellstealLogs_.empty() && + now - recentSpellstealLogs_.front().timestamp > kRecentWindow) { + recentSpellstealLogs_.pop_front(); + } + + for (auto it = recentSpellstealLogs_.begin(); it != recentSpellstealLogs_.end(); ++it) { + if (it->casterGuid == casterGuid && + it->victimGuid == victimGuid && + it->spellId == spellId) { + recentSpellstealLogs_.erase(it); + return false; + } + } + + if (recentSpellstealLogs_.size() >= MAX_RECENT_SPELLSTEAL_LOGS) + recentSpellstealLogs_.pop_front(); + recentSpellstealLogs_.push_back({casterGuid, victimGuid, spellId, now}); + return true; +} + void GameHandler::updateCombatText(float deltaTime) { for (auto& entry : combatText) { entry.age += deltaTime; @@ -16253,14 +16500,14 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 5) { - // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). + addCombatText(CombatTextEntry::EVADE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 6) { // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 7) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); @@ -16862,23 +17109,30 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Clear unit cast bar when the spell lands (for any tracked unit) unitCastStates_.erase(data.casterUnit); - // Show miss/dodge/parry/etc combat text when player's spells miss targets - if (data.casterUnit == playerGuid && !data.missTargets.empty()) { + // Preserve spellId and actual participants for spell-go miss results. + // This keeps the persistent combat log aligned with the later GUID fixes. + if (!data.missTargets.empty()) { static const CombatTextEntry::Type missTypes[] = { CombatTextEntry::MISS, // 0=MISS CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::EVADE, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE - CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::DEFLECT, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; - // Show text for each miss (usually just 1 target per spell go) + const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; + const bool playerIsCaster = (spellCasterGuid == playerGuid); + for (const auto& m : data.missTargets) { - CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; - addCombatText(ct, 0, 0, true); + if (!playerIsCaster && m.targetGuid != playerGuid) { + continue; + } + CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] + : (m.missType == 11 ? CombatTextEntry::REFLECT : CombatTextEntry::MISS); + addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } @@ -18118,14 +18372,25 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { lower.find("coffer") != std::string::npos || lower.find("cache") != std::string::npos); } - // For WotLK, CMSG_GAMEOBJ_REPORT_USE is required for chests (and is harmless for others). - if (!isMailbox && isActiveExpansion("wotlk")) { - network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); - reportUse.writeUInt64(guid); - socket->send(reportUse); + // Some servers require CMSG_GAMEOBJ_REPORT_USE for lootable gameobjects. + // Only send it when the active opcode table actually supports it. + if (!isMailbox) { + const auto* table = getActiveOpcodeTable(); + if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); + } } if (shouldSendLoot) { lootTarget(guid); + // Some servers/scripts only make certain quest/chest GOs lootable after a short delay + // (use animation, state change). Queue one delayed loot attempt to catch that case. + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == guid; }), + pendingGameObjectLootOpens_.end()); + pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f}); } else { // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be // sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot @@ -19298,6 +19563,10 @@ void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; lootWindowOpen = true; lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }), + pendingGameObjectLootOpens_.end()); localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false}; // Query item info so loot window can show names instead of IDs diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 041af211..046839d0 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -4,6 +4,26 @@ namespace wowee { namespace game { +namespace { + +bool hasFullPackedGuid(const network::Packet& packet) { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; +} + +} // namespace + // ============================================================================ // Classic 1.12.1 movement flag constants // Key differences from TBC: @@ -421,6 +441,11 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da m.targetGuid = UpdateObjectParser::readPackedGuid(packet); if (rem() < 1) break; m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (rem() < 5) break; + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } // Check if we read all expected misses @@ -492,10 +517,10 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att // ============================================================================ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (rem() < 2) return false; + if (rem() < 2 || !hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (rem() < 1 || !hasFullPackedGuid(packet)) return false; data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla // uint32(spellId) + uint32(damage) + uint8(schoolMask) + uint32(absorbed) @@ -527,10 +552,10 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam // ============================================================================ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (rem() < 2) return false; + if (rem() < 2 || !hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (rem() < 1 || !hasFullPackedGuid(packet)) return false; data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla if (rem() < 13) return false; // uint32 + uint32 + uint32 + uint8 = 13 bytes diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 935b34ae..d218926a 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1261,7 +1261,9 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& // WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags. // ============================================================================ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { - if (packet.getSize() - packet.getReadPos() < 19) return false; + // Fixed header before hit/miss lists: + // casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + if (packet.getSize() - packet.getReadPos() < 25) return false; data.casterGuid = packet.readUInt64(); // full GUID in TBC data.casterUnit = packet.readUInt64(); // full GUID in TBC @@ -1304,6 +1306,13 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) SpellGoMissEntry m; m.targetGuid = packet.readUInt64(); // full GUID in TBC m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (packet.getReadPos() + 5 > packet.getSize()) { + break; + } + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } // Check if we read all expected misses diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index dbcbf4c9..4c0a0b1e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3632,8 +3632,9 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { } bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { - // Upfront validation: packed GUID(1-8) + packed GUID(1-8) + castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1) + missCount(1) = 24 bytes minimum - if (packet.getSize() - packet.getReadPos() < 24) return false; + // Packed GUIDs are variable-length, so only require the smallest possible + // shape up front: 2 GUID masks + fixed fields through missCount. + if (packet.getSize() - packet.getReadPos() < 17) return false; size_t startPos = packet.getReadPos(); data.casterGuid = UpdateObjectParser::readPackedGuid(packet); @@ -3660,12 +3661,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.reserve(data.hitCount); for (uint8_t i = 0; i < data.hitCount; ++i) { - if (packet.getSize() - packet.getReadPos() < 8) { + // WotLK hit targets are packed GUIDs, like the caster and miss targets. + if (packet.getSize() - packet.getReadPos() < 1) { LOG_WARNING("Spell go: truncated hit targets at index ", (int)i, "/", (int)data.hitCount); data.hitCount = i; break; } - data.hitTargets.push_back(packet.readUInt64()); + data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); } // Validate missCount field exists @@ -3682,7 +3684,8 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.reserve(data.missCount); for (uint8_t i = 0; i < data.missCount; ++i) { - // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte), validate before reading + // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). + // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. if (packet.getSize() - packet.getReadPos() < 2) { LOG_WARNING("Spell go: truncated miss targets at index ", (int)i, "/", (int)data.missCount); data.missCount = i; @@ -3691,6 +3694,15 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK m.missType = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + if (m.missType == 11) { + if (packet.getSize() - packet.getReadPos() < 5) { + LOG_WARNING("Spell go: truncated reflect payload at miss index ", (int)i, "/", (int)data.missCount); + data.missCount = i; + break; + } + (void)packet.readUInt32(); + (void)packet.readUInt8(); + } data.missTargets.push_back(m); } diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index a5a76ab2..d6119e74 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -461,7 +461,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Turn left/right"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Strafe left/right"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 75ca41c9..a8b518a2 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -990,42 +990,64 @@ void TerrainRenderer::clear() { } chunks.clear(); renderedChunks = 0; - - if (materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { + if (!vkCtx) return; + + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (chunk.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.vertexBuffer; ab.allocation = chunk.vertexAlloc; - destroyBuffer(allocator, ab); - chunk.vertexBuffer = VK_NULL_HANDLE; - } - if (chunk.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.indexBuffer; ab.allocation = chunk.indexAlloc; - destroyBuffer(allocator, ab); - chunk.indexBuffer = VK_NULL_HANDLE; - } - if (chunk.paramsUBO) { - AllocatedBuffer ab{}; ab.buffer = chunk.paramsUBO; ab.allocation = chunk.paramsAlloc; - destroyBuffer(allocator, ab); - chunk.paramsUBO = VK_NULL_HANDLE; - } - // Return material descriptor set to the pool so it can be reused by new chunks - if (chunk.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &chunk.materialSet); - } - chunk.materialSet = VK_NULL_HANDLE; + // These resources may still be referenced by in-flight command buffers from + // previous frames. Defer actual destruction until this frame slot is safe. + ::VkBuffer vertexBuffer = chunk.vertexBuffer; + VmaAllocation vertexAlloc = chunk.vertexAlloc; + ::VkBuffer indexBuffer = chunk.indexBuffer; + VmaAllocation indexAlloc = chunk.indexAlloc; + ::VkBuffer paramsUBO = chunk.paramsUBO; + VmaAllocation paramsAlloc = chunk.paramsAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = chunk.materialSet; - // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) - VkDevice device = vkCtx->getDevice(); + std::vector alphaTextures; + alphaTextures.reserve(chunk.ownedAlphaTextures.size()); for (auto& tex : chunk.ownedAlphaTextures) { - if (tex) tex->destroy(device, allocator); + alphaTextures.push_back(tex.release()); } + + chunk.vertexBuffer = VK_NULL_HANDLE; + chunk.vertexAlloc = VK_NULL_HANDLE; + chunk.indexBuffer = VK_NULL_HANDLE; + chunk.indexAlloc = VK_NULL_HANDLE; + chunk.paramsUBO = VK_NULL_HANDLE; + chunk.paramsAlloc = VK_NULL_HANDLE; + chunk.materialSet = VK_NULL_HANDLE; chunk.ownedAlphaTextures.clear(); + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + paramsUBO, paramsAlloc, pool, materialSet, alphaTextures]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (paramsUBO) { + AllocatedBuffer ab{}; ab.buffer = paramsUBO; ab.allocation = paramsAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + for (VkTexture* tex : alphaTextures) { + if (!tex) continue; + tex->destroy(device, allocator); + delete tex; + } + }); } int TerrainRenderer::getTriangleCount() const { diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index fdd07d8e..51781a3c 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -55,6 +55,11 @@ void VkContext::shutdown() { vkDeviceWaitIdle(device); } + // With the device idle, it is safe to run any deferred per-frame cleanup. + for (uint32_t fi = 0; fi < MAX_FRAMES_IN_FLIGHT; fi++) { + runDeferredCleanup(fi); + } + LOG_WARNING("VkContext::shutdown - destroyImGuiResources..."); destroyImGuiResources(); @@ -103,6 +108,19 @@ void VkContext::shutdown() { LOG_WARNING("Vulkan context shutdown complete"); } +void VkContext::deferAfterFrameFence(std::function&& fn) { + deferredCleanup_[currentFrame].push_back(std::move(fn)); +} + +void VkContext::runDeferredCleanup(uint32_t frameIndex) { + auto& q = deferredCleanup_[frameIndex]; + if (q.empty()) return; + for (auto& fn : q) { + if (fn) fn(); + } + q.clear(); +} + bool VkContext::createInstance(SDL_Window* window) { // Get required SDL extensions unsigned int sdlExtCount = 0; @@ -1349,6 +1367,9 @@ VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { return VK_NULL_HANDLE; } + // Any work queued for this frame slot is now guaranteed to be unused by the GPU. + runDeferredCleanup(currentFrame); + // Acquire next swapchain image VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, frame.imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex); diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index d79e53f7..2bbff1a3 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1029,10 +1029,6 @@ void WaterRenderer::clear() { destroyWaterMesh(surface); } surfaces.clear(); - - if (vkCtx && materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } // ============================================================== @@ -1358,27 +1354,45 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { void WaterRenderer::destroyWaterMesh(WaterSurface& surface) { if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (surface.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.vertexBuffer; ab.allocation = surface.vertexAlloc; - destroyBuffer(allocator, ab); - surface.vertexBuffer = VK_NULL_HANDLE; - } - if (surface.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.indexBuffer; ab.allocation = surface.indexAlloc; - destroyBuffer(allocator, ab); - surface.indexBuffer = VK_NULL_HANDLE; - } - if (surface.materialUBO) { - AllocatedBuffer ab{}; ab.buffer = surface.materialUBO; ab.allocation = surface.materialAlloc; - destroyBuffer(allocator, ab); - surface.materialUBO = VK_NULL_HANDLE; - } - if (surface.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &surface.materialSet); - } + ::VkBuffer vertexBuffer = surface.vertexBuffer; + VmaAllocation vertexAlloc = surface.vertexAlloc; + ::VkBuffer indexBuffer = surface.indexBuffer; + VmaAllocation indexAlloc = surface.indexAlloc; + ::VkBuffer materialUBO = surface.materialUBO; + VmaAllocation materialAlloc = surface.materialAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = surface.materialSet; + + surface.vertexBuffer = VK_NULL_HANDLE; + surface.vertexAlloc = VK_NULL_HANDLE; + surface.indexBuffer = VK_NULL_HANDLE; + surface.indexAlloc = VK_NULL_HANDLE; + surface.materialUBO = VK_NULL_HANDLE; + surface.materialAlloc = VK_NULL_HANDLE; surface.materialSet = VK_NULL_HANDLE; + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + materialUBO, materialAlloc, pool, materialSet]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (materialUBO) { + AllocatedBuffer ab{}; ab.buffer = materialUBO; ab.allocation = materialAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + }); } // ============================================================== diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2f30ee64..f9108e1a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2275,9 +2275,25 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto& io = ImGui::GetIO(); auto& input = core::Input::getInstance(); + // If the user is typing (or about to focus chat this frame), do not allow + // A-Z or 1-0 shortcuts to fire. + if (!io.WantTextInput && !chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { + refocusChatInput = true; + chatInputBuffer[0] = '/'; + chatInputBuffer[1] = '\0'; + chatInputMoveCursorToEnd = true; + } + if (!io.WantTextInput && !chatInputActive && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { + refocusChatInput = true; + } + + const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput || io.WantCaptureKeyboard; + // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { - if (input.isKeyJustPressed(SDL_SCANCODE_TAB)) { + // When typing in chat (or any text input), never treat keys as gameplay/UI shortcuts. + if (!textFocus && input.isKeyJustPressed(SDL_SCANCODE_TAB)) { const auto& movement = gameHandler.getMovementInfo(); gameHandler.tabTarget(movement.x, movement.y, movement.z); } @@ -2300,88 +2316,76 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } - // Toggle character screen (C) and inventory/bags (I) - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { - inventoryScreen.toggleCharacter(); - } + if (!textFocus) { + // Toggle character screen (C) and inventory/bags (I) + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { + inventoryScreen.toggleCharacter(); + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { - inventoryScreen.toggle(); - } - - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { - if (inventoryScreen.isSeparateBags()) { - inventoryScreen.openAllBags(); - } else { + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { inventoryScreen.toggle(); } - } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { - showNameplates_ = !showNameplates_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { + if (inventoryScreen.isSeparateBags()) { + inventoryScreen.openAllBags(); + } else { + inventoryScreen.toggle(); + } + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { - showWorldMap_ = !showWorldMap_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { + showNameplates_ = !showNameplates_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { - showMinimap_ = !showMinimap_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { + showWorldMap_ = !showWorldMap_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { - showRaidFrames_ = !showRaidFrames_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { + showMinimap_ = !showMinimap_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { - questLogScreen.toggle(); - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { + showRaidFrames_ = !showRaidFrames_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { - showAchievementWindow_ = !showAchievementWindow_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { + questLogScreen.toggle(); + } - // Toggle Titles window with H (hero/title screen — no conflicting keybinding) - if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { - showTitlesWindow_ = !showTitlesWindow_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { + showAchievementWindow_ = !showAchievementWindow_; + } + // Toggle Titles window with H (hero/title screen — no conflicting keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + showTitlesWindow_ = !showTitlesWindow_; + } - // Action bar keys (1-9, 0, -, =) - static const SDL_Scancode actionBarKeys[] = { - SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, - SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, - SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS - }; - 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])) { - 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[slotIdx].id, target); - } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { - gameHandler.useItemById(bar[slotIdx].id); + // Action bar keys (1-9, 0, -, =) + static const SDL_Scancode actionBarKeys[] = { + SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, + SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, + SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS + }; + 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])) { + 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[slotIdx].id, target); + } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { + gameHandler.useItemById(bar[slotIdx].id); + } } } } } - // Slash key: focus chat input — always works unless already typing in chat - if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { - refocusChatInput = true; - chatInputBuffer[0] = '/'; - chatInputBuffer[1] = '\0'; - chatInputMoveCursorToEnd = true; - } - - // Enter key: focus chat input (empty) — always works unless already typing - if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { - refocusChatInput = true; - } - // Cursor affordance: show hand cursor over interactable game objects. if (!io.WantCaptureMouse) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -2534,6 +2538,25 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto* camera = renderer ? renderer->getCamera() : nullptr; auto* window = core::Application::getInstance().getWindow(); if (camera && window) { + // If a quest objective gameobject is under the cursor, prefer it over + // hostile units so quest pickups (e.g. "Bundle of Wood") are reliable. + std::unordered_set questObjectiveGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + questObjectiveGoEntries.reserve(32); + for (const auto& q : ql) { + if (q.complete) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId >= 0 || obj.required == 0) continue; + uint32_t entry = static_cast(-obj.npcOrGoId); + uint32_t cur = 0; + auto it = q.killCounts.find(entry); + if (it != q.killCounts.end()) cur = it->second.first; + if (cur < obj.required) questObjectiveGoEntries.insert(entry); + } + } + } + glm::vec2 mousePos = input.getMousePosition(); float screenW = static_cast(window->getWidth()); float screenH = static_cast(window->getHeight()); @@ -2543,13 +2566,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { game::ObjectType closestType = game::ObjectType::OBJECT; float closestHostileUnitT = 1e30f; uint64_t closestHostileUnitGuid = 0; + float closestQuestGoT = 1e30f; + uint64_t closestQuestGoGuid = 0; const uint64_t myGuid = gameHandler.getPlayerGuid(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER && - t != game::ObjectType::GAMEOBJECT) continue; + t != game::ObjectType::GAMEOBJECT) + continue; if (guid == myGuid) continue; + glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); @@ -2564,10 +2591,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } else if (t == game::ObjectType::GAMEOBJECT) { // For GOs with no renderer instance yet, use a tight fallback - // sphere (not 2.5f) so invisible/unloaded GOs (chairs, doodads) - // are not accidentally clicked during camera right-drag. + // sphere so invisible/unloaded doodads aren't accidentally clicked. hitRadius = 1.2f; heightOffset = 1.0f; + // Quest objective GOs should be easier to click. + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + hitRadius = 2.2f; + heightOffset = 1.2f; + } } hitCenter = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); @@ -2575,6 +2607,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else { hitRadius = std::max(hitRadius * 1.1f, 0.6f); } + float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) { if (t == game::ObjectType::UNIT) { @@ -2585,6 +2618,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { closestHostileUnitGuid = guid; } } + if (t == game::ObjectType::GAMEOBJECT && !questObjectiveGoEntries.empty()) { + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + if (hitT < closestQuestGoT) { + closestQuestGoT = hitT; + closestQuestGoGuid = guid; + } + } + } if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -2592,11 +2634,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } } - // Prefer hostile monsters over nearby gameobjects/others when right-click picking. - if (closestHostileUnitGuid != 0) { + + // Prefer quest objective GOs over hostile monsters when both are hittable. + if (closestQuestGoGuid != 0) { + closestGuid = closestQuestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else if (closestHostileUnitGuid != 0) { + // Prefer hostile monsters over nearby gameobjects/others when right-click picking. closestGuid = closestHostileUnitGuid; closestType = game::ObjectType::UNIT; } + if (closestGuid != 0) { if (closestType == game::ObjectType::GAMEOBJECT) { gameHandler.setTarget(closestGuid); @@ -8342,6 +8390,11 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; case game::CombatTextEntry::PERIODIC_DAMAGE: snprintf(text, sizeof(text), "-%d", entry.amount); color = outgoing ? @@ -8366,6 +8419,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue } break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; + } + break; case game::CombatTextEntry::XP_GAIN: snprintf(text, sizeof(text), "+%d XP", entry.amount); color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP @@ -8388,6 +8451,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; case game::CombatTextEntry::PROC_TRIGGER: { const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; if (!procName.empty()) @@ -8397,6 +8470,44 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc break; } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else + snprintf(text, sizeof(text), "Dispel"); + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -11161,7 +11272,9 @@ void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Guild Roster toggle (customizable keybind) - if (!ImGui::GetIO().WantCaptureKeyboard && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + !ImGui::GetIO().WantCaptureKeyboard && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { // Open friends tab directly if not in guild @@ -19480,7 +19593,8 @@ void GameScreen::renderZoneText() { // --------------------------------------------------------------------------- void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // Toggle Dungeon Finder (customizable keybind) - if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { showDungeonFinder_ = !showDungeonFinder_; } @@ -20214,6 +20328,13 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); break; + case T::EVADE: + if (spell) + snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; case T::IMMUNE: if (spell) snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); @@ -20243,6 +20364,20 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "Resisted"); color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); break; + case T::DEFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src); + color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f); + break; + case T::REFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; case T::ENVIRONMENTAL: snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); @@ -20254,6 +20389,13 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; + case T::POWER_DRAIN: + if (spell) + snprintf(desc, sizeof(desc), "%s loses %d power to %s's %s", tgt, e.amount, src, spell); + else + snprintf(desc, sizeof(desc), "%s loses %d power", tgt, e.amount); + color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); + break; case T::XP_GAIN: snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); @@ -20276,6 +20418,17 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); break; + case T::STEAL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; case T::INTERRUPT: if (spell && e.isPlayerSource) snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); @@ -20287,6 +20440,17 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s interrupted", tgt); color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); break; + case T::INSTAKILL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s", tgt); + else + snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); + color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; default: snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index fbe70e33..ca2a6cd5 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -5,6 +5,11 @@ namespace wowee::ui { +static bool isReservedMovementKey(ImGuiKey key) { + return key == ImGuiKey_W || key == ImGuiKey_A || key == ImGuiKey_S || + key == ImGuiKey_D || key == ImGuiKey_Q || key == ImGuiKey_E; +} + KeybindingManager& KeybindingManager::getInstance() { static KeybindingManager instance; return instance; @@ -37,7 +42,21 @@ void KeybindingManager::initializeDefaults() { bool KeybindingManager::isActionPressed(Action action, bool repeat) { auto it = bindings_.find(static_cast(action)); if (it == bindings_.end()) return false; - return ImGui::IsKeyPressed(it->second, repeat); + ImGuiKey key = it->second; + if (key == ImGuiKey_None) return false; + + // When typing in a text field (e.g. chat input), never treat A-Z or 0-9 as shortcuts. + const ImGuiIO& io = ImGui::GetIO(); + // Note: WantTextInput may not be set until the text widget is processed later in the + // frame, but WantCaptureKeyboard remains true while an ImGui widget is active. + if (io.WantTextInput || io.WantCaptureKeyboard) { + if ((key >= ImGuiKey_A && key <= ImGuiKey_Z) || + (key >= ImGuiKey_0 && key <= ImGuiKey_9)) { + return false; + } + } + + return ImGui::IsKeyPressed(key, repeat); } ImGuiKey KeybindingManager::getKeyForAction(Action action) const { @@ -47,6 +66,11 @@ ImGuiKey KeybindingManager::getKeyForAction(Action action) const { } void KeybindingManager::setKeyForAction(Action action, ImGuiKey key) { + // Reserve movement keys so they cannot be used as UI shortcuts. + (void)action; + if (isReservedMovementKey(key)) { + key = ImGuiKey_None; + } bindings_[static_cast(action)] = key; } @@ -175,9 +199,14 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { } } - if (key != ImGuiKey_None) { - bindings_[actionIdx] = key; + if (key == ImGuiKey_None) continue; + + // Reserve movement keys so they cannot be used as UI shortcuts. + if (isReservedMovementKey(key)) { + continue; } + + bindings_[actionIdx] = key; } file.close();