diff --git a/docs/status.md b/docs/status.md index 06722c2f..991d813d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Project Status -**Last updated**: 2026-03-11 +**Last updated**: 2026-03-18 ## What This Repo Is @@ -35,9 +35,9 @@ Implemented (working in normal use): In progress / known gaps: - Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain -- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects) +- Visual edge cases: some M2/WMO rendering gaps (some particle effects) - Lava steam particles: sparse in some areas (tuning opportunity) -- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs); currently requires FSR to be active +- Water refraction: enabled by default; srcAccessMask barrier fix (2026-03-18) resolved prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs ## Where To Look diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e75fedb5..47997040 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -375,6 +375,10 @@ public: std::shared_ptr getFocus() const; bool hasFocus() const { return focusGuid != 0; } + // Mouseover targeting — set each frame by the nameplate renderer + void setMouseoverGuid(uint64_t guid) { mouseoverGuid_ = guid; } + uint64_t getMouseoverGuid() const { return mouseoverGuid_; } + // Advanced targeting void targetLastTarget(); void targetEnemy(bool reverse = false); @@ -742,6 +746,8 @@ public: } // Send CMSG_PET_ACTION to issue a pet command void sendPetAction(uint32_t action, uint64_t targetGuid = 0); + // Toggle autocast for a pet spell via CMSG_PET_SPELL_AUTOCAST + void togglePetSpellAutocast(uint32_t spellId); const std::unordered_set& getKnownSpells() const { return knownSpells; } // ---- Pet Stable ---- @@ -803,6 +809,9 @@ public: int getCraftQueueRemaining() const { return craftQueueRemaining_; } uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; } + // 400ms spell-queue window: next spell to cast when current finishes + uint32_t getQueuedSpellId() const { return queuedSpellId_; } + // Unit cast state (tracked per GUID for target frame + boss frames) struct UnitCastState { bool casting = false; @@ -885,6 +894,10 @@ public: const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); + // Client-side macro text storage (server sends only macro index; text is stored locally) + const std::string& getMacroText(uint32_t macroId) const; + void setMacroText(uint32_t macroId, const std::string& text); + void saveCharacterConfig(); void loadCharacterConfig(); static std::string getCharacterConfigDir(); @@ -931,6 +944,10 @@ public: using SpellCastAnimCallback = std::function; void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } + // Fired when the player's own spell cast fails (spellId of the failed spell). + using SpellCastFailedCallback = std::function; + void setSpellCastFailedCallback(SpellCastFailedCallback cb) { spellCastFailedCallback_ = std::move(cb); } + // Unit animation hint: signal jump (animId=38) for other players/NPCs using UnitAnimHintCallback = std::function; void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); } @@ -1171,6 +1188,10 @@ public: bool isPlayerGhost() const { return releasedSpirit_; } bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } + /** True when SMSG_PRE_RESURRECT arrived — Reincarnation/Twisting Nether available. */ + bool canSelfRes() const { return selfResAvailable_; } + /** Send CMSG_SELF_RES to use Reincarnation / Twisting Nether. */ + void useSelfRes(); const std::string& getResurrectCasterName() const { return resurrectCasterName_; } bool showTalentWipeConfirmDialog() const { return talentWipePending_; } uint32_t getTalentWipeCost() const { return talentWipeCost_; } @@ -1183,6 +1204,8 @@ public: void cancelPetUnlearn() { petUnlearnPending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; + /** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */ + float getCorpseReclaimDelaySec() const; /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ float getCorpseDistance() const { if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; @@ -1878,6 +1901,7 @@ public: bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } + bool isHostileFactionPublic(uint32_t factionTemplateId) const { return isHostileFaction(factionTemplateId); } float getServerRunSpeed() const { return serverRunSpeed_; } float getServerWalkSpeed() const { return serverWalkSpeed_; } float getServerSwimSpeed() const { return serverSwimSpeed_; } @@ -1983,6 +2007,9 @@ public: void autoEquipItemInBag(int bagIndex, int slotIndex); void useItemBySlot(int backpackIndex); void useItemInBag(int bagIndex, int slotIndex); + // CMSG_OPEN_ITEM — for locked containers (lockboxes); server checks keyring automatically + void openItemBySlot(int backpackIndex); + void openItemInBag(int bagIndex, int slotIndex); void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1); void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot); void swapBagSlots(int srcBagIndex, int dstBagIndex); @@ -2110,6 +2137,22 @@ public: if (index < 0 || index >= static_cast(backpackSlotGuids_.size())) return 0; return backpackSlotGuids_[index]; } + uint64_t getEquipSlotGuid(int slot) const { + if (slot < 0 || slot >= static_cast(equipSlotGuids_.size())) return 0; + return equipSlotGuids_[slot]; + } + // Returns the permanent and temporary enchant IDs for an item by GUID (0 if unknown). + std::pair getItemEnchantIds(uint64_t guid) const { + auto it = onlineItems_.find(guid); + if (it == onlineItems_.end()) return {0, 0}; + return {it->second.permanentEnchantId, it->second.temporaryEnchantId}; + } + // Returns the socket gem enchant IDs (3 slots; 0 = empty socket) for an item by GUID. + std::array getItemSocketEnchantIds(uint64_t guid) const { + auto it = onlineItems_.find(guid); + if (it == onlineItems_.end()) return {}; + return it->second.socketEnchantIds; + } uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; } /** @@ -2526,6 +2569,7 @@ private: uint64_t targetGuid = 0; uint64_t focusGuid = 0; // Focus target uint64_t lastTargetGuid = 0; // Previous target + uint64_t mouseoverGuid_ = 0; // Set each frame by nameplate renderer std::vector tabCycleList; int tabCycleIndex = -1; bool tabCycleStale = true; @@ -2605,10 +2649,21 @@ private: uint32_t stackCount = 1; uint32_t curDurability = 0; uint32_t maxDurability = 0; + uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting) + uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons) + std::array socketEnchantIds{}; // ITEM_ENCHANTMENT_SLOT 2-4 (gems) }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; std::unordered_set pendingItemQueries_; + + // Deferred SMSG_ITEM_PUSH_RESULT notifications for items whose info wasn't + // cached at arrival time; emitted once the query response arrives. + struct PendingItemPushNotif { + uint32_t itemId = 0; + uint32_t count = 1; + }; + std::vector pendingItemPushNotifs_; std::array equipSlotGuids_{}; std::array backpackSlotGuids_{}; std::array keyringSlotGuids_{}; @@ -2719,6 +2774,9 @@ private: // Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes uint32_t craftQueueSpellId_ = 0; int craftQueueRemaining_ = 0; + // Spell queue: next spell to cast within the 400ms window before current cast ends + uint32_t queuedSpellId_ = 0; + uint64_t queuedSpellTarget_ = 0; // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) std::unordered_map unitCastStates_; uint64_t pendingGameObjectInteractGuid_ = 0; @@ -2750,6 +2808,7 @@ private: float castTimeTotal = 0.0f; std::array actionBar{}; + std::unordered_map macros_; // client-side macro text (persisted in char config) std::vector playerAuras; std::vector targetAuras; std::unordered_map> unitAurasCache_; // per-unit aura cache @@ -3262,6 +3321,7 @@ private: MeleeSwingCallback meleeSwingCallback_; uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing SpellCastAnimCallback spellCastAnimCallback_; + SpellCastFailedCallback spellCastFailedCallback_; UnitAnimHintCallback unitAnimHintCallback_; UnitMoveFlagsCallback unitMoveFlagsCallback_; NpcSwingCallback npcSwingCallback_; @@ -3298,6 +3358,9 @@ private: uint32_t corpseMapId_ = 0; float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f; uint64_t corpseGuid_ = 0; + // Absolute time (ms since epoch) when PvP corpse-reclaim delay expires. + // 0 means no active delay (reclaim allowed immediately upon proximity). + uint64_t corpseReclaimAvailableMs_ = 0; // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially std::array playerRunes_ = [] { std::array r{}; @@ -3309,6 +3372,7 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + bool selfResAvailable_ = false; // SMSG_PRE_RESURRECT received — Reincarnation/Twisting Nether // ---- Talent wipe confirm dialog ---- bool talentWipePending_ = false; uint64_t talentWipeNpcGuid_ = 0; diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index ea6f6110..cf092ac4 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -125,6 +125,10 @@ public: int findFreeBackpackSlot() const; bool addItem(const ItemDef& item); + // Sort all bag slots (backpack + equip bags) by quality desc → itemId asc → stackCount desc. + // Purely client-side: reorders the local inventory struct without server interaction. + void sortBags(); + // Test data void populateTestItems(); diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c7fc0ef4..c2aa581f 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2027,6 +2027,12 @@ public: static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0); }; +/** CMSG_OPEN_ITEM packet builder (for locked containers / lockboxes) */ +class OpenItemPacket { +public: + static network::Packet build(uint8_t bagIndex, uint8_t slotIndex); +}; + /** CMSG_AUTOEQUIP_ITEM packet builder */ class AutoEquipItemPacket { public: diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0e73c552..0054bf05 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -55,6 +55,13 @@ private: std::vector chatSentHistory_; int chatHistoryIdx_ = -1; // -1 = not browsing history + // Set to true by /stopmacro; checked in executeMacroText to halt remaining commands. + bool macroStopped_ = false; + + // Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends. + // Populated by the SpellCastFailedCallback; queried during action bar button rendering. + std::unordered_map actionFlashEndTimes_; + // Tab-completion state for slash commands std::string chatTabPrefix_; // prefix captured on first Tab press std::vector chatTabMatches_; // matching command list @@ -106,6 +113,8 @@ private: std::vector uiErrors_; bool uiErrorCallbackSet_ = false; static constexpr float kUIErrorLifetime = 2.5f; + bool castFailedCallbackSet_ = false; + static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade // Reputation change toast: brief colored slide-in below minimap struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; }; @@ -170,7 +179,7 @@ private: int pendingResIndex = 0; bool pendingShadows = true; float pendingShadowDistance = 300.0f; - bool pendingWaterRefraction = false; + bool pendingWaterRefraction = true; int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default) int pendingMasterVolume = 100; int pendingMusicVolume = 30; @@ -201,6 +210,10 @@ private: // Keybinding customization int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index bool awaitingKeyPress = false; + // Macro editor popup state + uint32_t macroEditorId_ = 0; // macro index being edited + bool macroEditorOpen_ = false; // deferred OpenPopup flag + char macroEditorBuf_[256] = {}; // edit buffer bool pendingUseOriginalSoundtrack = true; bool pendingShowActionBar2 = true; // Show second action bar above main bar float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5) @@ -274,6 +287,7 @@ private: * Send chat message */ void sendChatMessage(game::GameHandler& gameHandler); + void executeMacroText(game::GameHandler& gameHandler, const std::string& macroText); /** * Get chat type name diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index b9c30c6c..21ccdc00 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -99,7 +99,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); - void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr); + void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0); private: // Character model preview @@ -161,7 +161,7 @@ private: SlotKind kind, int backpackIndex, game::EquipSlot equipSlot, int bagIndex = -1, int bagSlotIndex = -1); - void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr); + void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0); // Held item helpers void pickupFromBackpack(game::Inventory& inv, int index); diff --git a/src/core/application.cpp b/src/core/application.cpp index 22e93abc..4ff3aae1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -6908,6 +6908,10 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, }; // --- Geosets --- + // Mirror the same group-range logic as CharacterPreview::applyEquipment to + // keep other-player rendering consistent with the local character preview. + // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, + // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). std::unordered_set geosets; // Body parts (group 0: IDs 0-99, some models use up to 27) for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); @@ -6915,8 +6919,6 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); geosets.insert(static_cast(100 + hairStyleId + 1)); geosets.insert(static_cast(200 + st.facialFeatures + 1)); - geosets.insert(401); // Body joint patches (knees) - geosets.insert(402); // Body joint patches (elbows) geosets.insert(701); // Ears geosets.insert(902); // Kneepads geosets.insert(2002); // Bare feet mesh @@ -6924,39 +6926,47 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; - // Chest/Shirt/Robe (invType 4,5,20) + // Per-group defaults — overridden below when equipment provides a geoset value. + uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves) + uint16_t geosetBoots = 502; // Bare shins (group 5, no boots) + uint16_t geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves) + uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings) + + // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 { uint32_t did = findDisplayIdByInvType({4, 5, 20}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 501 + gg1 : 501)); - + if (gg1 > 0) geosetSleeves = static_cast(801 + gg1); + // Robe kilt → leg group 13 uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); - if (gg3 > 0) geosets.insert(static_cast(1301 + gg3)); + if (gg3 > 0) geosetPants = static_cast(1301 + gg3); } - // Legs (invType 7) + // Legs (invType 7) → leg group 13 { uint32_t did = findDisplayIdByInvType({7}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { - geosets.insert(static_cast(gg1 > 0 ? 1301 + gg1 : 1301)); - } + if (gg1 > 0) geosetPants = static_cast(1301 + gg1); } - // Feet (invType 8): 401/402 are body patches (always on), 403+ are boot meshes + // Feet/Boots (invType 8) → shin group 5 { uint32_t did = findDisplayIdByInvType({8}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosets.insert(static_cast(402 + gg1)); + if (gg1 > 0) geosetBoots = static_cast(501 + gg1); } - // Hands (invType 10) + // Hands/Gloves (invType 10) → forearm group 4 { uint32_t did = findDisplayIdByInvType({10}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 301 + gg1 : 301)); + if (gg1 > 0) geosetGloves = static_cast(401 + gg1); } + geosets.insert(geosetGloves); + geosets.insert(geosetBoots); + geosets.insert(geosetSleeves); + geosets.insert(geosetPants); // Back/Cloak (invType 16) geosets.insert(hasInvType({16}) ? 1502 : 1501); // Tabard (invType 19) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2eacb363..3591d97a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1956,22 +1956,22 @@ void GameHandler::handlePacket(network::Packet& packet) { queryItemInfo(itemId, 0); if (showInChat) { - std::string itemName = "item #" + std::to_string(itemId); - uint32_t quality = 1; // white default if (const ItemQueryResponseData* info = getItemInfo(itemId)) { - if (!info->name.empty()) itemName = info->name; - quality = info->quality; - } - std::string link = buildItemLink(itemId, quality, itemName); - std::string msg = "Received: " + link; - if (count > 1) msg += " x" + std::to_string(count); - addSystemChatMessage(msg); - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* sfx = renderer->getUiSoundManager()) - sfx->playLootItem(); - } - if (itemLootCallback_) { - itemLootCallback_(itemId, count, quality, itemName); + // Item info already cached — emit immediately. + std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name; + uint32_t quality = info->quality; + std::string link = buildItemLink(itemId, quality, itemName); + std::string msg = "Received: " + link; + if (count > 1) msg += " x" + std::to_string(count); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } + if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName); + } else { + // Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE. + pendingItemPushNotifs_.push_back({itemId, count}); } } LOG_INFO("Item push: itemId=", itemId, " count=", count, @@ -2252,9 +2252,11 @@ void GameHandler::handlePacket(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; - // Cancel craft queue on cast failure + // Cancel craft queue and spell queue on cast failure craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Pass player's power type so result 85 says "Not enough rage/energy/etc." int playerPowerType = -1; if (auto pe = entityManager.getEntity(playerGuid)) { @@ -2265,6 +2267,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::string errMsg = reason ? reason : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addUIError(errMsg); + if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; @@ -2645,25 +2648,31 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CORPSE_RECLAIM_DELAY: { - // uint32 delayMs before player can reclaim corpse + // uint32 delayMs before player can reclaim corpse (PvP deaths) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t delayMs = packet.readUInt32(); - uint32_t delaySec = (delayMs + 999) / 1000; - addSystemChatMessage("You can reclaim your corpse in " + - std::to_string(delaySec) + " seconds."); - LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + corpseReclaimAvailableMs_ = nowMs + delayMs; + LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); } break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { - // uint32 mapId + float x + float y + float z — corpse/spirit healer position + // uint32 mapId + float x + float y + float z + // This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location. + // The corpse remains at the death position (already cached when health dropped to 0, + // and updated when the corpse object arrives via SMSG_UPDATE_OBJECT). + // Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse() + // by making it check distance to the graveyard instead of the real corpse. if (packet.getSize() - packet.getReadPos() >= 16) { - corpseMapId_ = packet.readUInt32(); - corpseX_ = packet.readFloat(); - corpseY_ = packet.readFloat(); - corpseZ_ = packet.readFloat(); - LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_, - " x=", corpseX_, " y=", corpseY_, " z=", corpseZ_); + uint32_t relMapId = packet.readUInt32(); + float relX = packet.readFloat(); + float relY = packet.readFloat(); + float relZ = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, + " x=", relX, " y=", relY, " z=", relZ); } break; } @@ -3240,9 +3249,21 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; case Opcode::SMSG_ATTACKSWING_NOTSTANDING: - case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: autoAttackOutOfRange_ = false; autoAttackOutOfRangeTime_ = 0.0f; + if (autoAttackRangeWarnCooldown_ <= 0.0f) { + addSystemChatMessage("You need to stand up to fight."); + autoAttackRangeWarnCooldown_ = 1.25f; + } + break; + case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: + // Target is permanently non-attackable (critter, civilian, already dead, etc.). + // Stop the auto-attack loop so the client doesn't spam the server. + stopAutoAttack(); + if (autoAttackRangeWarnCooldown_ <= 0.0f) { + addSystemChatMessage("You can't attack that."); + autoAttackRangeWarnCooldown_ = 1.25f; + } break; case Opcode::SMSG_ATTACKERSTATEUPDATE: handleAttackerStateUpdate(packet); @@ -3347,6 +3368,10 @@ void GameHandler::handlePacket(network::Packet& packet) { castIsChannel = false; currentCastSpellId = 0; lastInteractedGoGuid_ = 0; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); @@ -4006,9 +4031,9 @@ void GameHandler::handlePacket(network::Packet& packet) { } worldStateMapId_ = packet.readUInt32(); worldStateZoneId_ = packet.readUInt32(); - // WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent + // WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format size_t remaining = packet.getSize() - packet.getReadPos(); - bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle"); + bool isWotLKFormat = isActiveExpansion("wotlk"); if (isWotLKFormat && remaining >= 6) { packet.readUInt32(); // areaId (WotLK only) } @@ -4166,7 +4191,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (delayMs == 0) break; float delaySec = delayMs / 1000.0f; if (caster == playerGuid) { - if (casting) castTimeRemaining += delaySec; + if (casting) { + castTimeRemaining += delaySec; + castTimeTotal += delaySec; // keep progress percentage correct + } } else { auto it = unitCastStates_.find(caster); if (it != unitCastStates_.end() && it->second.casting) { @@ -4411,9 +4439,10 @@ void GameHandler::handlePacket(network::Packet& packet) { ActionBarSlot slot; switch (type) { case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; - case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; - case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; - default: continue; // macro or unknown — leave as-is + case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item + case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item + case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions) + default: continue; // unknown — leave as-is } actionBar[i] = slot; } @@ -7310,8 +7339,15 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Pre-resurrect state ---- case Opcode::SMSG_PRE_RESURRECT: { - // packed GUID of the player to enter pre-resurrect - (void)UpdateObjectParser::readPackedGuid(packet); + // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. + // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), + // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. + uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (targetGuid == playerGuid || targetGuid == 0) { + selfResAvailable_ = true; + LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x", + std::hex, targetGuid, std::dec, ")"); + } break; } @@ -9077,9 +9113,14 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; targetGuid = 0; focusGuid = 0; lastTargetGuid = 0; @@ -9186,6 +9227,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementInfo.jumpXYSpeed = 0.0f; resurrectPending_ = false; resurrectRequestPending_ = false; + selfResAvailable_ = false; onTaxiFlight_ = false; taxiMountActive_ = false; taxiActivatePending_ = false; @@ -10685,6 +10727,21 @@ void GameHandler::sendMovement(Opcode opcode) { } } + // Cancel any timed (non-channeled) cast the moment the player starts moving. + // Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server. + // Turning (MSG_MOVE_START_TURN_*) is allowed while casting. + if (casting && !castIsChannel) { + const bool isPositionalMove = + opcode == Opcode::MSG_MOVE_START_FORWARD || + opcode == Opcode::MSG_MOVE_START_BACKWARD || + opcode == Opcode::MSG_MOVE_START_STRAFE_LEFT || + opcode == Opcode::MSG_MOVE_START_STRAFE_RIGHT || + opcode == Opcode::MSG_MOVE_JUMP; + if (isPositionalMove) { + cancelCast(); + } + } + // Update movement flags based on opcode switch (opcode) { case Opcode::MSG_MOVE_START_FORWARD: @@ -10978,9 +11035,11 @@ void GameHandler::forceClearTaxiAndMovementState() { vehicleId_ = 0; resurrectPending_ = false; resurrectRequestPending_ = false; + selfResAvailable_ = false; playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; @@ -11619,14 +11678,26 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); + const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) + ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; + auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); + auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); + auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end(); + auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end(); + auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); if (entryIt != block.fields.end() && entryIt->second != 0) { // Preserve existing info when doing partial updates OnlineItemInfo info = onlineItems_.count(block.guid) ? onlineItems_[block.guid] : OnlineItemInfo{}; info.entry = entryIt->second; - if (stackIt != block.fields.end()) info.stackCount = stackIt->second; - if (durIt != block.fields.end()) info.curDurability = durIt->second; - if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; + if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; + if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; + if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; + if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; + if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second; bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); onlineItems_[block.guid] = info; if (isNew) newItemCreated = true; @@ -11878,6 +11949,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem } else if (wasDead && !nowDead) { playerDead_ = false; releasedSpirit_ = false; + selfResAvailable_ = false; LOG_INFO("Player resurrected (dynamic flags)"); } } else if (entity->getType() == ObjectType::UNIT) { @@ -12159,8 +12231,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem playerDead_ = false; repopPending_ = false; resurrectPending_ = false; + selfResAvailable_ = false; corpseMapId_ = 0; // corpse reclaimed corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (ghostStateCallback_) ghostStateCallback_(false); } @@ -12208,6 +12282,15 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); + // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset + // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). + // Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12). + const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; + const uint16_t itemPermEnchField = itemEnchBase; + const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; + const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF; + const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; + const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; auto it = onlineItems_.find(block.guid); bool isItemInInventory = (it != onlineItems_.end()); @@ -12220,14 +12303,61 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem } } else if (key == itemDurField && isItemInInventory) { if (it->second.curDurability != val) { + const uint32_t prevDur = it->second.curDurability; it->second.curDurability = val; inventoryChanged = true; + // Warn once when durability drops below 20% for an equipped item. + const uint32_t maxDur = it->second.maxDurability; + if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { + // Check if this item is in an equip slot (not bag inventory). + bool isEquipped = false; + for (uint64_t slotGuid : equipSlotGuids_) { + if (slotGuid == block.guid) { isEquipped = true; break; } + } + if (isEquipped) { + std::string itemName; + const auto* info = getItemInfo(it->second.entry); + if (info) itemName = info->name; + char buf[128]; + if (!itemName.empty()) + std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str()); + else + std::snprintf(buf, sizeof(buf), "An equipped item is about to break!"); + addUIError(buf); + addSystemChatMessage(buf); + } + } } } else if (key == itemMaxDurField && isItemInInventory) { if (it->second.maxDurability != val) { it->second.maxDurability = val; inventoryChanged = true; } + } else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) { + if (it->second.permanentEnchantId != val) { + it->second.permanentEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) { + if (it->second.temporaryEnchantId != val) { + it->second.temporaryEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) { + if (it->second.socketEnchantIds[0] != val) { + it->second.socketEnchantIds[0] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) { + if (it->second.socketEnchantIds[1] != val) { + it->second.socketEnchantIds[1] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) { + if (it->second.socketEnchantIds[2] != val) { + it->second.socketEnchantIds[2] = val; + inventoryChanged = true; + } } } // Update container slot GUIDs on bag content changes @@ -13895,7 +14025,7 @@ void GameHandler::stopCasting() { socket->send(packet); } - // Reset casting state + // Reset casting state and clear any queued spell so it doesn't fire later casting = false; castIsChannel = false; currentCastSpellId = 0; @@ -13903,6 +14033,10 @@ void GameHandler::stopCasting() { lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; LOG_INFO("Cancelled spell cast"); } @@ -13916,7 +14050,12 @@ void GameHandler::releaseSpirit() { } auto packet = RepopRequestPacket::build(); socket->send(packet); - releasedSpirit_ = true; + // Do NOT set releasedSpirit_ = true here. Setting it optimistically races + // with PLAYER_FLAGS field updates that arrive before the server processes + // CMSG_REPOP_REQUEST: the PLAYER_FLAGS handler sees wasGhost=true/nowGhost=false + // and fires the "ghost cleared" path, wiping corpseMapId_/corpseGuid_. + // Let the server drive ghost state via PLAYER_FLAGS_GHOST (field update path). + selfResAvailable_ = false; // self-res window closes when spirit is released repopPending_ = true; lastRepopRequestMs_ = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); @@ -13924,26 +14063,47 @@ void GameHandler::releaseSpirit() { } bool GameHandler::canReclaimCorpse() const { - if (!releasedSpirit_ || corpseMapId_ == 0) return false; - // Only if ghost is on the same map as their corpse + // Need: ghost state + corpse object GUID (required by CMSG_RECLAIM_CORPSE) + + // corpse map known + same map + within 40 yards. + if (!releasedSpirit_ || corpseGuid_ == 0 || corpseMapId_ == 0) return false; if (currentMapId_ != corpseMapId_) return false; // movementInfo.x/y are canonical (x=north=server_y, y=west=server_x). // corpseX_/Y_ are raw server coords (x=west, y=north). - // Convert corpse to canonical before comparing. float dx = movementInfo.x - corpseY_; // canonical north - server.y float dy = movementInfo.y - corpseX_; // canonical west - server.x float dz = movementInfo.z - corpseZ_; return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } +float GameHandler::getCorpseReclaimDelaySec() const { + if (corpseReclaimAvailableMs_ == 0) return 0.0f; + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + if (nowMs >= corpseReclaimAvailableMs_) return 0.0f; + return static_cast(corpseReclaimAvailableMs_ - nowMs) / 1000.0f; +} + void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; - // Reclaim expects the corpse object guid when known; fallback to player guid. - uint64_t reclaimGuid = (corpseGuid_ != 0) ? corpseGuid_ : playerGuid; - auto packet = ReclaimCorpsePacket::build(reclaimGuid); + // CMSG_RECLAIM_CORPSE requires the corpse object's own GUID. + // Servers look up the corpse by this GUID; sending the player GUID silently fails. + if (corpseGuid_ == 0) { + LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim"); + return; + } + auto packet = ReclaimCorpsePacket::build(corpseGuid_); socket->send(packet); - LOG_INFO("Sent CMSG_RECLAIM_CORPSE for guid=0x", std::hex, reclaimGuid, std::dec, - (corpseGuid_ == 0 ? " (fallback player guid)" : "")); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec); +} + +void GameHandler::useSelfRes() { + if (!selfResAvailable_ || !socket) return; + // CMSG_SELF_RES: empty body — server confirms resurrection via SMSG_UPDATE_OBJECT. + network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES)); + socket->send(pkt); + selfResAvailable_ = false; + LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)"); } void GameHandler::activateSpiritHealer(uint64_t npcGuid) { @@ -14336,6 +14496,25 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { rebuildOnlineInventory(); maybeDetectVisibleItemLayout(); + // Flush any deferred loot notifications waiting on this item's name/quality. + for (auto it = pendingItemPushNotifs_.begin(); it != pendingItemPushNotifs_.end(); ) { + if (it->itemId == data.entry) { + std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name; + std::string link = buildItemLink(data.entry, data.quality, itemName); + std::string msg = "Received: " + link; + if (it->count > 1) msg += " x" + std::to_string(it->count); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } + if (itemLootCallback_) itemLootCallback_(data.entry, it->count, data.quality, itemName); + it = pendingItemPushNotifs_.erase(it); + } else { + ++it; + } + } + // Selectively re-emit only players whose equipment references this item entry const uint32_t resolvedEntry = data.entry; for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) { @@ -17891,7 +18070,17 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { return; } - if (casting) return; // Already casting + if (casting) { + // Spell queue: if we're within 400ms of the cast completing (and not channeling), + // store the spell so it fires automatically when the cast finishes. + if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) { + queuedSpellId_ = spellId; + queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid; + LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f, + "ms remaining)"); + } + return; + } // Hearthstone: cast spell directly (server checks item in inventory) // Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which @@ -17993,9 +18182,11 @@ void GameHandler::cancelCast() { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - // Cancel craft queue when player manually cancels cast + // Cancel craft queue and spell queue when player manually cancels cast craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; } void GameHandler::startCraftQueue(uint32_t spellId, int count) { @@ -18104,6 +18295,24 @@ void GameHandler::dismissPet() { socket->send(packet); } +void GameHandler::togglePetSpellAutocast(uint32_t spellId) { + if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return; + bool currentlyOn = petAutocastSpells_.count(spellId) != 0; + uint8_t newState = currentlyOn ? 0 : 1; + // CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1) + network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST)); + pkt.writeUInt64(petGuid_); + pkt.writeUInt32(spellId); + pkt.writeUInt8(newState); + socket->send(pkt); + // Optimistically update local state; server will confirm via SMSG_PET_SPELLS + if (newState) + petAutocastSpells_.insert(spellId); + else + petAutocastSpells_.erase(spellId); + LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState); +} + void GameHandler::renamePet(const std::string& newName) { if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return; if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars @@ -18269,6 +18478,10 @@ void GameHandler::handleCastFailed(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -18441,6 +18654,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) { if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); } + + // Spell queue: fire the next queued spell now that casting has ended + if (queuedSpellId_ != 0) { + uint32_t nextSpell = queuedSpellId_; + uint64_t nextTarget = queuedSpellTarget_; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; + LOG_INFO("Spell queue: firing queued spellId=", nextSpell); + castSpell(nextSpell, nextTarget); + } } else { if (spellCastAnimCallback_) { // End cast animation on other unit @@ -19664,14 +19887,12 @@ void GameHandler::interactWithGameObject(uint64_t guid) { void GameHandler::performGameObjectInteractionNow(uint64_t guid) { if (guid == 0) return; if (state != WorldState::IN_WORLD || !socket) return; - bool turtleMode = isActiveExpansion("turtle"); - // Rate-limit to prevent spamming the server static uint64_t lastInteractGuid = 0; static std::chrono::steady_clock::time_point lastInteractTime{}; auto now = std::chrono::steady_clock::now(); // Keep duplicate suppression, but allow quick retry clicks. - int64_t minRepeatMs = turtleMode ? 150 : 150; + constexpr int64_t minRepeatMs = 150; if (guid == lastInteractGuid && std::chrono::duration_cast(now - lastInteractTime).count() < minRepeatMs) { return; @@ -20944,6 +21165,26 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) { } } +void GameHandler::openItemBySlot(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; + if (inventory.getBackpackSlot(backpackIndex).empty()) return; + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = OpenItemPacket::build(0xFF, static_cast(23 + backpackIndex)); + LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex)); + socket->send(packet); +} + +void GameHandler::openItemInBag(int bagIndex, int slotIndex) { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return; + if (inventory.getBagSlot(bagIndex, slotIndex).empty()) return; + if (state != WorldState::IN_WORLD || !socket) return; + uint8_t wowBag = static_cast(19 + bagIndex); + auto packet = OpenItemPacket::build(wowBag, static_cast(slotIndex)); + LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex); + socket->send(packet); +} + void GameHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; LOG_DEBUG("useItemById: searching for itemId=", itemId); @@ -21963,6 +22204,14 @@ void GameHandler::handleNewWorld(network::Packet& packet) { hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true; + casting = false; + castIsChannel = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; if (socket) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK)); @@ -22021,6 +22270,10 @@ void GameHandler::handleNewWorld(network::Packet& packet) { pendingGameObjectInteractGuid_ = 0; lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready if (socket) { @@ -23345,6 +23598,21 @@ std::string GameHandler::getCharacterConfigDir() { return dir; } +static const std::string EMPTY_MACRO_TEXT; + +const std::string& GameHandler::getMacroText(uint32_t macroId) const { + auto it = macros_.find(macroId); + return (it != macros_.end()) ? it->second : EMPTY_MACRO_TEXT; +} + +void GameHandler::setMacroText(uint32_t macroId, const std::string& text) { + if (text.empty()) + macros_.erase(macroId); + else + macros_[macroId] = text; + saveCharacterConfig(); +} + void GameHandler::saveCharacterConfig() { const Character* ch = getActiveCharacter(); if (!ch || ch->name.empty()) return; @@ -23371,6 +23639,21 @@ void GameHandler::saveCharacterConfig() { out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n"; } + // Save client-side macro text (escape newlines as \n literal) + for (const auto& [id, text] : macros_) { + if (!text.empty()) { + std::string escaped; + escaped.reserve(text.size()); + for (char c : text) { + if (c == '\n') { escaped += "\\n"; } + else if (c == '\r') { /* skip CR */ } + else if (c == '\\') { escaped += "\\\\"; } + else { escaped += c; } + } + out << "macro_" << id << "_text=" << escaped << "\n"; + } + } + // Save quest log out << "quest_log_count=" << questLog_.size() << "\n"; for (size_t i = 0; i < questLog_.size(); i++) { @@ -23411,6 +23694,28 @@ void GameHandler::loadCharacterConfig() { try { savedGender = std::stoi(val); } catch (...) {} } else if (key == "use_female_model") { try { savedUseFemaleModel = std::stoi(val); } catch (...) {} + } else if (key.rfind("macro_", 0) == 0) { + // Parse macro_N_text + size_t firstUnder = 6; // length of "macro_" + size_t secondUnder = key.find('_', firstUnder); + if (secondUnder == std::string::npos) continue; + uint32_t macroId = 0; + try { macroId = static_cast(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; } + if (key.substr(secondUnder + 1) == "text" && !val.empty()) { + // Unescape \n and \\ sequences + std::string unescaped; + unescaped.reserve(val.size()); + for (size_t i = 0; i < val.size(); ++i) { + if (val[i] == '\\' && i + 1 < val.size()) { + if (val[i+1] == 'n') { unescaped += '\n'; ++i; } + else if (val[i+1] == '\\') { unescaped += '\\'; ++i; } + else { unescaped += val[i]; } + } else { + unescaped += val[i]; + } + } + macros_[macroId] = std::move(unescaped); + } } else if (key.rfind("action_bar_", 0) == 0) { // Parse action_bar_N_type or action_bar_N_id size_t firstUnderscore = 11; // length of "action_bar_" @@ -24487,27 +24792,32 @@ void GameHandler::resetTradeState() { } void GameHandler::handleTradeStatusExtended(network::Packet& packet) { - // WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format: - // uint8 isSelfState (1 = my trade window, 0 = peer's) - // uint32 tradeId - // uint32 slotCount (7: 6 normal + 1 extra for enchanting) - // Per slot (up to slotCount): - // uint8 slotIndex - // uint32 itemId - // uint32 displayId - // uint32 stackCount - // uint8 isWrapped - // uint64 giftCreatorGuid - // uint32 enchantId (and several more enchant/stat fields) - // ... (complex; we parse only the essential fields) - // uint64 coins (gold offered by the sender of this message) + // SMSG_TRADE_STATUS_EXTENDED format differs by expansion: + // + // Classic/TBC: + // uint8 isSelf + uint32 slotCount + [slots] + uint64 coins + // Per slot tail (after isWrapped): giftCreatorGuid(8) + enchants(24) + + // randomPropertyId(4) + suffixFactor(4) + durability(4) + maxDurability(4) = 48 bytes + // + // WotLK 3.3.5a adds: + // uint32 tradeId (after isSelf, before slotCount) + // Per slot: + createPlayedTime(4) at end of trail → trail = 52 bytes + // + // Minimum: isSelf(1) + [tradeId(4)] + slotCount(4) = 5 or 9 bytes + const bool isWotLK = isActiveExpansion("wotlk"); + size_t minHdr = isWotLK ? 9u : 5u; + if (packet.getSize() - packet.getReadPos() < minHdr) return; - size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 9) return; + uint8_t isSelf = packet.readUInt8(); + if (isWotLK) { + /*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field + } + uint32_t slotCount = packet.readUInt32(); - uint8_t isSelf = packet.readUInt8(); - uint32_t tradeId = packet.readUInt32(); (void)tradeId; - uint32_t slotCount= packet.readUInt32(); + // Per-slot tail bytes after isWrapped: + // Classic/TBC: giftCreatorGuid(8) + enchants(24) + stats(16) = 48 + // WotLK: same + createPlayedTime(4) = 52 + const size_t SLOT_TRAIL = isWotLK ? 52u : 48u; auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_; @@ -24521,12 +24831,6 @@ void GameHandler::handleTradeStatusExtended(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 1) { isWrapped = (packet.readUInt8() != 0); } - // AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped: - // giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12) - // + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24] - // + randomPropertyId (4) + suffixFactor (4) - // + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes - constexpr size_t SLOT_TRAIL = 52; if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); } else { diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 0d694aba..a6de6dcb 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -1,5 +1,6 @@ #include "game/inventory.hpp" #include "core/logger.hpp" +#include namespace wowee { namespace game { @@ -185,6 +186,44 @@ bool Inventory::addItem(const ItemDef& item) { return true; } +void Inventory::sortBags() { + // Collect all items from backpack and equip bags into a flat list. + std::vector items; + items.reserve(BACKPACK_SLOTS + NUM_BAG_SLOTS * MAX_BAG_SIZE); + + for (int i = 0; i < BACKPACK_SLOTS; ++i) { + if (!backpack[i].empty()) + items.push_back(backpack[i].item); + } + for (int b = 0; b < NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < bags[b].size; ++s) { + if (!bags[b].slots[s].empty()) + items.push_back(bags[b].slots[s].item); + } + } + + // Sort: quality descending → itemId ascending → stackCount descending. + std::stable_sort(items.begin(), items.end(), [](const ItemDef& a, const ItemDef& b) { + if (a.quality != b.quality) + return static_cast(a.quality) > static_cast(b.quality); + if (a.itemId != b.itemId) + return a.itemId < b.itemId; + return a.stackCount > b.stackCount; + }); + + // Write sorted items back, filling backpack first then equip bags. + int idx = 0; + int n = static_cast(items.size()); + + for (int i = 0; i < BACKPACK_SLOTS; ++i) + backpack[i].item = (idx < n) ? items[idx++] : ItemDef{}; + + for (int b = 0; b < NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < bags[b].size; ++s) + bags[b].slots[s].item = (idx < n) ? items[idx++] : ItemDef{}; + } +} + void Inventory::populateTestItems() { // Equipment { diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 8e8fbd25..c1397460 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1403,15 +1403,14 @@ 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()) { + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (packet.getReadPos() + 1 > packet.getSize()) { LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); truncatedTargets = true; break; } - (void)packet.readUInt32(); - (void)packet.readUInt8(); + (void)packet.readUInt8(); // reflectResult } if (i < storedMissLimit) { data.missTargets.push_back(m); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index aaf18ca2..e6f6d872 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3891,46 +3891,54 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { const uint8_t rawMissCount = packet.readUInt8(); if (rawMissCount > 128) { - LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ")"); + LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, + ") spell=", data.spellId, " hits=", (int)data.hitCount, + " remaining=", packet.getSize() - packet.getReadPos()); } const uint8_t storedMissLimit = std::min(rawMissCount, 128); data.missTargets.reserve(storedMissLimit); for (uint16_t i = 0; i < rawMissCount; ++i) { // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). - // REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult. + // REFLECT additionally appends uint8 reflectResult. if (!hasFullPackedGuid(packet)) { - LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount); + LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount, + " spell=", data.spellId, " hits=", (int)data.hitCount); truncatedTargets = true; break; } SpellGoMissEntry m; m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK if (packet.getSize() - packet.getReadPos() < 1) { - LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount); + LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount, + " spell=", data.spellId); truncatedTargets = true; break; } m.missType = packet.readUInt8(); - if (m.missType == 11) { - if (packet.getSize() - packet.getReadPos() < 5) { + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (packet.getSize() - packet.getReadPos() < 1) { LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); truncatedTargets = true; break; } - (void)packet.readUInt32(); - (void)packet.readUInt8(); + (void)packet.readUInt8(); // reflectResult } if (i < storedMissLimit) { data.missTargets.push_back(m); } } - if (truncatedTargets) { - packet.setReadPos(startPos); - return false; - } data.missCount = static_cast(data.missTargets.size()); + // If miss targets were truncated, salvage the successfully-parsed hit data + // rather than discarding the entire spell. The server already applied effects; + // we just need the hit list for UI feedback (combat text, health bars). + if (truncatedTargets) { + LOG_DEBUG("Spell go: salvaging ", (int)data.hitCount, " hits despite miss truncation"); + packet.setReadPos(packet.getSize()); // consume remaining bytes + return true; + } + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that // any trailing fields after the target section are not misaligned for // ground-targeted or AoE spells. Same layout as SpellStartParser. @@ -4271,6 +4279,13 @@ network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64 return packet; } +network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) { + network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + return packet; +} + network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM)); packet.writeUInt8(srcBag); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index ea815963..390ee2c5 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -4008,14 +4008,67 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran } void M2Renderer::removeInstance(uint32_t instanceId) { - for (auto it = instances.begin(); it != instances.end(); ++it) { - if (it->id == instanceId) { - destroyInstanceBones(*it); - instances.erase(it); - rebuildSpatialIndex(); - return; + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + size_t idx = idxIt->second; + if (idx >= instances.size()) return; + + auto& inst = instances[idx]; + + // Remove from spatial grid incrementally (same pattern as the move-update path) + GridCell minCell = toCell(inst.worldBoundsMin); + GridCell maxCell = toCell(inst.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + auto gIt = spatialGrid.find(GridCell{x, y, z}); + if (gIt != spatialGrid.end()) { + auto& vec = gIt->second; + vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); + } + } } } + + // Remove from dedup map + if (!inst.cachedIsGroundDetail) { + DedupKey dk{inst.modelId, + static_cast(std::round(inst.position.x * 10.0f)), + static_cast(std::round(inst.position.y * 10.0f)), + static_cast(std::round(inst.position.z * 10.0f))}; + instanceDedupMap_.erase(dk); + } + + destroyInstanceBones(inst); + + // Swap-remove: move last element to the hole and pop_back to avoid O(n) shift + instanceIndexById.erase(instanceId); + if (idx < instances.size() - 1) { + uint32_t movedId = instances.back().id; + instances[idx] = std::move(instances.back()); + instances.pop_back(); + instanceIndexById[movedId] = idx; + } else { + instances.pop_back(); + } + + // Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.) + // These are small vectors of indices that are rebuilt cheaply. + smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); + animatedInstanceIndices_.clear(); + particleOnlyInstanceIndices_.clear(); + particleInstanceIndices_.clear(); + for (size_t i = 0; i < instances.size(); i++) { + auto& ri = instances[i]; + if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i); + if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i); + if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i); + if (ri.cachedHasAnimation && !ri.cachedDisableAnimation) + animatedInstanceIndices_.push_back(i); + else if (ri.cachedHasParticleEmitters) + particleOnlyInstanceIndices_.push_back(i); + } } void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) { diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 2bbff1a3..6dd0b26f 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1142,10 +1142,14 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, }; // Color source: final render pass layout is PRESENT_SRC. + // srcAccessMask must be COLOR_ATTACHMENT_WRITE (not 0) so that GPU cache flushes + // happen before the transfer read. Using srcAccessMask=0 with BOTTOM_OF_PIPE + // causes VK_ERROR_DEVICE_LOST on strict drivers (AMD/Mali) because color writes + // are not made visible to the transfer unit before the copy begins. barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - 0, VK_ACCESS_TRANSFER_READ_BIT, - VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8cd7a6c8..f4f8cd11 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -255,6 +256,16 @@ bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabInde return (tab.typeMask & typeBit) != 0; } +// Forward declaration — defined near sendChatMessage below +static std::string firstMacroCommand(const std::string& macroText); +static std::vector allMacroCommands(const std::string& macroText); +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); +// Returns the spell/item name from #showtooltip [Name], or "__auto__" for bare +// #showtooltip (use first /cast target), or "" if no directive is present. +static std::string getMacroShowtooltipArg(const std::string& macroText); + void GameScreen::render(game::GameHandler& gameHandler) { // Set up chat bubble callback (once) if (!chatBubbleCallbackSet_) { @@ -403,6 +414,16 @@ void GameScreen::render(game::GameHandler& gameHandler) { uiErrorCallbackSet_ = true; } + // Flash the action bar button whose spell just failed (0.5 s red overlay). + if (!castFailedCallbackSet_) { + gameHandler.setSpellCastFailedCallback([this](uint32_t spellId) { + if (spellId == 0) return; + float now = static_cast(ImGui::GetTime()); + actionFlashEndTimes_[spellId] = now + kActionFlashDuration; + }); + castFailedCallbackSet_ = true; + } + // Set up reputation change toast callback (once) if (!repChangeCallbackSet_) { gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) { @@ -2590,16 +2611,21 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); static const std::vector kCmds = { - "/afk", "/away", "/cast", "/chathelp", "/clear", + "/afk", "/away", "/cancelaura", "/cancelform", "/cancelshapeshift", + "/cast", "/chathelp", "/clear", "/dance", "/do", "/dnd", "/e", "/emote", - "/cl", "/combatlog", "/equip", "/follow", "/g", "/guild", "/guildinfo", + "/cl", "/combatlog", "/dismount", "/equip", "/follow", + "/g", "/guild", "/guildinfo", "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", "/l", "/leave", "/local", "/me", - "/p", "/party", "/r", "/raid", + "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", + "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", + "/r", "/raid", "/raidwarning", "/random", "/reply", "/roll", - "/s", "/say", "/setloot", "/shout", - "/stopattack", "/stopfollow", "/t", "/time", + "/s", "/say", "/setloot", "/shout", "/sit", "/stand", + "/startattack", "/stopattack", "/stopfollow", "/stopcasting", + "/t", "/target", "/time", "/trade", "/uninvite", "/use", "/w", "/whisper", "/who", "/wts", "/wtb", "/y", "/yell", "/zone" }; @@ -2833,6 +2859,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.castSpell(bar[slotIdx].id, target); } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { gameHandler.useItemById(bar[slotIdx].id); + } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { + executeMacroText(gameHandler, gameHandler.getMacroText(bar[slotIdx].id)); } } } @@ -3871,6 +3899,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; gameHandler.sendPetAction(slotVal, targetGuid); } + // Right-click toggles autocast for castable pet spells (actionId > 6) + if (actionId > 6 && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + gameHandler.togglePetSpellAutocast(actionId); + } // Tooltip: rich spell info for pet spells, simple label for built-in commands if (ImGui::IsItemHovered()) { @@ -3892,8 +3924,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (nm.empty()) nm = "Spell #" + std::to_string(actionId); ImGui::Text("%s", nm.c_str()); } - if (autocastOn) - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); + ImGui::TextColored(autocastOn + ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "Autocast: %s (right-click to toggle)", autocastOn ? "On" : "Off"); if (petOnCd) { if (petCd >= 60.0f) ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), @@ -4370,6 +4404,27 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (ImGui::IsItemClicked()) { gameHandler.setTarget(totGuid); } + + // Compact health bar for the ToT — essential for healers tracking boss target + if (totEnt) { + auto totUnit = std::dynamic_pointer_cast(totEnt); + if (totUnit && totUnit->getMaxHealth() > 0) { + uint32_t totHp = totUnit->getHealth(); + uint32_t totMaxHp = totUnit->getMaxHealth(); + float totPct = static_cast(totHp) / static_cast(totMaxHp); + ImVec4 totBarColor = + totPct > 0.5f ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) : + totPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, totBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char totOverlay[32]; + snprintf(totOverlay, sizeof(totOverlay), "%u%%", + static_cast(totPct * 100.0f + 0.5f)); + ImGui::ProgressBar(totPct, ImVec2(-1, 10), totOverlay); + ImGui::PopStyleColor(2); + } + } } } } @@ -5208,6 +5263,61 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { } } + // Target-of-Focus: who the focus target is currently targeting + { + uint64_t fofGuid = 0; + const auto& fFields = focus->getFields(); + auto fItLo = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (fItLo != fFields.end()) { + fofGuid = fItLo->second; + auto fItHi = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (fItHi != fFields.end()) + fofGuid |= (static_cast(fItHi->second) << 32); + } + if (fofGuid != 0) { + auto fofEnt = gameHandler.getEntityManager().getEntity(fofGuid); + std::string fofName; + ImVec4 fofColor(0.7f, 0.7f, 0.7f, 1.0f); + if (fofGuid == gameHandler.getPlayerGuid()) { + fofName = "You"; + fofColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (fofEnt) { + fofName = getEntityName(fofEnt); + uint8_t fcid = entityClassId(fofEnt.get()); + if (fcid != 0) fofColor = classColorVec4(fcid); + } + if (!fofName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(fofColor, "%s", fofName.c_str()); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Focus's target: %s\nClick to target", fofName.c_str()); + if (ImGui::IsItemClicked()) + gameHandler.setTarget(fofGuid); + + // Compact health bar for target-of-focus + if (fofEnt) { + auto fofUnit = std::dynamic_pointer_cast(fofEnt); + if (fofUnit && fofUnit->getMaxHealth() > 0) { + float fofPct = static_cast(fofUnit->getHealth()) / + static_cast(fofUnit->getMaxHealth()); + ImVec4 fofBarColor = + fofPct > 0.5f ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) : + fofPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, fofBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char fofOverlay[32]; + snprintf(fofOverlay, sizeof(fofOverlay), "%u%%", + static_cast(fofPct * 100.0f + 0.5f)); + ImGui::ProgressBar(fofPct, ImVec2(-1, 10), fofOverlay); + ImGui::PopStyleColor(2); + } + } + } + } + } + // Distance to focus target { const auto& mv = gameHandler.getMovementInfo(); @@ -5229,6 +5339,339 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// Returns the first executable line of a macro text block, skipping blank lines +// and # directive lines (e.g. #showtooltip). Returns empty string if none found. +static std::string firstMacroCommand(const std::string& macroText) { + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + return line; + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + +// Collect all non-comment, non-empty lines from a macro body. +static std::vector allMacroCommands(const std::string& macroText) { + std::vector cmds; + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + cmds.push_back(std::move(line)); + if (nl == std::string::npos) break; + pos = nl + 1; + } + return cmds; +} + +// Returns the #showtooltip argument from a macro body: +// "#showtooltip Spell" → "Spell" +// "#showtooltip" → "__auto__" (derive from first /cast) +// (none) → "" +static std::string getMacroShowtooltipArg(const std::string& macroText) { + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t fs = line.find_first_not_of(" \t"); + if (fs != std::string::npos) line = line.substr(fs); + if (line.rfind("#showtooltip", 0) == 0 || line.rfind("#show", 0) == 0) { + size_t sp = line.find(' '); + if (sp != std::string::npos) { + std::string arg = line.substr(sp + 1); + size_t as = arg.find_first_not_of(" \t"); + if (as != std::string::npos) arg = arg.substr(as); + size_t ae = arg.find_last_not_of(" \t"); + if (ae != std::string::npos) arg.resize(ae + 1); + if (!arg.empty()) return arg; + } + return "__auto__"; + } + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + +// --------------------------------------------------------------------------- +// WoW macro conditional evaluator +// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell +// Returns the first matching alternative's argument, or "" if none matches. +// targetOverride is set to a specific GUID if [target=X] was in the conditions, +// or left as UINT64_MAX to mean "use the normal target". +// --------------------------------------------------------------------------- +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + targetOverride = static_cast(-1); + + auto& input = core::Input::getInstance(); + + const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || + input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || + input.isKeyPressed(SDL_SCANCODE_RCTRL); + const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || + input.isKeyPressed(SDL_SCANCODE_RALT); + const bool anyMod = shiftHeld || ctrlHeld || altHeld; + + // Split rawArg on ';' → alternatives + std::vector alts; + { + std::string cur; + for (char c : rawArg) { + if (c == ';') { alts.push_back(cur); cur.clear(); } + else cur += c; + } + alts.push_back(cur); + } + + // Evaluate a single comma-separated condition token. + // tgt is updated if a target= or @ specifier is found. + auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { + std::string c = raw; + // trim + size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; + size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); + if (c.empty()) return true; + + // @target specifiers: @player, @focus, @mouseover, @target + if (!c.empty() && c[0] == '@') { + std::string spec = c.substr(1); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + // target=X specifiers + if (c.rfind("target=", 0) == 0) { + std::string spec = c.substr(7); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + + // mod / nomod + if (c == "nomod" || c == "mod:none") return !anyMod; + if (c.rfind("mod:", 0) == 0) { + std::string mods = c.substr(4); + bool ok = true; + if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; + if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; + if (mods.find("alt") != std::string::npos && !altHeld) ok = false; + return ok; + } + + // combat / nocombat + if (c == "combat") return gameHandler.isInCombat(); + if (c == "nocombat") return !gameHandler.isInCombat(); + + // Helper to get the effective target entity + auto effTarget = [&]() -> std::shared_ptr { + if (tgt != static_cast(-1) && tgt != 0) + return gameHandler.getEntityManager().getEntity(tgt); + return gameHandler.getTarget(); + }; + + // exists / noexists + if (c == "exists") return effTarget() != nullptr; + if (c == "noexists") return effTarget() == nullptr; + + // dead / nodead + if (c == "dead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() == 0; + } + if (c == "nodead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() > 0; + } + + // help (friendly) / harm (hostile) and their no- variants + auto unitHostile = [&](const std::shared_ptr& t) -> bool { + if (!t) return false; + auto u = std::dynamic_pointer_cast(t); + return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); + }; + if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } + if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } + + // noform / nostance — player is NOT in a shapeshift/stance + if (c == "noform" || c == "nostance") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + // form:0 same as noform + if (c == "form:0" || c == "stance:0") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + + // buff:SpellName / nobuff:SpellName — check if the effective target (or player + // if no target specified) has a buff with the given name. + // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). + auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, + bool negate) -> bool { + // Determine which aura list to check: effective target or player + const std::vector* auras = nullptr; + if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { + // Check target's auras + auras = &gameHandler.getTargetAuras(); + } else { + auras = &gameHandler.getPlayerAuras(); + } + std::string nameLow = spellName; + for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); + for (const auto& a : *auras) { + if (a.isEmpty() || a.spellId == 0) continue; + // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type + bool isDebuff = (a.flags & 0x80) != 0; + if (wantDebuff ? !isDebuff : isDebuff) continue; + std::string sn = gameHandler.getSpellName(a.spellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + if (sn == nameLow) return !negate; + } + return negate; + }; + if (c.rfind("buff:", 0) == 0 && c.size() > 5) + return checkAuraByName(c.substr(5), false, false); + if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), false, true); + if (c.rfind("debuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), true, false); + if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) + return checkAuraByName(c.substr(9), true, true); + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // group (any group) / nogroup / raid + if (c == "group") return !gameHandler.getPartyData().isEmpty(); + if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); + if (c == "raid") { + const auto& pd = gameHandler.getPartyData(); + return pd.groupType >= 1; // groupType 1 = raid, 0 = party + } + + // channeling:SpellName — player is currently channeling that spell + if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { + if (!gameHandler.isChanneling()) return false; + std::string want = c.substr(11); + for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); + uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); + std::string sn = gameHandler.getSpellName(castSpellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + return sn == want; + } + if (c == "channeling") return gameHandler.isChanneling(); + if (c == "nochanneling") return !gameHandler.isChanneling(); + + // casting (any active cast or channel) + if (c == "casting") return gameHandler.isCasting(); + if (c == "nocasting") return !gameHandler.isCasting(); + + // vehicle / novehicle (WotLK) + if (c == "vehicle") return gameHandler.getVehicleId() != 0; + if (c == "novehicle") return gameHandler.getVehicleId() == 0; + + // Unknown → permissive (don't block) + return true; + }; + + for (auto& alt : alts) { + // trim + size_t fs = alt.find_first_not_of(" \t"); + if (fs == std::string::npos) continue; + alt = alt.substr(fs); + size_t ls = alt.find_last_not_of(" \t"); + if (ls != std::string::npos) alt.resize(ls + 1); + + if (!alt.empty() && alt[0] == '[') { + size_t close = alt.find(']'); + if (close == std::string::npos) continue; + std::string condStr = alt.substr(1, close - 1); + std::string argPart = alt.substr(close + 1); + // Trim argPart + size_t as = argPart.find_first_not_of(" \t"); + argPart = (as != std::string::npos) ? argPart.substr(as) : ""; + + // Evaluate comma-separated conditions + uint64_t tgt = static_cast(-1); + bool pass = true; + size_t cp = 0; + while (pass) { + size_t comma = condStr.find(',', cp); + std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); + if (!evalCond(tok, tgt)) { pass = false; break; } + if (comma == std::string::npos) break; + cp = comma + 1; + } + if (pass) { + if (tgt != static_cast(-1)) targetOverride = tgt; + return argPart; + } + } else { + // No condition block — default fallback always matches + return alt; + } + } + return {}; +} + +// Execute all non-comment lines of a macro body in sequence. +// In WoW, every line executes per click; the server enforces spell-cast limits. +// /stopmacro (with optional conditionals) halts the remaining commands early. +void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::string& macroText) { + macroStopped_ = false; + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); + chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; + sendChatMessage(gameHandler); + if (macroStopped_) break; + } + macroStopped_ = false; +} + +// /castsequence persistent state — shared across all macros using the same spell list. +// Keyed by the normalized (lowercase, comma-joined) spell sequence string. +namespace { +struct CastSeqState { + size_t index = 0; + float lastPressSec = 0.0f; + uint64_t lastTargetGuid = 0; + bool lastInCombat = false; +}; +std::unordered_map s_castSeqStates; +} // namespace + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); @@ -5278,6 +5721,29 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /stopmacro [conditions] + // Halts execution of the current macro (remaining lines are skipped). + // With a condition block, only stops if the conditions evaluate to true. + // /stopmacro → always stops + // /stopmacro [combat] → stops only while in combat + // /stopmacro [nocombat] → stops only when not in combat + if (cmdLower == "stopmacro") { + bool shouldStop = true; + if (spacePos != std::string::npos) { + std::string condArg = command.substr(spacePos + 1); + while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); + if (!condArg.empty() && condArg.front() == '[') { + // Append a sentinel action so evaluateMacroConditionals can signal a match. + uint64_t tgtOver = static_cast(-1); + std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); + shouldStop = !hit.empty(); + } + } + if (shouldStop) macroStopped_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /invite command if (cmdLower == "invite" && spacePos != std::string::npos) { std::string targetName = command.substr(spacePos + 1); @@ -5390,7 +5856,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { " /gleader /groster /ginfo /gcreate /gdisband", "Combat: /startattack /stopattack /stopcasting /cast /duel /pvp", " /forfeit /follow /stopfollow /assist", - "Items: /use /equip ", + "Items: /use /equip /equipset [name]", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", @@ -5590,6 +6056,96 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // Pet control commands (common macro use) + // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive + if (cmdLower == "petattack") { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendPetAction(5, target); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petfollow") { + gameHandler.sendPetAction(2, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petstay" || cmdLower == "pethalt") { + gameHandler.sendPetAction(3, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petpassive") { + gameHandler.sendPetAction(1, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petdefensive") { + gameHandler.sendPetAction(4, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petaggressive") { + gameHandler.sendPetAction(6, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petdismiss") { + gameHandler.dismissPet(); + chatInputBuffer[0] = '\0'; + return; + } + + // /cancelform / /cancelshapeshift — leave current shapeshift/stance + if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { + // Cancel the first permanent shapeshift aura the player has + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + // Permanent shapeshift auras have the permanent flag (0x20) set + if (aura.flags & 0x20) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer[0] = '\0'; + return; + } + + // /cancelaura — cancel a specific buff by name or ID + if (cmdLower == "cancelaura" && spacePos != std::string::npos) { + std::string auraArg = command.substr(spacePos + 1); + while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); + while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); + // Try numeric ID first + { + std::string numStr = auraArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNum = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNum) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId) gameHandler.cancelAura(spellId); + chatInputBuffer[0] = '\0'; + return; + } + } + // Name match against player auras + std::string argLow = auraArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + std::string sn = gameHandler.getSpellName(aura.spellId); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == argLow) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer[0] = '\0'; + return; + } + // /sit command if (cmdLower == "sit") { gameHandler.setStandState(1); // 1 = sit @@ -5655,7 +6211,79 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // /assist command if (cmdLower == "assist") { - gameHandler.assistTarget(); + // /assist → assist current target (use their target) + // /assist PlayerName → find PlayerName, target their target + // /assist [target=X] → evaluate conditional, target that entity's target + auto assistEntityTarget = [&](uint64_t srcGuid) { + auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); + if (!srcEnt) { gameHandler.assistTarget(); return; } + uint64_t atkGuid = 0; + const auto& flds = srcEnt->getFields(); + auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (iLo != flds.end()) { + atkGuid = iLo->second; + auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); + } + if (atkGuid != 0) { + gameHandler.setTarget(atkGuid); + } else { + std::string sn = getEntityName(srcEnt); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = (sn.empty() ? "Target" : sn) + " has no target."; + gameHandler.addLocalChatMessage(msg); + } + }; + + if (spacePos != std::string::npos) { + std::string assistArg = command.substr(spacePos + 1); + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + + // Evaluate conditionals if present + uint64_t assistOver = static_cast(-1); + if (!assistArg.empty() && assistArg.front() == '[') { + assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); + if (assistArg.empty() && assistOver == static_cast(-1)) { + chatInputBuffer[0] = '\0'; return; // no condition matched + } + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); + } + + if (assistOver != static_cast(-1) && assistOver != 0) { + assistEntityTarget(assistOver); + } else if (!assistArg.empty()) { + // Name search + std::string argLow = assistArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { + if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; + std::string nm = getEntityName(ent); + std::string nml = nm; + for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); + if (nml.find(argLow) != 0) continue; + float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) + + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); + if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } + } + if (bestGuid) assistEntityTarget(bestGuid); + else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + assistArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } else { + gameHandler.assistTarget(); + } + } else { + gameHandler.assistTarget(); + } chatInputBuffer[0] = '\0'; return; } @@ -5959,6 +6587,57 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /mark [icon] — set or clear a raid target mark on the current target. + // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull + // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) + // /mark — no arg marks with skull (icon 7) + if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { + if (!gameHandler.hasTarget()) { + game::MessageChatData noTgt; + noTgt.type = game::ChatType::SYSTEM; + noTgt.language = game::ChatLanguage::UNIVERSAL; + noTgt.message = "No target selected."; + gameHandler.addLocalChatMessage(noTgt); + chatInputBuffer[0] = '\0'; + return; + } + static const char* kMarkWords[] = { + "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" + }; + uint8_t icon = 7; // default: skull + if (spacePos != std::string::npos) { + std::string arg = command.substr(spacePos + 1); + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + std::string argLow = arg; + for (auto& c : argLow) c = static_cast(std::tolower(c)); + if (argLow == "clear" || argLow == "0" || argLow == "none") { + gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); + chatInputBuffer[0] = '\0'; + return; + } + bool found = false; + for (int mi = 0; mi < 8; ++mi) { + if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } + } + if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { + icon = static_cast(argLow[0] - '1'); + found = true; + } + if (!found) { + game::MessageChatData badArg; + badArg.type = game::ChatType::SYSTEM; + badArg.language = game::ChatLanguage::UNIVERSAL; + badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; + gameHandler.addLocalChatMessage(badArg); + chatInputBuffer[0] = '\0'; + return; + } + } + gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); + chatInputBuffer[0] = '\0'; + return; + } + // Combat and Trade commands if (cmdLower == "duel") { if (gameHandler.hasTarget()) { @@ -5996,14 +6675,29 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "startattack") { - if (gameHandler.hasTarget()) { - gameHandler.startAutoAttack(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You have no target."; - gameHandler.addLocalChatMessage(msg); + // Support macro conditionals: /startattack [harm,nodead] + bool condPass = true; + uint64_t saOverride = static_cast(-1); + if (spacePos != std::string::npos) { + std::string saArg = command.substr(spacePos + 1); + while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); + if (!saArg.empty() && saArg.front() == '[') { + std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); + condPass = !(result.empty() && saOverride == static_cast(-1)); + } + } + if (condPass) { + uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) + ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + if (atkTarget != 0) { + gameHandler.startAutoAttack(atkTarget); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You have no target."; + gameHandler.addLocalChatMessage(msg); + } } chatInputBuffer[0] = '\0'; return; @@ -6021,12 +6715,225 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) + // /equipset — list available sets in chat + if (cmdLower == "equipset") { + const auto& sets = gameHandler.getEquipmentSets(); + auto sysSay = [&](const std::string& msg) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = msg; + gameHandler.addLocalChatMessage(m); + }; + if (spacePos == std::string::npos) { + // No argument: list available sets + if (sets.empty()) { + sysSay("[System] No equipment sets saved."); + } else { + sysSay("[System] Equipment sets:"); + for (const auto& es : sets) + sysSay(" " + es.name); + } + } else { + std::string setName = command.substr(spacePos + 1); + while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); + while (!setName.empty() && setName.back() == ' ') setName.pop_back(); + // Case-insensitive prefix match + std::string setLower = setName; + std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); + const game::GameHandler::EquipmentSetInfo* found = nullptr; + for (const auto& es : sets) { + std::string nameLow = es.name; + std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); + if (nameLow == setLower || nameLow.find(setLower) == 0) { + found = &es; + break; + } + } + if (found) { + gameHandler.useEquipmentSet(found->setId); + } else { + sysSay("[System] No equipment set matching '" + setName + "'."); + } + } + chatInputBuffer[0] = '\0'; + return; + } + + // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... + // Cycles through the spell list on successive presses; resets per the reset= spec. + if (cmdLower == "castsequence" && spacePos != std::string::npos) { + std::string seqArg = command.substr(spacePos + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + + // Macro conditionals + uint64_t seqTgtOver = static_cast(-1); + if (!seqArg.empty() && seqArg.front() == '[') { + seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); + if (seqArg.empty() && seqTgtOver == static_cast(-1)) { + chatInputBuffer[0] = '\0'; return; + } + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); + } + + // Optional reset= spec (may contain slash-separated conditions: reset=5/target) + std::string resetSpec; + if (seqArg.rfind("reset=", 0) == 0) { + size_t spAfter = seqArg.find(' '); + if (spAfter != std::string::npos) { + resetSpec = seqArg.substr(6, spAfter - 6); + seqArg = seqArg.substr(spAfter + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + } + } + + // Parse comma-separated spell list + std::vector seqSpells; + { + std::string cur; + for (char c : seqArg) { + if (c == ',') { + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + cur.clear(); + } else { cur += c; } + } + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + } + if (seqSpells.empty()) { chatInputBuffer[0] = '\0'; return; } + + // Build stable key from lowercase spell list + std::string seqKey; + for (size_t k = 0; k < seqSpells.size(); ++k) { + if (k) seqKey += ','; + std::string sl = seqSpells[k]; + for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); + seqKey += sl; + } + + auto& seqState = s_castSeqStates[seqKey]; + + // Check reset conditions (slash-separated: e.g. "5/target") + float nowSec = static_cast(ImGui::GetTime()); + bool shouldReset = false; + if (!resetSpec.empty()) { + size_t rpos = 0; + while (rpos <= resetSpec.size()) { + size_t slash = resetSpec.find('/', rpos); + std::string part = (slash != std::string::npos) + ? resetSpec.substr(rpos, slash - rpos) + : resetSpec.substr(rpos); + std::string plow = part; + for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); + bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), + [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (isNum) { + float rSec = 0.0f; + try { rSec = std::stof(plow); } catch (...) {} + if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; + } else if (plow == "target") { + if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; + } else if (plow == "combat") { + if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; + } + if (slash == std::string::npos) break; + rpos = slash + 1; + } + } + if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; + + const std::string& seqSpell = seqSpells[seqState.index]; + seqState.index = (seqState.index + 1) % seqSpells.size(); + seqState.lastPressSec = nowSec; + seqState.lastTargetGuid = gameHandler.getTargetGuid(); + seqState.lastInCombat = gameHandler.isInCombat(); + + // Cast the selected spell — mirrors /cast spell lookup + std::string ssLow = seqSpell; + for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); + if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); + + uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) + ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + + // Numeric ID + if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); + bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (ssNumeric) { + uint32_t ssId = 0; + try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} + if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); + } else { + uint32_t ssBest = 0; int ssBestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl != ssLow) continue; + int sRnk = 0; + const std::string& rk = gameHandler.getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } + } + if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } + } + if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); + } + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "cast" && spacePos != std::string::npos) { std::string spellArg = command.substr(spacePos + 1); // Trim leading/trailing whitespace while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal + uint64_t castTargetOverride = static_cast(-1); + if (!spellArg.empty() && spellArg.front() == '[') { + spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); + if (spellArg.empty()) { + chatInputBuffer[0] = '\0'; + return; // No conditional matched — skip cast + } + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + } + + // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) + if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); + + // Support numeric spell ID: /cast 133 or /cast #133 + { + std::string numStr = spellArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId != 0) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(spellId, targetGuid); + } + chatInputBuffer[0] = '\0'; + return; + } + } + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" int requestedRank = -1; // -1 = highest rank std::string spellName = spellArg; @@ -6079,7 +6986,9 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (bestSpellId) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); gameHandler.castSpell(bestSpellId, targetGuid); } else { game::MessageChatData sysMsg; @@ -6094,11 +7003,65 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } - // /use — use an item from backpack/bags by name + // /use + // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, + // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) if (cmdLower == "use" && spacePos != std::string::npos) { std::string useArg = command.substr(spacePos + 1); while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + + // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem + if (!useArg.empty() && useArg.front() == '[') { + uint64_t dummy = static_cast(-1); + useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); + if (useArg.empty()) { chatInputBuffer[0] = '\0'; return; } + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + } + + // Check for bag/slot notation: two numbers separated by whitespace + { + std::istringstream iss(useArg); + int bagNum = -1, slotNum = -1; + iss >> bagNum >> slotNum; + if (!iss.fail() && slotNum >= 1) { + if (bagNum == 0) { + // Backpack: bag=0, slot 1-based → 0-based + gameHandler.useItemBySlot(slotNum - 1); + chatInputBuffer[0] = '\0'; + return; + } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { + // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) + gameHandler.useItemInBag(bagNum - 1, slotNum - 1); + chatInputBuffer[0] = '\0'; + return; + } + } + } + + // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) + { + std::string numStr = useArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) + int slotNum = 0; + try { slotNum = std::stoi(numStr); } catch (...) {} + if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { + auto eslot = static_cast(slotNum - 1); + const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); + if (!esl.empty()) + gameHandler.useItemById(esl.item.itemId); + } + chatInputBuffer[0] = '\0'; + return; + } + } + std::string useArgLower = useArg; for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); @@ -6194,17 +7157,62 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // Targeting commands if (cmdLower == "cleartarget") { - gameHandler.clearTarget(); + // Support macro conditionals: /cleartarget [dead] clears only if target is dead + bool ctCondPass = true; + if (spacePos != std::string::npos) { + std::string ctArg = command.substr(spacePos + 1); + while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); + if (!ctArg.empty() && ctArg.front() == '[') { + uint64_t ctOver = static_cast(-1); + std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); + ctCondPass = !(res.empty() && ctOver == static_cast(-1)); + } + } + if (ctCondPass) gameHandler.clearTarget(); chatInputBuffer[0] = '\0'; return; } if (cmdLower == "target" && spacePos != std::string::npos) { - // Search visible entities for name match (case-insensitive prefix) + // Search visible entities for name match (case-insensitive prefix). + // Among all matches, pick the nearest living unit to the player. + // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss std::string targetArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t targetCmdOverride = static_cast(-1); + if (!targetArg.empty() && targetArg.front() == '[') { + targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); + if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { + // No condition matched — silently skip (macro fallthrough) + chatInputBuffer[0] = '\0'; + return; + } + while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); + while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); + } + + // If conditionals resolved to a specific GUID, target it directly + if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { + gameHandler.setTarget(targetCmdOverride); + chatInputBuffer[0] = '\0'; + return; + } + + // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently + if (targetArg.empty()) { + chatInputBuffer[0] = '\0'; + return; + } + std::string targetArgLower = targetArg; for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + const float playerX = pmi.x; + const float playerY = pmi.y; + const float playerZ = pmi.z; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; std::string name; @@ -6217,8 +7225,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { std::string nameLower = name; for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); if (nameLower.find(targetArgLower) == 0) { - bestGuid = guid; - if (nameLower == targetArgLower) break; // Exact match wins + float dx = entity->getX() - playerX; + float dy = entity->getY() - playerY; + float dz = entity->getZ() - playerZ; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { + bestDist = dist; + bestGuid = guid; + } } } if (bestGuid) { @@ -6265,7 +7279,64 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "focus") { - if (gameHandler.hasTarget()) { + // /focus → set current target as focus + // /focus PlayerName → search for entity by name and set as focus + // /focus [target=X] Name → macro conditional: set focus to resolved target + if (spacePos != std::string::npos) { + std::string focusArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t focusCmdOverride = static_cast(-1); + if (!focusArg.empty() && focusArg.front() == '[') { + focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); + if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { + chatInputBuffer[0] = '\0'; + return; + } + while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); + while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); + } + + if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { + // Conditional resolved to a specific GUID (e.g. [target=mouseover]) + gameHandler.setFocus(focusCmdOverride); + } else if (!focusArg.empty()) { + // Name search — same logic as /target + std::string focusArgLower = focusArg; + for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(focusArgLower) == 0) { + float dx = entity->getX() - pmi.x; + float dy = entity->getY() - pmi.y; + float dz = entity->getZ() - pmi.z; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { bestDist = dist; bestGuid = guid; } + } + } + if (bestGuid) { + gameHandler.setFocus(bestGuid); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + focusArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } + } else if (gameHandler.hasTarget()) { gameHandler.setFocus(gameHandler.getTargetGuid()); } else { game::MessageChatData msg; @@ -7373,6 +8444,59 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); } + // Macro icon: #showtooltip [SpellName] → show that spell's icon on the button + if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !iconTex) { + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + std::string showArg = getMacroShowtooltipArg(macroText); + if (showArg.empty() || showArg == "__auto__") { + // No explicit #showtooltip arg — derive spell from first /cast line + for (const auto& cmdLine : allMacroCommands(macroText)) { + if (cmdLine.size() < 6) continue; + std::string cl = cmdLine; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + if (cl.rfind("/cast ", 0) != 0 && cl != "/cast") continue; + size_t sp2 = cmdLine.find(' '); + if (sp2 == std::string::npos) continue; + showArg = cmdLine.substr(sp2 + 1); + // Strip conditionals [...] + if (!showArg.empty() && showArg.front() == '[') { + size_t ce = showArg.find(']'); + if (ce != std::string::npos) showArg = showArg.substr(ce + 1); + } + // Take first alternative before ';' + size_t semi = showArg.find(';'); + if (semi != std::string::npos) showArg = showArg.substr(0, semi); + // Trim and strip '!' + size_t ss = showArg.find_first_not_of(" \t!"); + if (ss != std::string::npos) showArg = showArg.substr(ss); + size_t se = showArg.find_last_not_of(" \t"); + if (se != std::string::npos) showArg.resize(se + 1); + break; + } + } + // Look up the spell icon by name + if (!showArg.empty() && showArg != "__auto__") { + std::string showLower = showArg; + for (char& c : showLower) c = static_cast(std::tolower(static_cast(c))); + // Also strip "(Rank N)" suffix for matching + size_t rankParen = showLower.find('('); + if (rankParen != std::string::npos) showLower.resize(rankParen); + while (!showLower.empty() && showLower.back() == ' ') showLower.pop_back(); + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl == showLower) { + iconTex = assetMgr ? getSpellIcon(sid, assetMgr) : VK_NULL_HANDLE; + if (iconTex) break; + } + } + } + } + } + // Item-missing check: grey out item slots whose item is not in the player's inventory. const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 && barItemDef == nullptr && !onCooldown); @@ -7446,6 +8570,25 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Error-flash overlay: red fade on spell cast failure (~0.5 s). + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { + auto flashIt = actionFlashEndTimes_.find(slot.id); + if (flashIt != actionFlashEndTimes_.end()) { + float now = static_cast(ImGui::GetTime()); + float remaining = flashIt->second - now; + if (remaining > 0.0f) { + float alpha = remaining / kActionFlashDuration; // 1→0 + ImVec2 rMin = ImGui::GetItemRectMin(); + ImVec2 rMax = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddRectFilled( + rMin, rMax, + ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.1f, 0.1f, 0.55f * alpha))); + } else { + actionFlashEndTimes_.erase(flashIt); + } + } + } + bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseReleased(ImGuiMouseButton_Left); @@ -7471,6 +8614,8 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { gameHandler.castSpell(slot.id, target); } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); + } else if (slot.type == game::ActionBarSlot::MACRO) { + executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); } } @@ -7499,6 +8644,19 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Use")) { gameHandler.useItemById(slot.id); } + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::TextDisabled("Macro #%u", slot.id); + ImGui::Separator(); + if (ImGui::MenuItem("Execute")) { + executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); + } + if (ImGui::MenuItem("Edit")) { + const std::string& txt = gameHandler.getMacroText(slot.id); + strncpy(macroEditorBuf_, txt.c_str(), sizeof(macroEditorBuf_) - 1); + macroEditorBuf_[sizeof(macroEditorBuf_) - 1] = '\0'; + macroEditorId_ = slot.id; + macroEditorOpen_ = true; + } } ImGui::Separator(); if (ImGui::MenuItem("Clear Slot")) { @@ -7557,6 +8715,17 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); } ImGui::EndTooltip(); + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::BeginTooltip(); + ImGui::Text("Macro #%u", slot.id); + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted(macroText.c_str()); + } else { + ImGui::TextDisabled("(no text — right-click to Edit)"); + } + ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { ImGui::BeginTooltip(); // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) @@ -7768,6 +8937,28 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (i > 0) ImGui::SameLine(0, spacing); renderBarSlot(i, keyLabels1[i]); } + + // Macro editor modal — opened by "Edit" in action bar context menus + if (macroEditorOpen_) { + ImGui::OpenPopup("Edit Macro###MacroEdit"); + macroEditorOpen_ = false; + } + if (ImGui::BeginPopupModal("Edit Macro###MacroEdit", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Macro #%u (all lines execute; [cond] Spell; Default supported)", macroEditorId_); + ImGui::SetNextItemWidth(320.0f); + ImGui::InputTextMultiline("##MacroText", macroEditorBuf_, sizeof(macroEditorBuf_), + ImVec2(320.0f, 80.0f)); + if (ImGui::Button("Save")) { + gameHandler.setMacroText(macroEditorId_, std::string(macroEditorBuf_)); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } ImGui::End(); @@ -8666,13 +9857,32 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { } } + // Queued spell icon (right edge): the next spell queued to fire within 400ms. + uint32_t queuedId = gameHandler.getQueuedSpellId(); + VkDescriptorSet queuedTex = (queuedId != 0 && assetMgr) + ? getSpellIcon(queuedId, assetMgr) : VK_NULL_HANDLE; + + const float iconSz = 20.0f; + const float reservedRight = (queuedTex) ? (iconSz + 4.0f) : 0.0f; + if (iconTex) { // Spell icon to the left of the progress bar - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(20, 20)); + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(iconSz, iconSz)); ImGui::SameLine(0, 4); - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); } else { - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); + } + // Draw queued-spell icon on the right with a ">" arrow prefix tooltip. + if (queuedTex) { + ImGui::SameLine(0, 4); + ImGui::Image((ImTextureID)(uintptr_t)queuedTex, ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,0.8f), ImVec4(0,0,0,0)); // slightly dimmed + if (ImGui::IsItemHovered()) { + const std::string& qn = gameHandler.getSpellName(queuedId); + ImGui::SetTooltip("Queued: %s", qn.empty() ? "Unknown" : qn.c_str()); + } } ImGui::PopStyleColor(); } @@ -9578,6 +10788,9 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { void GameScreen::renderNameplates(game::GameHandler& gameHandler) { if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + // Reset mouseover each frame; we'll set it below when the cursor is over a nameplate + gameHandler.setMouseoverGuid(0); + auto* appRenderer = core::Application::getInstance().getRenderer(); if (!appRenderer) return; rendering::Camera* camera = appRenderer->getCamera(); @@ -9952,6 +11165,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { 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) { + // Track mouseover for [target=mouseover] macro conditionals + gameHandler.setMouseoverGuid(guid); if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { gameHandler.setTarget(guid); } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { @@ -10285,6 +11500,9 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { gameHandler.setTarget(m.guid); } + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(m.guid); + } if (ImGui::BeginPopupContextItem("RaidMemberCtx")) { ImGui::TextDisabled("%s", m.name.c_str()); ImGui::Separator(); @@ -10395,6 +11613,10 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + // Set mouseover for [target=mouseover] macro conditionals + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(member.guid); + } // Zone tooltip on name hover if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); @@ -15364,8 +16586,10 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); // "Release Spirit" dialog centered on screen + const bool hasSelfRes = gameHandler.canSelfRes(); float dlgW = 280.0f; - float dlgH = 130.0f; + // Extra height when self-res button is available; +20 for the "wait for res" hint + float dlgH = hasSelfRes ? 190.0f : 150.0f; ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); @@ -15384,13 +16608,13 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::SetCursorPosX((dlgW - textW) / 2); ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "%s", deathText); - // Respawn timer: show how long until forced release + // Respawn timer: show how long until the server auto-releases the spirit float timeLeft = kForcedReleaseSec - deathElapsed_; if (timeLeft > 0.0f) { int mins = static_cast(timeLeft) / 60; int secs = static_cast(timeLeft) % 60; char timerBuf[48]; - snprintf(timerBuf, sizeof(timerBuf), "Release in %d:%02d", mins, secs); + snprintf(timerBuf, sizeof(timerBuf), "Auto-release in %d:%02d", mins, secs); float tw = ImGui::CalcTextSize(timerBuf).x; ImGui::SetCursorPosX((dlgW - tw) / 2); ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "%s", timerBuf); @@ -15399,6 +16623,19 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::Spacing(); + // Self-resurrection button (Reincarnation / Twisting Nether / Deathpact) + if (hasSelfRes) { + float btnW2 = 220.0f; + ImGui::SetCursorPosX((dlgW - btnW2) / 2); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.55f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.5f, 0.75f, 1.0f)); + if (ImGui::Button("Use Self-Resurrection", ImVec2(btnW2, 30))) { + gameHandler.useSelfRes(); + } + ImGui::PopStyleColor(2); + ImGui::Spacing(); + } + // Center the Release Spirit button float btnW = 180.0f; ImGui::SetCursorPosX((dlgW - btnW) / 2); @@ -15408,6 +16645,12 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { gameHandler.releaseSpirit(); } ImGui::PopStyleColor(2); + + // Hint: player can stay dead and wait for another player to cast Resurrection + const char* resHint = "Or wait for a player to resurrect you."; + float hw = ImGui::CalcTextSize(resHint).x; + ImGui::SetCursorPosX((dlgW - hw) / 2); + ImGui::TextColored(ImVec4(0.5f, 0.6f, 0.5f, 0.85f), "%s", resHint); } ImGui::End(); ImGui::PopStyleColor(2); @@ -15421,28 +16664,48 @@ void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float delaySec = gameHandler.getCorpseReclaimDelaySec(); + bool onDelay = (delaySec > 0.0f); + float btnW = 220.0f, btnH = 36.0f; + float winH = btnH + 16.0f + (onDelay ? 20.0f : 0.0f); ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, winH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); if (ImGui::Begin("##ReclaimCorpse", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus)) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); - if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { - gameHandler.reclaimCorpse(); - } - ImGui::PopStyleColor(2); - float corpDist = gameHandler.getCorpseDistance(); - if (corpDist >= 0.0f) { - char distBuf[48]; - snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); - float dw = ImGui::CalcTextSize(distBuf).x; - ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); - ImGui::TextDisabled("%s", distBuf); + if (onDelay) { + // Greyed-out button while PvP reclaim timer ticks down + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::BeginDisabled(true); + char delayLabel[64]; + snprintf(delayLabel, sizeof(delayLabel), "Resurrect from Corpse (%.0fs)", delaySec); + ImGui::Button(delayLabel, ImVec2(btnW, btnH)); + ImGui::EndDisabled(); + ImGui::PopStyleColor(2); + const char* waitMsg = "You cannot reclaim your corpse yet."; + float tw = ImGui::CalcTextSize(waitMsg).x; + ImGui::SetCursorPosX((btnW + 16.0f - tw) * 0.5f); + ImGui::TextColored(ImVec4(0.8f, 0.5f, 0.2f, 1.0f), "%s", waitMsg); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); + if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { + gameHandler.reclaimCorpse(); + } + ImGui::PopStyleColor(2); + float corpDist = gameHandler.getCorpseDistance(); + if (corpDist >= 0.0f) { + char distBuf[48]; + snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); + float dw = ImGui::CalcTextSize(distBuf).x; + ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); + ImGui::TextDisabled("%s", distBuf); + } } } ImGui::End(); @@ -15790,18 +17053,11 @@ void GameScreen::renderSettingsWindow() { } } { - bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled()); - if (!fsrActive && pendingWaterRefraction) { - // FSR was disabled while refraction was on — auto-disable - pendingWaterRefraction = false; - if (renderer) renderer->setWaterRefractionEnabled(false); - } - if (!fsrActive) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) { + if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } - if (!fsrActive) ImGui::EndDisabled(); } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 083096a7..b4e2ac89 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1019,7 +1019,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, float contentH = rows * (slotSize + 4.0f) + 10.0f; if (bagIndex < 0) { int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns; - contentH += 25.0f; // money display for backpack + contentH += 36.0f; // separator + sort button + money display contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots } float gridW = columns * (slotSize + 4.0f) + 30.0f; @@ -1094,16 +1094,29 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, } } - // Money display at bottom of backpack - if (bagIndex < 0 && moneyCopper > 0) { + // Footer for backpack: sort button + money display + if (bagIndex < 0) { ImGui::Spacing(); - uint64_t gold = moneyCopper / 10000; - uint64_t silver = (moneyCopper / 100) % 100; - uint64_t copper = moneyCopper % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", - static_cast(gold), - static_cast(silver), - static_cast(copper)); + ImGui::Separator(); + + // Sort Bags button — client-side reorder by quality/type + if (ImGui::SmallButton("Sort Bags")) { + inventory.sortBags(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Sort all bag slots by quality (highest first),\nthen by item ID, then by stack size."); + } + + if (moneyCopper > 0) { + ImGui::SameLine(); + uint64_t gold = moneyCopper / 10000; + uint64_t silver = (moneyCopper / 100) % 100; + uint64_t copper = moneyCopper % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", + static_cast(gold), + static_cast(silver), + static_cast(copper)); + } } ImGui::End(); @@ -2343,7 +2356,14 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemBySlot(backpackIndex); } else { - gameHandler_->useItemBySlot(backpackIndex); + // itemClass==1 (Container) with inventoryType==0 means a lockbox; + // use CMSG_OPEN_ITEM so the server checks keyring automatically. + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info && info->valid && info->itemClass == 1) { + gameHandler_->openItemBySlot(backpackIndex); + } else { + gameHandler_->useItemBySlot(backpackIndex); + } } } else if (kind == SlotKind::BACKPACK && isBagSlot) { LOG_INFO("Right-click bag item: name='", item.name, @@ -2356,7 +2376,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex); } else { - gameHandler_->useItemInBag(bagIndex, bagSlotIndex); + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info && info->valid && info->itemClass == 1) { + gameHandler_->openItemInBag(bagIndex, bagSlotIndex); + } else { + gameHandler_->useItemInBag(bagIndex, bagSlotIndex); + } } } } @@ -2388,12 +2413,36 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite if (ImGui::IsItemHovered() && !holdingItem) { // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; - renderItemTooltip(item, tooltipInv); + uint64_t slotGuid = 0; + if (kind == SlotKind::EQUIPMENT && gameHandler_) + slotGuid = gameHandler_->getEquipSlotGuid(static_cast(equipSlot)); + renderItemTooltip(item, tooltipInv, slotGuid); } } } -void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory) { +void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory, uint64_t itemGuid) { + // Shared SpellItemEnchantment name lookup — used for socket gems, permanent and temp enchants. + static std::unordered_map s_enchLookupB; + static bool s_enchLookupLoadedB = false; + if (!s_enchLookupLoadedB && assetManager_) { + s_enchLookupLoadedB = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchLookupB[eid] = std::move(en); + } + } + } + ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(item.quality); @@ -2778,39 +2827,33 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, }; + // Get socket gem enchant IDs for this item (filled from item update fields) + std::array sockGems{}; + if (itemGuid != 0 && gameHandler_) + sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid); + bool hasSocket = false; for (int i = 0; i < 3; ++i) { if (qi2->socketColor[i] == 0) continue; if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } for (const auto& st : kSocketTypes) { if (qi2->socketColor[i] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); + if (sockGems[i] != 0) { + auto git = s_enchLookupB.find(sockGems[i]); + if (git != s_enchLookupB.end()) + ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str()); + else + ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]); + } else { + ImGui::TextColored(st.col, "%s", st.label); + } break; } } } if (hasSocket && qi2->socketBonus != 0) { - static std::unordered_map s_enchantNamesD; - static bool s_enchantNamesLoadedD = false; - if (!s_enchantNamesLoadedD && assetManager_) { - s_enchantNamesLoadedD = true; - auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNamesD[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNamesD.find(qi2->socketBonus); - if (enchIt != s_enchantNamesD.end()) + auto enchIt = s_enchLookupB.find(qi2->socketBonus); + if (enchIt != s_enchLookupB.end()) ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); else ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", qi2->socketBonus); @@ -2902,6 +2945,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Weapon/armor enchant display for equipped items (reads from item update fields) + if (itemGuid != 0 && gameHandler_) { + auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); + if (permId != 0) { + auto it2 = s_enchLookupB.find(permId); + const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchLookupB.find(tempId); + const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); + } + } + // "Begins a Quest" line (shown in yellow-green like the game) if (item.startQuestId != 0) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); @@ -3054,7 +3112,28 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // --------------------------------------------------------------------------- // Tooltip overload for ItemQueryResponseData (used by loot window, etc.) // --------------------------------------------------------------------------- -void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) { +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory, uint64_t itemGuid) { + // Shared SpellItemEnchantment name lookup — used for socket gems, socket bonus, and enchants. + static std::unordered_map s_enchLookup; + static bool s_enchLookupLoaded = false; + if (!s_enchLookupLoaded && assetManager_) { + s_enchLookupLoaded = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchLookup[eid] = std::move(en); + } + } + } + ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(static_cast(info.quality)); @@ -3389,46 +3468,54 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, }; + // Get socket gem enchant IDs for this item (filled from item update fields) + std::array sockGems{}; + if (itemGuid != 0 && gameHandler_) + sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid); + bool hasSocket = false; for (int i = 0; i < 3; ++i) { if (info.socketColor[i] == 0) continue; if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } for (const auto& st : kSocketTypes) { if (info.socketColor[i] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); + if (sockGems[i] != 0) { + auto git = s_enchLookup.find(sockGems[i]); + if (git != s_enchLookup.end()) + ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str()); + else + ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]); + } else { + ImGui::TextColored(st.col, "%s", st.label); + } break; } } } if (hasSocket && info.socketBonus != 0) { - // Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc - static std::unordered_map s_enchantNames; - static bool s_enchantNamesLoaded = false; - if (!s_enchantNamesLoaded && assetManager_) { - s_enchantNamesLoaded = true; - auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNames[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNames.find(info.socketBonus); - if (enchIt != s_enchantNames.end()) + auto enchIt = s_enchLookup.find(info.socketBonus); + if (enchIt != s_enchLookup.end()) ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); else ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus); } } + // Weapon/armor enchant display for equipped items + if (itemGuid != 0 && gameHandler_) { + auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); + if (permId != 0) { + auto it2 = s_enchLookup.find(permId); + const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchLookup.find(tempId); + const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); + } + } + // Item set membership if (info.itemSetId != 0) { // Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds)