diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 5c550c81..e5d0793f 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 117, + "ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, "DispelType": 4 @@ -95,5 +95,14 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 26ac235e..da2fb9a5 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 124, + "ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 124, "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215, "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40, "DispelType": 3 @@ -111,5 +111,14 @@ "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, "Threshold8": 46, "Threshold9": 47 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index c5a3948e..cb44c54a 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 117, + "ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, "DispelType": 4 @@ -108,5 +108,14 @@ "Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33, "Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37, "Threshold8": 38, "Threshold9": 39 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 73d50a87..4ecbfc32 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,6 +1,6 @@ { "Spell": { - "ID": 0, "Attributes": 4, "IconID": 133, + "ID": 0, "Attributes": 4, "AttributesEx": 5, "IconID": 133, "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49, "DispelType": 2 @@ -116,5 +116,14 @@ }, "LFGDungeons": { "ID": 0, "Name": 1 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e8762167..e75fedb5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -497,6 +497,15 @@ public: return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_; } + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + struct BgPlayerPosition { + uint64_t guid = 0; + float wowX = 0.0f; // canonical WoW X (north) + float wowY = 0.0f; // canonical WoW Y (west) + int group = 0; // 0 = first list (usually ally flag carriers), 1 = second list + }; + const std::vector& getBgPlayerPositions() const { return bgPlayerPositions_; } + // Network latency (milliseconds, updated each PONG response) uint32_t getLatencyMs() const { return lastLatency; } @@ -709,6 +718,8 @@ public: void dismissPet(); void renamePet(const std::string& newName); bool hasPet() const { return petGuid_ != 0; } + // Returns true once after SMSG_PET_RENAMEABLE; consuming the flag clears it. + bool consumePetRenameablePending() { bool v = petRenameablePending_; petRenameablePending_ = false; return v; } uint64_t getPetGuid() const { return petGuid_; } // ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ---- @@ -798,6 +809,7 @@ public: uint32_t spellId = 0; float timeRemaining = 0.0f; float timeTotal = 0.0f; + bool interruptible = true; ///< false when SPELL_ATTR_EX_NOT_INTERRUPTIBLE is set }; // Returns cast state for any unit by GUID (empty/non-casting if not found) const UnitCastState* getUnitCastState(uint64_t guid) const { @@ -819,6 +831,10 @@ public: auto* s = getUnitCastState(targetGuid); return s ? s->timeRemaining : 0.0f; } + bool isTargetCastInterruptible() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->interruptible : true; + } // Talents uint8_t getActiveTalentSpec() const { return activeTalentSpec_; } @@ -1160,6 +1176,11 @@ public: uint32_t getTalentWipeCost() const { return talentWipeCost_; } void confirmTalentWipe(); void cancelTalentWipe() { talentWipePending_ = false; } + // Pet talent respec confirm + bool showPetUnlearnDialog() const { return petUnlearnPending_; } + uint32_t getPetUnlearnCost() const { return petUnlearnCost_; } + void confirmPetUnlearn(); + void cancelPetUnlearn() { petUnlearnPending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ @@ -1389,6 +1410,10 @@ public: const LootResponseData& getCurrentLoot() const { return currentLoot; } void setAutoLoot(bool enabled) { autoLoot_ = enabled; } bool isAutoLoot() const { return autoLoot_; } + void setAutoSellGrey(bool enabled) { autoSellGrey_ = enabled; } + bool isAutoSellGrey() const { return autoSellGrey_; } + void setAutoRepair(bool enabled) { autoRepair_ = enabled; } + bool isAutoRepair() const { return autoRepair_; } // Master loot candidates (from SMSG_LOOT_MASTER_LIST) const std::vector& getMasterLootCandidates() const { return masterLootCandidates_; } @@ -1435,6 +1460,9 @@ public: void acceptQuest(); void declineQuest(); void closeGossip(); + // Quest-starting items: right-click triggers quest offer dialog via questgiver protocol + void offerQuestFromItem(uint64_t itemGuid, uint32_t questId); + uint64_t getBagItemGuid(int bagIndex, int slotIndex) const; bool isGossipWindowOpen() const { return gossipWindowOpen; } const GossipMessageData& getCurrentGossip() const { return currentGossip; } bool isQuestDetailsOpen() { @@ -1919,6 +1947,11 @@ public: float x = 0, y = 0, z = 0; }; const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } + bool isKnownTaxiNode(uint32_t nodeId) const { + if (nodeId == 0 || nodeId > 384) return false; + uint32_t idx = nodeId - 1; + return (knownTaxiMask_[idx / 32] & (1u << (idx % 32))) != 0; + } uint32_t getTaxiCostTo(uint32_t destNodeId) const; bool taxiNpcHasRoutes(uint64_t guid) const { auto it = taxiNpcHasRoutes_.find(guid); @@ -2051,6 +2084,13 @@ public: const std::string& getSkillLineName(uint32_t spellId) const; /// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other) uint8_t getSpellDispelType(uint32_t spellId) const; + /// Returns true if the spell can be interrupted by abilities like Kick/Counterspell. + /// False for spells with SPELL_ATTR_EX_NOT_INTERRUPTIBLE (attrEx bit 4 = 0x10). + bool isSpellInterruptible(uint32_t spellId) const; + /// Returns the school bitmask for the spell from Spell.dbc + /// (0x01=Physical, 0x02=Holy, 0x04=Fire, 0x08=Nature, 0x10=Frost, 0x20=Shadow, 0x40=Arcane). + /// Returns 0 if unknown. + uint32_t getSpellSchoolMask(uint32_t spellId) const; struct TrainerTab { std::string name; @@ -2717,6 +2757,7 @@ private: uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss uint8_t petReact_ = 1; // 0=passive,1=defensive,2=aggressive + bool petRenameablePending_ = false; // set by SMSG_PET_RENAMEABLE, consumed by UI std::vector petSpellList_; // known pet spells std::unordered_set petAutocastSpells_; // spells with autocast on @@ -2759,6 +2800,9 @@ private: // BG scoreboard (MSG_PVP_LOG_DATA) BgScoreboardData bgScoreboard_; + // BG flag carrier / player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + std::vector bgPlayerPositions_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot @@ -2862,6 +2906,8 @@ private: // ---- Phase 5: Loot ---- bool lootWindowOpen = false; bool autoLoot_ = false; + bool autoSellGrey_ = false; + bool autoRepair_ = false; LootResponseData currentLoot; std::vector masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST @@ -3054,7 +3100,7 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; }; + struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; @@ -3267,6 +3313,10 @@ private: bool talentWipePending_ = false; uint64_t talentWipeNpcGuid_ = 0; uint32_t talentWipeCost_ = 0; + // ---- Pet talent respec confirm dialog ---- + bool petUnlearnPending_ = false; + uint64_t petUnlearnGuid_ = 0; + uint32_t petUnlearnCost_ = 0; bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST uint64_t resurrectCasterGuid_ = 0; std::string resurrectCasterName_; diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 10c4a5cd..67d94c2d 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -53,7 +53,7 @@ struct CombatTextEntry { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, - DISPEL, STEAL, INTERRUPT, INSTAKILL + DISPEL, STEAL, INTERRUPT, INSTAKILL, HONOR_GAIN, GLANCING, CRUSHING }; Type type; int32_t amount = 0; diff --git a/include/game/warden_emulator.hpp b/include/game/warden_emulator.hpp index c3dbc37c..30a0759f 100644 --- a/include/game/warden_emulator.hpp +++ b/include/game/warden_emulator.hpp @@ -147,9 +147,18 @@ private: uint32_t heapSize_; // Heap size uint32_t apiStubBase_; // API stub base address - // API hooks: DLL name -> Function name -> Handler + // API hooks: DLL name -> Function name -> stub address std::map> apiAddresses_; + // API stub dispatch: stub address -> {argCount, handler} + struct ApiHookEntry { + int argCount; + std::function&)> handler; + }; + std::map apiHandlers_; + uint32_t nextApiStubAddr_; // tracks next free stub slot (replaces static local) + bool apiCodeHookRegistered_; // true once UC_HOOK_CODE for stub range is added + // Memory allocation tracking std::map allocations_; std::map freeBlocks_; // free-list keyed by base address diff --git a/include/game/warden_module.hpp b/include/game/warden_module.hpp index bcea989f..e11bc4f9 100644 --- a/include/game/warden_module.hpp +++ b/include/game/warden_module.hpp @@ -140,6 +140,7 @@ private: size_t relocDataOffset_ = 0; // Offset into decompressedData_ where relocation data starts WardenFuncList funcList_; // Callback functions std::unique_ptr emulator_; // Cross-platform x86 emulator + uint32_t emulatedPacketHandlerAddr_ = 0; // Raw emulated VA for 4-arg PacketHandler call // Validation and loading steps bool verifyMD5(const std::vector& data, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c2e92f06..c7fc0ef4 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1719,8 +1719,10 @@ struct AttackerStateUpdateData { uint32_t blocked = 0; bool isValid() const { return attackerGuid != 0; } - bool isCrit() const { return (hitInfo & 0x200) != 0; } - bool isMiss() const { return (hitInfo & 0x10) != 0; } + bool isCrit() const { return (hitInfo & 0x0200) != 0; } + bool isMiss() const { return (hitInfo & 0x0010) != 0; } + bool isGlancing() const { return (hitInfo & 0x0800) != 0; } + bool isCrushing() const { return (hitInfo & 0x1000) != 0; } }; class AttackerStateUpdateParser { @@ -1873,6 +1875,7 @@ struct SpellGoData { std::vector hitTargets; uint8_t missCount = 0; std::vector missTargets; + uint64_t targetGuid = 0; ///< Primary target GUID from SpellCastTargets (0 = none/AoE) bool isValid() const { return spellId != 0; } }; diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index e219e917..fae92812 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -44,6 +44,7 @@ public: } void reset(); + void resetAngles(); void teleportTo(const glm::vec3& pos); void setOnlineMode(bool online) { onlineMode = online; } diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 07c6ebd6..1f33d2f4 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -152,6 +153,11 @@ public: void playEmote(const std::string& emoteName); void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); + + // Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT) + // useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path + void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, + bool useImpactKit = false); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static uint32_t getEmoteDbcId(const std::string& emoteName); @@ -323,6 +329,19 @@ private: glm::mat4 computeLightSpaceMatrix(); pipeline::AssetManager* cachedAssetManager = nullptr; + + // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL/IMPACT + struct SpellVisualInstance { uint32_t instanceId; float elapsed; }; + std::vector activeSpellVisuals_; + std::unordered_map spellVisualCastPath_; // visualId → cast M2 path + std::unordered_map spellVisualImpactPath_; // visualId → impact M2 path + std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId + uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799 + bool spellVisualDbcLoaded_ = false; + void loadSpellVisualDbc(); + void updateSpellVisuals(float deltaTime); + static constexpr float SPELL_VISUAL_DURATION = 3.5f; + uint32_t currentZoneId = 0; std::string currentZoneName; bool inTavern_ = false; diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index e908ffaa..eedc88af 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -25,6 +25,15 @@ struct WorldMapPartyDot { std::string name; ///< Member name (shown as tooltip on hover) }; +/// Taxi (flight master) node passed from the UI layer for world map overlay. +struct WorldMapTaxiNode { + uint32_t id = 0; ///< TaxiNodes.dbc ID + uint32_t mapId = 0; ///< WoW internal map ID (0=EK,1=Kal,530=Outland,571=Northrend) + float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates + std::string name; ///< Node name (shown as tooltip) + bool known = false; ///< Player has discovered this node +}; + struct WorldMapZone { uint32_t wmaID = 0; uint32_t areaID = 0; // 0 = continent level @@ -57,6 +66,14 @@ public: void setMapName(const std::string& name); void setServerExplorationMask(const std::vector& masks, bool hasData); void setPartyDots(std::vector dots) { partyDots_ = std::move(dots); } + void setTaxiNodes(std::vector nodes) { taxiNodes_ = std::move(nodes); } + /// Set the player's corpse position for overlay rendering. + /// @param hasCorpse True when the player is a ghost with an unclaimed corpse on this map. + /// @param renderPos Corpse position in render-space coordinates. + void setCorpsePos(bool hasCorpse, glm::vec3 renderPos) { + hasCorpse_ = hasCorpse; + corpseRenderPos_ = renderPos; + } bool isOpen() const { return open; } void close() { open = false; } @@ -127,6 +144,14 @@ private: // Party member dots (set each frame from the UI layer) std::vector partyDots_; + // Taxi node markers (set each frame from the UI layer) + std::vector taxiNodes_; + int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC) + + // Corpse marker (ghost state — set each frame from the UI layer) + bool hasCorpse_ = false; + glm::vec3 corpseRenderPos_ = {}; + // Exploration / fog of war std::vector serverExplorationMask; bool hasServerExplorationMask = false; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 75502dee..0e73c552 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -195,6 +195,8 @@ private: bool pendingSeparateBags = true; bool pendingShowKeyring = true; bool pendingAutoLoot = false; + bool pendingAutoSellGrey = false; + bool pendingAutoRepair = false; // Keybinding customization int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index @@ -317,6 +319,7 @@ private: // ---- New UI renders ---- void renderActionBar(game::GameHandler& gameHandler); + void renderStanceBar(game::GameHandler& gameHandler); void renderBagBar(game::GameHandler& gameHandler); void renderXpBar(game::GameHandler& gameHandler); void renderRepBar(game::GameHandler& gameHandler); @@ -355,6 +358,7 @@ private: void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); + void renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); void applyGraphicsPreset(GraphicsPreset preset); @@ -375,7 +379,6 @@ private: void renderGuildBankWindow(game::GameHandler& gameHandler); void renderAuctionHouseWindow(game::GameHandler& gameHandler); void renderDungeonFinderWindow(game::GameHandler& gameHandler); - void renderObjectiveTracker(game::GameHandler& gameHandler); void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); @@ -435,6 +438,10 @@ private: char achievementSearchBuf_[128] = {}; void renderAchievementWindow(game::GameHandler& gameHandler); + // Skills / Professions window (K key) + bool showSkillsWindow_ = false; + void renderSkillsWindow(game::GameHandler& gameHandler); + // Titles window bool showTitlesWindow_ = false; void renderTitlesWindow(game::GameHandler& gameHandler); @@ -633,7 +640,9 @@ private: float zoneTextTimer_ = 0.0f; std::string zoneTextName_; std::string lastKnownZoneName_; - void renderZoneText(); + uint32_t lastKnownWorldStateZoneId_ = 0; + void renderZoneText(game::GameHandler& gameHandler); + void renderWeatherOverlay(game::GameHandler& gameHandler); // Cooldown tracker bool showCooldownTracker_ = false; diff --git a/include/ui/keybinding_manager.hpp b/include/ui/keybinding_manager.hpp index e242ae5d..3c67b125 100644 --- a/include/ui/keybinding_manager.hpp +++ b/include/ui/keybinding_manager.hpp @@ -30,6 +30,7 @@ public: TOGGLE_NAMEPLATES, TOGGLE_RAID_FRAMES, TOGGLE_ACHIEVEMENTS, + TOGGLE_SKILLS, ACTION_COUNT }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7decf08c..2eacb363 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1849,12 +1849,15 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CHAT_WRONG_FACTION: + addUIError("You cannot send messages to members of that faction."); addSystemChatMessage("You cannot send messages to members of that faction."); break; case Opcode::SMSG_CHAT_NOT_IN_PARTY: + addUIError("You are not in a party."); addSystemChatMessage("You are not in a party."); break; case Opcode::SMSG_CHAT_RESTRICTED: + addUIError("You cannot send chat messages in this area."); addSystemChatMessage("You cannot send chat messages in this area."); break; @@ -2049,6 +2052,7 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t reason = packet.readUInt8(); const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason"; std::string s = std::string("Failed to tame: ") + msg; + addUIError(s); addSystemChatMessage(s); } break; @@ -2168,6 +2172,8 @@ void GameHandler::handlePacket(network::Packet& packet) { std::dec, " rank=", rank); std::string msg = "You gain " + std::to_string(honor) + " honor points."; addSystemChatMessage(msg); + if (honor > 0) + addCombatText(CombatTextEntry::HONOR_GAIN, static_cast(honor), 0, true); if (pvpHonorCallback_) { pvpHonorCallback_(honor, victimGuid, rank); } @@ -2369,7 +2375,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case 0x06: msg = "Pet retrieved from stable."; break; case 0x07: msg = "Stable slot purchased."; break; case 0x08: msg = "Stable list updated."; break; - case 0x09: msg = "Stable failed: not enough money or other error."; break; + case 0x09: msg = "Stable failed: not enough money or other error."; + addUIError(msg); break; default: break; } if (msg) addSystemChatMessage(msg); @@ -2525,8 +2532,10 @@ void GameHandler::handlePacket(network::Packet& packet) { "Character name does not meet requirements.", // 7 }; const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; - addSystemChatMessage(errMsg ? std::string("Rename failed: ") + errMsg - : "Character rename failed."); + std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg + : "Character rename failed."; + addUIError(renameErr); + addSystemChatMessage(renameErr); } LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); } @@ -2539,6 +2548,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Your home is now set to this location."); } else { + addUIError("You are too far from the innkeeper."); addSystemChatMessage("You are too far from the innkeeper."); } } @@ -2557,12 +2567,14 @@ void GameHandler::handlePacket(network::Packet& packet) { "You must be in a raid group", "Player not in group" }; const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; + addUIError(std::string("Cannot change difficulty: ") + msg); addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); } } break; } case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE: + addUIError("Your corpse is outside this instance."); addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); break; case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: { @@ -2815,7 +2827,9 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); if (result != 4) { const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." }; - addSystemChatMessage(result < 4 ? msgs[result] : "Cannot mount."); + std::string mountErr = result < 4 ? msgs[result] : "Cannot mount."; + addUIError(mountErr); + addSystemChatMessage(mountErr); } break; } @@ -2823,7 +2837,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 result: 0=ok, others=error if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t result = packet.readUInt32(); - if (result != 0) addSystemChatMessage("Cannot dismount here."); + if (result != 0) { addUIError("Cannot dismount here."); addSystemChatMessage("Cannot dismount here."); } break; } @@ -3251,10 +3265,24 @@ void GameHandler::handlePacket(network::Packet& packet) { handleSpellDamageLog(packet); break; case Opcode::SMSG_PLAY_SPELL_VISUAL: { - // Minimal parse: uint64 casterGuid, uint32 visualId + // uint64 casterGuid + uint32 visualId if (packet.getSize() - packet.getReadPos() < 12) break; - packet.readUInt64(); - packet.readUInt32(); + uint64_t casterGuid = packet.readUInt64(); + uint32_t visualId = packet.readUInt32(); + if (visualId == 0) break; + // Resolve caster world position and spawn the effect + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (casterGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(casterGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(visualId, spawnPos); break; } case Opcode::SMSG_SPELLHEALLOG: @@ -3455,6 +3483,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::string msg = areaName.empty() ? std::string("A zone is under attack!") : (areaName + " is under attack!"); + addUIError(msg); addSystemChatMessage(msg); } break; @@ -3549,6 +3578,7 @@ void GameHandler::handlePacket(network::Packet& packet) { char buf[80]; std::snprintf(buf, sizeof(buf), "You have lost %u%% of your gear's durability due to death.", pct); + addUIError(buf); addSystemChatMessage(buf); } break; @@ -3586,6 +3616,7 @@ void GameHandler::handlePacket(network::Packet& packet) { partyData.members.clear(); partyData.memberCount = 0; partyData.leaderGuid = 0; + addUIError("Your party has been disbanded."); addSystemChatMessage("Your party has been disbanded."); LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); break; @@ -3955,6 +3986,7 @@ void GameHandler::handlePacket(network::Packet& packet) { else if (errorCode == 2) msg += " (already known)"; else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")"; + addUIError(msg); addSystemChatMessage(msg); // Play error sound so the player notices the failure if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -4473,6 +4505,7 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t len =*/ packet.readUInt32(); std::string msg = packet.readString(); if (!msg.empty()) { + addUIError(msg); addSystemChatMessage(msg); areaTriggerMsgs_.push_back(msg); } @@ -4578,6 +4611,7 @@ void GameHandler::handlePacket(network::Packet& packet) { "Unknown error", "Only empty bag" }; const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error"; + addUIError(std::string("Sell failed: ") + msg); addSystemChatMessage(std::string("Sell failed: ") + msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) @@ -4611,8 +4645,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (requiredLevel > 0) { std::snprintf(levelBuf, sizeof(levelBuf), "You must reach level %u to use that item.", requiredLevel); + addUIError(levelBuf); addSystemChatMessage(levelBuf); } else { + addUIError("You must reach a higher level to use that item."); addSystemChatMessage("You must reach a higher level to use that item."); } break; @@ -4675,6 +4711,7 @@ void GameHandler::handlePacket(network::Packet& packet) { default: break; } std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ")."; + addUIError(msg); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) @@ -4739,6 +4776,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case 6: msg = "You can't carry any more items."; break; default: break; } + addUIError(msg); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) @@ -4827,6 +4865,7 @@ void GameHandler::handlePacket(network::Packet& packet) { : (result == 2) ? "You are not at a barber shop." : (result == 3) ? "You must stand up to use the barber shop." : "Barber shop unavailable."; + addUIError(msg); addSystemChatMessage(msg); } LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); @@ -4911,6 +4950,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Gems socketed successfully."); } else { + addUIError("Failed to socket gems."); addSystemChatMessage("Failed to socket gems."); } LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result); @@ -4947,6 +4987,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const char* msg = (reason == 1) ? "The target cannot be resurrected right now." : (reason == 2) ? "Cannot resurrect in this area." : "Resurrection failed."; + addUIError(msg); addSystemChatMessage(msg); LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason); } @@ -4974,6 +5015,7 @@ void GameHandler::handlePacket(network::Packet& packet) { auto go = std::static_pointer_cast(goEnt); auto* info = getCachedGameObjectInfo(go->getEntry()); if (info && info->type == 17) { // GO_TYPE_FISHINGNODE + addUIError("A fish is on your line!"); addSystemChatMessage("A fish is on your line!"); // Play a distinctive UI sound to alert the player if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -5438,6 +5480,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_QUESTLOG_FULL: // Zero-payload notification: the player's quest log is full (25 quests). + addUIError("Your quest log is full."); addSystemChatMessage("Your quest log is full."); LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity"); break; @@ -5503,6 +5546,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case 0x0C: abortMsg = "Transfer aborted."; break; default: abortMsg = "Transfer aborted."; break; } + addUIError(abortMsg); addSystemChatMessage(abortMsg); break; } @@ -5540,12 +5584,25 @@ void GameHandler::handlePacket(network::Packet& packet) { handleBattlefieldList(packet); break; case Opcode::SMSG_BATTLEFIELD_PORT_DENIED: + addUIError("Battlefield port denied."); addSystemChatMessage("Battlefield port denied."); break; - case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: - // Optional map position updates for BG objectives/players. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: { + bgPlayerPositions_.clear(); + for (int grp = 0; grp < 2; ++grp) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t count = packet.readUInt32(); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 16; ++i) { + BgPlayerPosition pos; + pos.guid = packet.readUInt64(); + pos.wowX = packet.readFloat(); + pos.wowY = packet.readFloat(); + pos.group = grp; + bgPlayerPositions_.push_back(pos); + } + } break; + } case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE: addSystemChatMessage("You have been removed from the PvP queue."); break; @@ -5635,6 +5692,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addUIError("Cannot reset " + mapLabel + ": " + reasonMsg); addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason); } @@ -6089,6 +6147,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Clear cached talent data so the talent screen reflects the reset. learnedTalents_[0].clear(); learnedTalents_[1].clear(); + addUIError("Your talents have been reset by the server."); addSystemChatMessage("Your talents have been reset by the server."); packet.setReadPos(packet.getSize()); break; @@ -6183,7 +6242,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); - if (result != 0) addSystemChatMessage("Failed to equip item set."); + if (result != 0) { addUIError("Failed to equip item set."); addSystemChatMessage("Failed to equip item set."); } } break; } @@ -6218,12 +6277,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); (void)result; } + addUIError("Dungeon Finder: Auto-join failed."); addSystemChatMessage("Dungeon Finder: Auto-join failed."); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: // No eligible players found for auto-join + addUIError("Dungeon Finder: No players available for auto-join."); addSystemChatMessage("Dungeon Finder: No players available for auto-join."); packet.setReadPos(packet.getSize()); break; @@ -6737,6 +6798,7 @@ void GameHandler::handlePacket(network::Packet& packet) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); + addUIError("You were killed by an instant-kill effect."); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, @@ -7066,10 +7128,11 @@ void GameHandler::handlePacket(network::Packet& packet) { castTimeRemaining = castTimeTotal; } else { auto& s = unitCastStates_[chanCaster]; - s.casting = true; - s.spellId = chanSpellId; - s.timeTotal = chanTotalMs / 1000.0f; - s.timeRemaining = s.timeTotal; + s.casting = true; + s.spellId = chanSpellId; + s.timeTotal = chanTotalMs / 1000.0f; + s.timeRemaining = s.timeTotal; + s.interruptible = isSpellInterruptible(chanSpellId); } LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, " spell=", chanSpellId, " total=", chanTotalMs, "ms"); @@ -7256,16 +7319,20 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PLAYERBINDERROR: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t error = packet.readUInt32(); - if (error == 0) + if (error == 0) { + addUIError("Your hearthstone is not bound."); addSystemChatMessage("Your hearthstone is not bound."); - else + } else { + addUIError("Hearthstone bind failed."); addSystemChatMessage("Hearthstone bind failed."); + } } break; } // ---- Instance/raid errors ---- case Opcode::SMSG_RAID_GROUP_ONLY: { + addUIError("You must be in a raid group to enter this instance."); addSystemChatMessage("You must be in a raid group to enter this instance."); packet.setReadPos(packet.getSize()); break; @@ -7273,13 +7340,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_RAID_READY_CHECK_ERROR: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t err = packet.readUInt8(); - if (err == 0) addSystemChatMessage("Ready check failed: not in a group."); - else if (err == 1) addSystemChatMessage("Ready check failed: in instance."); - else addSystemChatMessage("Ready check failed."); + if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); } + else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); } + else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); } } break; } case Opcode::SMSG_RESET_FAILED_NOTIFY: { + addUIError("Cannot reset instance: another player is still inside."); addSystemChatMessage("Cannot reset instance: another player is still inside."); packet.setReadPos(packet.getSize()); break; @@ -7344,12 +7412,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Play object/spell sounds ---- case Opcode::SMSG_PLAY_OBJECT_SOUND: - case Opcode::SMSG_PLAY_SPELL_IMPACT: if (packet.getSize() - packet.getReadPos() >= 12) { // uint32 soundId + uint64 sourceGuid - uint32_t soundId = packet.readUInt32(); - uint64_t srcGuid = packet.readUInt64(); - LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND/SPELL_IMPACT id=", soundId, " src=0x", std::hex, srcGuid, std::dec); + uint32_t soundId = packet.readUInt32(); + uint64_t srcGuid = packet.readUInt64(); + LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec); if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); else if (playSoundCallback_) playSoundCallback_(soundId); } else if (packet.getSize() - packet.getReadPos() >= 4) { @@ -7358,6 +7425,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PLAY_SPELL_IMPACT: { + // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t impTargetGuid = packet.readUInt64(); + uint32_t impVisualId = packet.readUInt32(); + if (impVisualId == 0) break; + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (impTargetGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(impTargetGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true); + break; + } // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { @@ -7407,6 +7496,7 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: + addUIError("You cannot read this item."); addSystemChatMessage("You cannot read this item."); packet.setReadPos(packet.getSize()); break; @@ -7474,6 +7564,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- NPC not responding ---- case Opcode::SMSG_NPC_WONT_TALK: + addUIError("That creature can't talk to you right now."); addSystemChatMessage("That creature can't talk to you right now."); packet.setReadPos(packet.getSize()); break; @@ -7569,12 +7660,26 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PET_GUIDS: case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: - case Opcode::SMSG_PET_UNLEARN_CONFIRM: - case Opcode::SMSG_PET_RENAMEABLE: + case Opcode::SMSG_PET_UNLEARN_CONFIRM: { + // uint64 petGuid + uint32 cost (copper) + if (packet.getSize() - packet.getReadPos() >= 12) { + petUnlearnGuid_ = packet.readUInt64(); + petUnlearnCost_ = packet.readUInt32(); + petUnlearnPending_ = true; + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PET_RENAMEABLE: + // Server signals that the pet can now be named (first tame) + petRenameablePending_ = true; + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_PET_NAME_INVALID: + addUIError("That pet name is invalid. Please choose a different name."); addSystemChatMessage("That pet name is invalid. Please choose a different name."); packet.setReadPos(packet.getSize()); break; @@ -16179,6 +16284,7 @@ void GameHandler::handleLfgJoinResult(network::Packet& packet) { } else { const char* msg = lfgJoinResultString(result); std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); + addUIError(errMsg); addSystemChatMessage(errMsg); LOG_INFO("SMSG_LFG_JOIN_RESULT: result=", static_cast(result), " state=", static_cast(state)); @@ -16224,6 +16330,7 @@ void GameHandler::handleLfgProposalUpdate(network::Packet& packet) { case 0: lfgState_ = LfgState::Queued; lfgProposalId_ = 0; + addUIError("Dungeon Finder: Group proposal failed."); addSystemChatMessage("Dungeon Finder: Group proposal failed."); break; case 1: { @@ -16267,6 +16374,7 @@ void GameHandler::handleLfgRoleCheckUpdate(network::Packet& packet) { LOG_INFO("LFG role check finished"); } else if (roleCheckState == 3) { lfgState_ = LfgState::None; + addUIError("Dungeon Finder: Role check failed — missing required role."); addSystemChatMessage("Dungeon Finder: Role check failed — missing required role."); } else if (roleCheckState == 2) { lfgState_ = LfgState::RoleCheck; @@ -17555,6 +17663,12 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { if (packet.getReadPos() + 4 > packet.getSize()) return; uint32_t pointCount = packet.readUInt32(); + constexpr uint32_t kMaxTransportSplinePoints = 1000; + if (pointCount > kMaxTransportSplinePoints) { + LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount, + " clamped to ", kMaxTransportSplinePoints); + pointCount = kMaxTransportSplinePoints; + } // Read destination point (transport-local server coords) float destLocalX = localX, destLocalY = localY, destLocalZ = localZ; @@ -17690,7 +17804,15 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { - auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; + CombatTextEntry::Type type; + if (data.isCrit()) + type = CombatTextEntry::CRIT_DAMAGE; + else if (data.isCrushing()) + type = CombatTextEntry::CRUSHING; + else if (data.isGlancing()) + type = CombatTextEntry::GLANCING; + else + type = CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); // Show partial absorb/resist from sub-damage entries uint32_t totalAbsorbed = 0, totalResisted = 0; @@ -18155,21 +18277,20 @@ void GameHandler::handleCastFailed(network::Packet& packet) { } } - // Add system message about failed cast with readable reason + // Show failure reason in the UIError overlay and in chat int powerType = -1; auto playerEntity = entityManager.getEntity(playerGuid); if (auto playerUnit = std::dynamic_pointer_cast(playerEntity)) { powerType = playerUnit->getPowerType(); } const char* reason = getSpellCastResultString(data.result, powerType); + std::string errMsg = reason ? reason + : ("Spell cast failed (error " + std::to_string(data.result) + ")"); + addUIError(errMsg); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - if (reason) { - msg.message = reason; - } else { - msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")"; - } + msg.message = errMsg; addLocalChatMessage(msg); } @@ -18190,10 +18311,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // Track cast bar for any non-player caster (target frame + boss frames) if (data.casterUnit != playerGuid && data.castTime > 0) { auto& s = unitCastStates_[data.casterUnit]; - s.casting = true; - s.spellId = data.spellId; - s.timeTotal = data.castTime / 1000.0f; - s.timeRemaining = s.timeTotal; + s.casting = true; + s.spellId = data.spellId; + s.timeTotal = data.castTime / 1000.0f; + s.timeRemaining = s.timeTotal; + s.interruptible = isSpellInterruptible(data.spellId); // Trigger cast animation on the casting unit if (spellCastAnimCallback_) { spellCastAnimCallback_(data.casterUnit, true, false); @@ -18760,6 +18882,20 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { addSystemChatMessage(msg); } +void GameHandler::confirmPetUnlearn() { + if (!petUnlearnPending_) return; + petUnlearnPending_ = false; + if (state != WorldState::IN_WORLD || !socket) return; + + // Respond with CMSG_PET_UNLEARN_TALENTS (no payload in 3.3.5a) + network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS)); + socket->send(pkt); + LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS"); + addSystemChatMessage("Pet talent reset confirmed."); + petUnlearnGuid_ = 0; + petUnlearnCost_ = 0; +} + void GameHandler::confirmTalentWipe() { if (!talentWipePending_) return; talentWipePending_ = false; @@ -18873,6 +19009,7 @@ void GameHandler::handleGroupUninvite(network::Packet& packet) { msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = "You have been removed from the group."; + addUIError("You have been removed from the group."); addLocalChatMessage(msg); } @@ -18907,6 +19044,7 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { static_cast(data.result)); } + addUIError(buf); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; @@ -20420,6 +20558,34 @@ void GameHandler::closeGossip() { currentGossip = GossipMessageData{}; } +void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { + if (state != WorldState::IN_WORLD || !socket) return; + if (itemGuid == 0 || questId == 0) { + addSystemChatMessage("Cannot start quest right now."); + return; + } + // Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver." + // The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() + // picks up and opens the Accept/Decline dialog. + auto queryPkt = packetParsers_ + ? packetParsers_->buildQueryQuestPacket(itemGuid, questId) + : QuestgiverQueryQuestPacket::build(itemGuid, questId); + socket->send(queryPkt); + LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec, + " questId=", questId); +} + +uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0; + if (slotIndex < 0) return 0; + uint64_t bagGuid = equipSlotGuids_[19 + bagIndex]; + if (bagGuid == 0) return 0; + auto it = containerContents_.find(bagGuid); + if (it == containerContents_.end()) return 0; + if (slotIndex >= static_cast(it->second.numSlots)) return 0; + return it->second.slotGuids[slotIndex]; +} + void GameHandler::openVendor(uint64_t npcGuid) { if (state != WorldState::IN_WORLD || !socket) return; buybackItems_.clear(); @@ -21104,6 +21270,86 @@ void GameHandler::handleListInventory(network::Packet& packet) { vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens + // Auto-sell grey items if enabled + if (autoSellGrey_ && currentVendorItems.vendorGuid != 0) { + uint32_t totalSellPrice = 0; + int itemsSold = 0; + + // Helper lambda to attempt selling a poor-quality slot + auto tryAutoSell = [&](const ItemSlot& slot, uint64_t itemGuid) { + if (slot.empty()) return; + if (slot.item.quality != ItemQuality::POOR) return; + // Determine sell price (slot cache first, then item info fallback) + uint32_t sp = slot.item.sellPrice; + if (sp == 0) { + if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) + sp = info->sellPrice; + } + if (sp == 0 || itemGuid == 0) return; + BuybackItem sold; + sold.itemGuid = itemGuid; + sold.item = slot.item; + sold.count = 1; + buybackItems_.push_front(sold); + if (buybackItems_.size() > 12) buybackItems_.pop_back(); + pendingSellToBuyback_[itemGuid] = sold; + sellItem(currentVendorItems.vendorGuid, itemGuid, 1); + totalSellPrice += sp; + ++itemsSold; + }; + + // Backpack slots + for (int i = 0; i < inventory.getBackpackSize(); ++i) { + uint64_t guid = backpackSlotGuids_[i]; + if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBackpackSlot(i).item.itemId); + tryAutoSell(inventory.getBackpackSlot(i), guid); + } + + // Extra bag slots + for (int b = 0; b < inventory.NUM_BAG_SLOTS; ++b) { + uint64_t bagGuid = equipSlotGuids_[19 + b]; + for (int s = 0; s < inventory.getBagSize(b); ++s) { + uint64_t guid = 0; + if (bagGuid != 0) { + auto it = containerContents_.find(bagGuid); + if (it != containerContents_.end() && s < static_cast(it->second.numSlots)) + guid = it->second.slotGuids[s]; + } + if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBagSlot(b, s).item.itemId); + tryAutoSell(inventory.getBagSlot(b, s), guid); + } + } + + if (itemsSold > 0) { + uint32_t gold = totalSellPrice / 10000; + uint32_t silver = (totalSellPrice % 10000) / 100; + uint32_t copper = totalSellPrice % 100; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "|cffaaaaaaAuto-sold %d grey item%s for %ug %us %uc.|r", + itemsSold, itemsSold == 1 ? "" : "s", gold, silver, copper); + addSystemChatMessage(buf); + } + } + + // Auto-repair all items if enabled and vendor can repair + if (autoRepair_ && currentVendorItems.canRepair && currentVendorItems.vendorGuid != 0) { + // Check that at least one equipped item is actually damaged to avoid no-op + bool anyDamaged = false; + for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inventory.getEquipSlot(static_cast(i)); + if (!slot.empty() && slot.item.maxDurability > 0 + && slot.item.curDurability < slot.item.maxDurability) { + anyDamaged = true; + break; + } + } + if (anyDamaged) { + repairAll(currentVendorItems.vendorGuid, false); + addSystemChatMessage("|cffaaaaaaAuto-repair triggered.|r"); + } + } + // Play vendor sound if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) { auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); @@ -21229,6 +21475,14 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } } + // AttributesEx field (bit 4 = SPELL_ATTR_EX_NOT_INTERRUPTIBLE) + uint32_t attrExField = 0xFFFFFFFF; + bool hasAttrExField = false; + if (spellL) { + uint32_t f = spellL->field("AttributesEx"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; } + } + // Tooltip/description field uint32_t tooltipField = 0xFFFFFFFF; if (spellL) { @@ -21243,7 +21497,7 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0}; + SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0}; if (tooltipField != 0xFFFFFFFF) { entry.description = dbc->getString(i, tooltipField); } @@ -21258,6 +21512,9 @@ void GameHandler::loadSpellNameCache() { if (hasDispelField) { entry.dispelType = static_cast(dbc->getUInt32(i, dispelField)); } + if (hasAttrExField) { + entry.attrEx = dbc->getUInt32(i, attrExField); + } spellNameCache_[id] = std::move(entry); } } @@ -21463,6 +21720,22 @@ uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.dispelType : 0; } +bool GameHandler::isSpellInterruptible(uint32_t spellId) const { + if (spellId == 0) return true; + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + if (it == spellNameCache_.end()) return true; // assume interruptible if unknown + // SPELL_ATTR_EX_NOT_INTERRUPTIBLE = bit 4 of AttributesEx (0x00000010) + return (it->second.attrEx & 0x00000010u) == 0; +} + +uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const { + if (spellId == 0) return 0; + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.schoolMask : 0; +} + const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; @@ -23936,6 +24209,7 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) { "DB error", "Restricted account"}; const char* errName = (result.errorCode < 9) ? errors[result.errorCode] : "Unknown"; std::string msg = std::string("Auction ") + actionName + " failed: " + errName; + addUIError(msg); addSystemChatMessage(msg); } LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName, diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 7b25d257..e0dd01f8 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -520,23 +520,20 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) data.castTime = packet.readUInt32(); - // SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK) - if (rem() < 2) { - LOG_WARNING("[Classic] Spell start: missing targetFlags"); - packet.setReadPos(startPos); - return false; - } - uint16_t targetFlags = packet.readUInt16(); - // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID - if ((targetFlags & 0x02) || (targetFlags & 0x800)) { - if (!hasFullPackedGuid(packet)) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + // SpellCastTargets: consume ALL target payload types so subsequent reads stay aligned. + // Previously only UNIT(0x02)/OBJECT(0x800) were handled; DEST_LOCATION(0x40), + // SOURCE_LOCATION(0x20), and ITEM(0x10) bytes were silently skipped, corrupting + // castFlags/castTime for every AOE/ground-targeted spell (Rain of Fire, Blizzard, etc.). + { + uint64_t targetGuid = 0; + // skipClassicSpellCastTargets reads uint16 targetFlags and all payloads. + // Non-fatal on truncation: self-cast spells have zero-byte targets. + skipClassicSpellCastTargets(packet, &targetGuid); + data.targetGuid = targetGuid; } - LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms", + " targetGuid=0x", std::hex, data.targetGuid, std::dec); return true; } @@ -765,6 +762,10 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da return false; } + // SpellCastTargets follows the miss list — consume all target bytes so that + // any subsequent fields (e.g. castFlags extras) are not misaligned. + skipClassicSpellCastTargets(packet, &data.targetGuid); + LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); return true; diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index a29a0beb..8e8fbd25 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1232,6 +1232,66 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector bool { + if (!(targetFlags & flag)) return true; + // Packed GUID: 1-byte mask + up to 8 data bytes + if (packet.getReadPos() >= packet.getSize()) return false; + uint8_t mask = packet.getData()[packet.getReadPos()]; + size_t needed = 1; + for (int b = 0; b < 8; ++b) if (mask & (1u << b)) ++needed; + if (packet.getSize() - packet.getReadPos() < needed) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (capture && primaryTargetGuid && *primaryTargetGuid == 0) *primaryTargetGuid = g; + return true; + }; + auto skipFloats3 = [&](uint32_t flag) -> bool { + if (!(targetFlags & flag)) return true; + if (packet.getSize() - packet.getReadPos() < 12) return false; + (void)packet.readFloat(); (void)packet.readFloat(); (void)packet.readFloat(); + return true; + }; + + // Process in wire order matching cmangos-tbc SpellCastTargets::write() + if (!readPackedGuidCond(0x0002, true)) return false; // UNIT + if (!readPackedGuidCond(0x0004, false)) return false; // UNIT_MINIPET + if (!readPackedGuidCond(0x0010, false)) return false; // ITEM + if (!skipFloats3(0x0020)) return false; // SOURCE_LOCATION + if (!skipFloats3(0x0040)) return false; // DEST_LOCATION + + if (targetFlags & 0x1000) { // TRADE_ITEM: uint8 + if (packet.getReadPos() >= packet.getSize()) return false; + (void)packet.readUInt8(); + } + if (targetFlags & 0x2000) { // STRING: null-terminated + const auto& raw = packet.getData(); + size_t pos = packet.getReadPos(); + while (pos < raw.size() && raw[pos] != 0) ++pos; + if (pos >= raw.size()) return false; + packet.setReadPos(pos + 1); + } + if (!readPackedGuidCond(0x8200, false)) return false; // CORPSE / PVP_CORPSE + if (!readPackedGuidCond(0x0800, true)) return false; // OBJECT + + return true; +} + // TbcPacketParsers::parseSpellStart — TBC 2.4.3 SMSG_SPELL_START // // TBC uses full uint64 GUIDs for casterGuid and casterUnit. @@ -1243,7 +1303,6 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector packet.getSize()) { - LOG_WARNING("[TBC] Spell start: missing targetFlags"); - packet.setReadPos(startPos); - return false; + // SpellCastTargets: consume ALL target payload types to keep the read position + // aligned for any bytes the caller may parse after this (ammo, etc.). + // The previous code only read UNIT(0x02)/OBJECT(0x800) target GUIDs and left + // DEST_LOCATION(0x40)/SOURCE_LOCATION(0x20)/ITEM(0x10) bytes unconsumed, + // corrupting subsequent reads for every AOE/ground-targeted spell cast. + { + uint64_t targetGuid = 0; + skipTbcSpellCastTargets(packet, &targetGuid); // non-fatal on truncation + data.targetGuid = targetGuid; } - uint32_t targetFlags = packet.readUInt32(); - const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT - if (needsTargetGuid) { - if (packet.getReadPos() + 8 > packet.getSize()) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = packet.readUInt64(); // full GUID in TBC - } - - LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms", + " targetGuid=0x", std::hex, data.targetGuid, std::dec); return true; } @@ -1368,6 +1423,10 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) } data.missCount = static_cast(data.missTargets.size()); + // SpellCastTargets follows the miss list — consume all target bytes so that + // any subsequent fields are not misaligned for ground-targeted AoE spells. + skipTbcSpellCastTargets(packet, &data.targetGuid); + LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); return true; diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index b1a99f7e..a30a6dd6 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -32,6 +32,8 @@ WardenEmulator::WardenEmulator() , heapBase_(HEAP_BASE) , heapSize_(HEAP_SIZE) , apiStubBase_(API_STUB_BASE) + , nextApiStubAddr_(API_STUB_BASE) + , apiCodeHookRegistered_(false) , nextHeapAddr_(HEAP_BASE) { } @@ -51,8 +53,11 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 allocations_.clear(); freeBlocks_.clear(); apiAddresses_.clear(); + apiHandlers_.clear(); hooks_.clear(); nextHeapAddr_ = heapBase_; + nextApiStubAddr_ = apiStubBase_; + apiCodeHookRegistered_ = false; { char addrBuf[32]; @@ -149,6 +154,13 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0); hooks_.push_back(hh); + // Add code hook over the API stub area so Windows API calls are intercepted + uc_hook apiHook; + uc_hook_add(uc_, &apiHook, UC_HOOK_CODE, (void*)hookCode, this, + API_STUB_BASE, API_STUB_BASE + 0x10000 - 1); + hooks_.push_back(apiHook); + apiCodeHookRegistered_ = true; + { char sBuf[128]; std::snprintf(sBuf, sizeof(sBuf), "WardenEmulator: Emulator initialized Stack: 0x%X-0x%X Heap: 0x%X-0x%X", @@ -161,23 +173,45 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uint32_t WardenEmulator::hookAPI(const std::string& dllName, const std::string& functionName, - [[maybe_unused]] std::function&)> handler) { - // Allocate address for this API stub - static uint32_t nextStubAddr = API_STUB_BASE; - uint32_t stubAddr = nextStubAddr; - nextStubAddr += 16; // Space for stub code + std::function&)> handler) { + // Allocate address for this API stub (16 bytes each) + uint32_t stubAddr = nextApiStubAddr_; + nextApiStubAddr_ += 16; - // Store mapping + // Store address mapping for IAT patching apiAddresses_[dllName][functionName] = stubAddr; - { - char hBuf[32]; - std::snprintf(hBuf, sizeof(hBuf), "0x%X", stubAddr); - LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf); + // Determine stdcall arg count from known Windows APIs so the hook can + // clean up the stack correctly (RETN N convention). + static const std::pair knownArgCounts[] = { + {"VirtualAlloc", 4}, + {"VirtualFree", 3}, + {"GetTickCount", 0}, + {"Sleep", 1}, + {"GetCurrentThreadId", 0}, + {"GetCurrentProcessId", 0}, + {"ReadProcessMemory", 5}, + }; + int argCount = 0; + for (const auto& [name, cnt] : knownArgCounts) { + if (functionName == name) { argCount = cnt; break; } } - // TODO: Write stub code that triggers a hook callback - // For now, just return the address for IAT patching + // Store the handler so hookCode() can dispatch to it + apiHandlers_[stubAddr] = { argCount, std::move(handler) }; + + // Write a RET (0xC3) at the stub address as a safe fallback in case + // the code hook fires after EIP has already advanced past our intercept. + if (uc_) { + static const uint8_t retInstr = 0xC3; + uc_mem_write(uc_, stubAddr, &retInstr, 1); + } + + { + char hBuf[64]; + std::snprintf(hBuf, sizeof(hBuf), "0x%X (argCount=%d)", stubAddr, argCount); + LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf); + } return stubAddr; } @@ -503,8 +537,40 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve // Unicorn Callbacks // ============================================================================ -void WardenEmulator::hookCode([[maybe_unused]] uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, [[maybe_unused]] void* userData) { - (void)address; // Trace disabled by default to avoid log spam +void WardenEmulator::hookCode(uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, void* userData) { + auto* self = static_cast(userData); + if (!self) return; + + auto it = self->apiHandlers_.find(static_cast(address)); + if (it == self->apiHandlers_.end()) return; // not an API stub — trace disabled to avoid spam + + const ApiHookEntry& entry = it->second; + + // Read stack: [ESP+0] = return address, [ESP+4..] = stdcall args + uint32_t esp = 0; + uc_reg_read(uc, UC_X86_REG_ESP, &esp); + + uint32_t retAddr = 0; + uc_mem_read(uc, esp, &retAddr, 4); + + std::vector args(static_cast(entry.argCount)); + for (int i = 0; i < entry.argCount; ++i) { + uint32_t val = 0; + uc_mem_read(uc, esp + 4 + static_cast(i) * 4, &val, 4); + args[static_cast(i)] = val; + } + + // Dispatch to the C++ handler + uint32_t retVal = 0; + if (entry.handler) { + retVal = entry.handler(*self, args); + } + + // Simulate stdcall epilogue: pop return address + args + uint32_t newEsp = esp + 4 + static_cast(entry.argCount) * 4; + uc_reg_write(uc, UC_X86_REG_EAX, &retVal); + uc_reg_write(uc, UC_X86_REG_ESP, &newEsp); + uc_reg_write(uc, UC_X86_REG_EIP, &retAddr); } void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, uint64_t address, int size, [[maybe_unused]] int64_t value, [[maybe_unused]] void* userData) { @@ -533,7 +599,8 @@ WardenEmulator::WardenEmulator() : uc_(nullptr), moduleBase_(0), moduleSize_(0) , stackBase_(0), stackSize_(0) , heapBase_(0), heapSize_(0) - , apiStubBase_(0), nextHeapAddr_(0) {} + , apiStubBase_(0), nextApiStubAddr_(0), apiCodeHookRegistered_(false) + , nextHeapAddr_(0) {} WardenEmulator::~WardenEmulator() {} bool WardenEmulator::initialize(const void*, size_t, uint32_t) { return false; } uint32_t WardenEmulator::hookAPI(const std::string&, const std::string&, diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 9f577978..eea0f0ee 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -161,24 +161,53 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, } try { - // Call module's PacketHandler - // void PacketHandler(uint8_t* checkData, size_t checkSize, - // uint8_t* responseOut, size_t* responseSizeOut) - LOG_INFO("WardenModule: Calling PacketHandler..."); + if (emulatedPacketHandlerAddr_ == 0) { + LOG_ERROR("WardenModule: PacketHandler address not set (module not fully initialized)"); + emulator_->freeMemory(checkDataAddr); + emulator_->freeMemory(responseAddr); + return false; + } - // For now, this is a placeholder - actual calling would depend on - // the module's exact function signature - LOG_WARNING("WardenModule: PacketHandler execution stubbed"); - LOG_INFO("WardenModule: Would call emulated function to process checks"); - LOG_INFO("WardenModule: This would generate REAL responses (not fakes!)"); + // Allocate uint32_t for responseSizeOut in emulated memory + uint32_t initialSize = 1024; + uint32_t responseSizeAddr = emulator_->writeData(&initialSize, sizeof(uint32_t)); + if (responseSizeAddr == 0) { + LOG_ERROR("WardenModule: Failed to allocate responseSizeAddr"); + emulator_->freeMemory(checkDataAddr); + emulator_->freeMemory(responseAddr); + return false; + } + + // Call: void PacketHandler(uint8_t* data, uint32_t size, + // uint8_t* responseOut, uint32_t* responseSizeOut) + LOG_INFO("WardenModule: Calling emulated PacketHandler..."); + emulator_->callFunction(emulatedPacketHandlerAddr_, { + checkDataAddr, + static_cast(checkData.size()), + responseAddr, + responseSizeAddr + }); + + // Read back response size and data + uint32_t responseSize = 0; + emulator_->readMemory(responseSizeAddr, &responseSize, sizeof(uint32_t)); + emulator_->freeMemory(responseSizeAddr); + + if (responseSize > 0 && responseSize <= 1024) { + responseOut.resize(responseSize); + if (!emulator_->readMemory(responseAddr, responseOut.data(), responseSize)) { + LOG_ERROR("WardenModule: Failed to read response data"); + responseOut.clear(); + } else { + LOG_INFO("WardenModule: PacketHandler wrote ", responseSize, " byte response"); + } + } else { + LOG_WARNING("WardenModule: PacketHandler returned invalid responseSize=", responseSize); + } - // Clean up emulator_->freeMemory(checkDataAddr); emulator_->freeMemory(responseAddr); - - // For now, return false to use fake responses - // Once we have a real module, we'd read the response from responseAddr - return false; + return !responseOut.empty(); } catch (const std::exception& e) { LOG_ERROR("WardenModule: Exception during PacketHandler: ", e.what()); @@ -196,25 +225,18 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, return false; } -uint32_t WardenModule::tick([[maybe_unused]] uint32_t deltaMs) { +uint32_t WardenModule::tick(uint32_t deltaMs) { if (!loaded_ || !funcList_.tick) { - return 0; // No tick needed + return 0; } - - // TODO: Call module's Tick function - // return funcList_.tick(deltaMs); - - return 0; + return funcList_.tick(deltaMs); } -void WardenModule::generateRC4Keys([[maybe_unused]] uint8_t* packet) { +void WardenModule::generateRC4Keys(uint8_t* packet) { if (!loaded_ || !funcList_.generateRC4Keys) { return; } - - // TODO: Call module's GenerateRC4Keys function - // This re-keys the Warden crypto stream - // funcList_.generateRC4Keys(packet); + funcList_.generateRC4Keys(packet); } void WardenModule::unload() { @@ -222,8 +244,7 @@ void WardenModule::unload() { // Call module's Unload() function if loaded if (loaded_ && funcList_.unload) { LOG_INFO("WardenModule: Calling module unload callback..."); - // TODO: Implement callback when execution layer is complete - // funcList_.unload(nullptr); + funcList_.unload(nullptr); } // Free executable memory region @@ -240,6 +261,7 @@ void WardenModule::unload() { // Clear function pointers funcList_ = {}; + emulatedPacketHandlerAddr_ = 0; loaded_ = false; moduleData_.clear(); @@ -961,7 +983,12 @@ bool WardenModule::initializeModule() { } // Read WardenFuncList structure from emulated memory - // Structure has 4 function pointers (16 bytes) + // Structure has 4 function pointers (16 bytes): + // [0] generateRC4Keys(uint8_t* seed) + // [1] unload(uint8_t* rc4Keys) + // [2] packetHandler(uint8_t* data, uint32_t size, + // uint8_t* responseOut, uint32_t* responseSizeOut) + // [3] tick(uint32_t deltaMs) -> uint32_t uint32_t funcAddrs[4] = {}; if (emulator_->readMemory(result, funcAddrs, 16)) { char fb[4][32]; @@ -973,11 +1000,48 @@ bool WardenModule::initializeModule() { LOG_INFO("WardenModule: packetHandler: ", fb[2]); LOG_INFO("WardenModule: tick: ", fb[3]); - // Store function addresses for later use - // funcList_.generateRC4Keys = ... (would wrap emulator calls) - // funcList_.unload = ... - // funcList_.packetHandler = ... - // funcList_.tick = ... + // Wrap emulated function addresses into std::function dispatchers + WardenEmulator* emu = emulator_.get(); + + if (funcAddrs[0]) { + uint32_t addr = funcAddrs[0]; + funcList_.generateRC4Keys = [emu, addr](uint8_t* seed) { + // Warden RC4 seed is a fixed 4-byte value + uint32_t seedAddr = emu->writeData(seed, 4); + if (seedAddr) { + emu->callFunction(addr, {seedAddr}); + emu->freeMemory(seedAddr); + } + }; + } + + if (funcAddrs[1]) { + uint32_t addr = funcAddrs[1]; + funcList_.unload = [emu, addr]([[maybe_unused]] uint8_t* rc4Keys) { + emu->callFunction(addr, {0u}); // pass NULL; module saves its own state + }; + } + + if (funcAddrs[2]) { + // Store raw address for the 4-arg call in processCheckRequest + emulatedPacketHandlerAddr_ = funcAddrs[2]; + uint32_t addr = funcAddrs[2]; + // Simple 2-arg variant for generic callers (no response extraction) + funcList_.packetHandler = [emu, addr](uint8_t* data, size_t length) { + uint32_t dataAddr = emu->writeData(data, length); + if (dataAddr) { + emu->callFunction(addr, {dataAddr, static_cast(length)}); + emu->freeMemory(dataAddr); + } + }; + } + + if (funcAddrs[3]) { + uint32_t addr = funcAddrs[3]; + funcList_.tick = [emu, addr](uint32_t deltaMs) -> uint32_t { + return emu->callFunction(addr, {deltaMs}); + }; + } } LOG_INFO("WardenModule: Module fully initialized and ready!"); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5d322ff1..aaf18ca2 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3780,14 +3780,44 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { return false; } + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // subsequent fields (e.g. school mask, cast flags 0x20 extra data) are not + // misaligned for ground-targeted or AoE spells. uint32_t targetFlags = packet.readUInt32(); - const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT - if (needsTargetGuid) { - if (!hasFullPackedGuid(packet)) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!hasFullPackedGuid(packet)) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!hasFullPackedGuid(packet)) return false; + UpdateObjectParser::readPackedGuid(packet); // transport GUID (may be zero) + if (packet.getSize() - packet.getReadPos() < 12) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share a single object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); // best-effort; ignore failure + } + // ITEM/TRADE_ITEM share a single item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {} } LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); @@ -3901,6 +3931,50 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } data.missCount = static_cast(data.missTargets.size()); + // 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. + if (packet.getReadPos() < packet.getSize()) { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t targetFlags = packet.readUInt32(); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!hasFullPackedGuid(packet)) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!hasFullPackedGuid(packet)) return false; + UpdateObjectParser::readPackedGuid(packet); // transport GUID + if (packet.getSize() - packet.getReadPos() < 12) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); + } + // ITEM/TRADE_ITEM share one item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {} + } + } + } + LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); return true; diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 22c5304f..d16fa26c 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -388,10 +388,11 @@ void CameraController::update(float deltaTime) { if (mounted_) sitting = false; xKeyWasDown = xDown; - // Reset camera with R key (edge-triggered) — only when UI doesn't want keyboard + // Reset camera angles with R key (edge-triggered) — only when UI doesn't want keyboard + // Does NOT move the player; full reset() is reserved for world-entry/respawn. bool rDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R); if (rDown && !rKeyWasDown) { - reset(); + resetAngles(); } rKeyWasDown = rDown; @@ -1941,6 +1942,14 @@ void CameraController::processMouseButton(const SDL_MouseButtonEvent& event) { mouseButtonDown = anyDown; } +void CameraController::resetAngles() { + if (!camera) return; + yaw = defaultYaw; + facingYaw = defaultYaw; + pitch = defaultPitch; + camera->setRotation(yaw, pitch); +} + void CameraController::reset() { if (!camera) { return; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 67a637bb..4da8bad7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2627,6 +2627,190 @@ void Renderer::stopChargeEffect() { } } +// ─── Spell Visual Effects ──────────────────────────────────────────────────── + +void Renderer::loadSpellVisualDbc() { + if (spellVisualDbcLoaded_) return; + spellVisualDbcLoaded_ = true; // Set early to prevent re-entry on failure + + if (!cachedAssetManager) { + cachedAssetManager = core::Application::getInstance().getAssetManager(); + } + if (!cachedAssetManager) return; + + auto* layout = pipeline::getActiveDBCLayout(); + const pipeline::DBCFieldMap* svLayout = layout ? layout->getLayout("SpellVisual") : nullptr; + const pipeline::DBCFieldMap* kitLayout = layout ? layout->getLayout("SpellVisualKit") : nullptr; + const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr; + + uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2; + uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3; + uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8; + uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11; + uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5; + uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2; + + // Helper to look up effectName path from a kit ID + // Load SpellVisualEffectName.dbc — ID → M2 path + auto fxDbc = cachedAssetManager->loadDBC("SpellVisualEffectName.dbc"); + if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) { + LOG_DEBUG("SpellVisual: SpellVisualEffectName.dbc unavailable (fc=", + fxDbc ? fxDbc->getFieldCount() : 0, ")"); + return; + } + std::unordered_map effectPaths; // effectNameId → path + for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) { + uint32_t id = fxDbc->getUInt32(i, 0); + std::string p = fxDbc->getString(i, fxFilePathField); + if (id && !p.empty()) effectPaths[id] = p; + } + + // Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID + auto kitDbc = cachedAssetManager->loadDBC("SpellVisualKit.dbc"); + std::unordered_map kitToEffectName; // kitId → effectNameId + if (kitDbc && kitDbc->isLoaded()) { + uint32_t fc = kitDbc->getFieldCount(); + for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) { + uint32_t kitId = kitDbc->getUInt32(i, 0); + if (!kitId) continue; + // Prefer SpecialEffect0, fall back to BaseEffect + uint32_t eff = 0; + if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field); + if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField); + if (eff) kitToEffectName[kitId] = eff; + } + } + + // Helper: resolve path for a given kit ID + auto kitPath = [&](uint32_t kitId) -> std::string { + if (!kitId) return {}; + auto kitIt = kitToEffectName.find(kitId); + if (kitIt == kitToEffectName.end()) return {}; + auto fxIt = effectPaths.find(kitIt->second); + return (fxIt != effectPaths.end()) ? fxIt->second : std::string{}; + }; + auto missilePath = [&](uint32_t effId) -> std::string { + if (!effId) return {}; + auto fxIt = effectPaths.find(effId); + return (fxIt != effectPaths.end()) ? fxIt->second : std::string{}; + }; + + // Load SpellVisual.dbc — visualId → cast/impact M2 paths via kit chain + auto svDbc = cachedAssetManager->loadDBC("SpellVisual.dbc"); + if (!svDbc || !svDbc->isLoaded()) { + LOG_DEBUG("SpellVisual: SpellVisual.dbc unavailable"); + return; + } + uint32_t svFc = svDbc->getFieldCount(); + uint32_t loadedCast = 0, loadedImpact = 0; + for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) { + uint32_t vid = svDbc->getUInt32(i, 0); + if (!vid) continue; + + // Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel + { + std::string path; + if (svCastKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svCastKitField)); + if (path.empty() && svMissileField < svFc) + path = missilePath(svDbc->getUInt32(i, svMissileField)); + if (!path.empty()) { spellVisualCastPath_[vid] = path; ++loadedCast; } + } + // Impact path: ImpactKit → SpecialEffect0/BaseEffect, fallback to MissileModel + { + std::string path; + if (svImpactKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svImpactKitField)); + if (path.empty() && svMissileField < svFc) + path = missilePath(svDbc->getUInt32(i, svMissileField)); + if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; } + } + } + LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact, + " visual→M2 mappings (of ", svDbc->getRecordCount(), " records)"); +} + +void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, + bool useImpactKit) { + if (!m2Renderer || visualId == 0) return; + + if (!cachedAssetManager) + cachedAssetManager = core::Application::getInstance().getAssetManager(); + if (!cachedAssetManager) return; + + if (!spellVisualDbcLoaded_) loadSpellVisualDbc(); + + // Select cast or impact path map + auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_; + auto pathIt = pathMap.find(visualId); + if (pathIt == pathMap.end()) return; // No model for this visual + + const std::string& modelPath = pathIt->second; + + // Get or assign a model ID for this path + auto midIt = spellVisualModelIds_.find(modelPath); + uint32_t modelId = 0; + if (midIt != spellVisualModelIds_.end()) { + modelId = midIt->second; + } else { + if (nextSpellVisualModelId_ >= 999800) { + LOG_WARNING("SpellVisual: model ID pool exhausted"); + return; + } + modelId = nextSpellVisualModelId_++; + spellVisualModelIds_[modelPath] = modelId; + } + + // Load the M2 model if not already loaded + if (!m2Renderer->hasModel(modelId)) { + auto m2Data = cachedAssetManager->readFile(modelPath); + if (m2Data.empty()) { + LOG_DEBUG("SpellVisual: could not read model: ", modelPath); + return; + } + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty() && model.particleEmitters.empty()) { + LOG_DEBUG("SpellVisual: empty model: ", modelPath); + return; + } + // Load skin file for WotLK-format M2s + if (model.version >= 264) { + std::string skinPath = modelPath.substr(0, modelPath.rfind('.')) + "00.skin"; + auto skinData = cachedAssetManager->readFile(skinPath); + if (!skinData.empty()) pipeline::M2Loader::loadSkin(skinData, model); + } + if (!m2Renderer->loadModel(model, modelId)) { + LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath); + return; + } + LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath); + } + + // Spawn instance at world position + uint32_t instanceId = m2Renderer->createInstance(modelId, worldPosition, + glm::vec3(0.0f), 1.0f); + if (instanceId == 0) { + LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId); + return; + } + activeSpellVisuals_.push_back({instanceId, 0.0f}); + LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId, + " model=", modelPath); +} + +void Renderer::updateSpellVisuals(float deltaTime) { + if (activeSpellVisuals_.empty() || !m2Renderer) return; + for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { + it->elapsed += deltaTime; + if (it->elapsed >= SPELL_VISUAL_DURATION) { + m2Renderer->removeInstance(it->instanceId); + it = activeSpellVisuals_.erase(it); + } else { + ++it; + } + } +} + void Renderer::triggerMeleeSwing() { if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown > 0.0f) return; @@ -3012,6 +3196,8 @@ void Renderer::update(float deltaTime) { if (chargeEffect) { chargeEffect->update(deltaTime); } + // Update transient spell visual instances + updateSpellVisuals(deltaTime); // Launch M2 doodad animation on background thread (overlaps with character animation + audio) diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 8163628d..cc278b5f 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -371,6 +371,7 @@ void WorldMap::loadZonesFromDBC() { } } + currentMapId_ = mapID; LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID, ", continentIdx=", continentIdx); } @@ -1059,6 +1060,69 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Taxi node markers — flight master icons on the map + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !taxiNodes_.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + for (const auto& node : taxiNodes_) { + if (!node.known) continue; + if (static_cast(node.mapId) != currentMapId_) continue; + + glm::vec3 rPos = core::coords::canonicalToRender( + glm::vec3(node.wowX, node.wowY, node.wowZ)); + glm::vec2 uv = renderPosToMapUV(rPos, currentIdx); + if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; + + float px = imgMin.x + uv.x * displayW; + float py = imgMin.y + uv.y * displayH; + + // Flight-master icon: yellow diamond with dark border + constexpr float H = 5.0f; // half-size of diamond + ImVec2 top2(px, py - H); + ImVec2 right2(px + H, py ); + ImVec2 bot2(px, py + H); + ImVec2 left2(px - H, py ); + drawList->AddQuadFilled(top2, right2, bot2, left2, + IM_COL32(255, 215, 0, 230)); + drawList->AddQuad(top2, right2, bot2, left2, + IM_COL32(80, 50, 0, 200), 1.2f); + + // Tooltip on hover + if (!node.name.empty()) { + float mdx = mp.x - px, mdy = mp.y - py; + if (mdx * mdx + mdy * mdy < 49.0f) { + ImGui::SetTooltip("%s\n(Flight Master)", node.name.c_str()); + } + } + } + } + + // Corpse marker — skull X shown when player is a ghost with unclaimed corpse + if (hasCorpse_ && currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + glm::vec2 uv = renderPosToMapUV(corpseRenderPos_, currentIdx); + if (uv.x >= 0.0f && uv.x <= 1.0f && uv.y >= 0.0f && uv.y <= 1.0f) { + float cx = imgMin.x + uv.x * displayW; + float cy = imgMin.y + uv.y * displayH; + constexpr float R = 5.0f; // cross arm half-length + constexpr float T = 1.8f; // line thickness + // Dark outline + drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), + IM_COL32(0, 0, 0, 220), T + 1.5f); + drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), + IM_COL32(0, 0, 0, 220), T + 1.5f); + // Bone-white X + drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), + IM_COL32(230, 220, 200, 240), T); + drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), + IM_COL32(230, 220, 200, 240), T); + // Tooltip on hover + ImVec2 mp = ImGui::GetMousePos(); + float dx = mp.x - cx, dy = mp.y - cy; + if (dx * dx + dy * dy < 64.0f) { + ImGui::SetTooltip("Your corpse"); + } + } + } + // Hover coordinate display — show WoW coordinates under cursor if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { auto& io = ImGui::GetIO(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 771a1ba2..8cd7a6c8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -592,8 +592,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } - // Apply auto-loot setting to GameHandler every frame (cheap bool sync) + // Apply auto-loot / auto-sell settings to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); + gameHandler.setAutoSellGrey(pendingAutoSellGrey); + gameHandler.setAutoRepair(pendingAutoRepair); // Zone entry detection — fire a toast when the renderer's zone name changes if (auto* rend = core::Application::getInstance().getRenderer()) { @@ -627,6 +629,12 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPetFrame(gameHandler); } + // Auto-open pet rename modal when server signals the pet is renameable (first tame) + if (gameHandler.consumePetRenameablePending()) { + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } + // Totem frame (Shaman only, when any totem is active) if (gameHandler.getPlayerClass() == 7) { renderTotemFrame(gameHandler); @@ -657,6 +665,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // ---- New UI elements ---- renderActionBar(gameHandler); + renderStanceBar(gameHandler); renderBagBar(gameHandler); renderXpBar(gameHandler); renderRepBar(gameHandler); @@ -715,6 +724,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWhoWindow(gameHandler); renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); + renderSkillsWindow(gameHandler); renderTitlesWindow(gameHandler); renderEquipSetWindow(gameHandler); renderGmTicketWindow(gameHandler); @@ -731,6 +741,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); renderTalentWipeConfirmDialog(gameHandler); + renderPetUnlearnConfirmDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -743,7 +754,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPvpHonorToasts(); renderItemLootToasts(); renderResurrectFlash(); - renderZoneText(); + renderZoneText(gameHandler); + renderWeatherOverlay(gameHandler); // World map (M key toggle handled inside) renderWorldMap(gameHandler); @@ -1380,6 +1392,25 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique / Unique-Equipped + if (info->maxCount == 1) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + // Slot type if (info->inventoryType > 0) { const char* slotName = ""; @@ -1432,10 +1463,23 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { }; const bool isWeapon = isWeaponInventoryType(info->inventoryType); + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { float speed = static_cast(info->delayMs) / 1000.0f; float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + // WoW-style: "22 - 41 Damage" with speed right-aligned on same row + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); } ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { @@ -1456,6 +1500,331 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (info->armor > 0) { ImGui::Text("%d Armor", info->armor); } + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + // Gem sockets (WotLK only — socketColor != 0 means socket present) + // socketColor bitmask: 1=Meta, 2=Red, 4=Yellow, 8=Blue + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + // Socket bonus ID maps to SpellItemEnchantment.dbc — lazy-load names + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->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()) + 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); + } + } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = {"Item0","Item1","Item2","Item3","Item4","Item5","Item6","Item7","Item8","Item9"}; + static const char* spellKeys[10] = {"Spell0","Spell1","Spell2","Spell3","Spell4","Spell5","Spell6","Spell7","Spell8","Spell9"}; + static const char* thrKeys[10] = {"Threshold0","Threshold1","Threshold2","Threshold3","Threshold4","Threshold5","Threshold6","Threshold7","Threshold8","Threshold9"}; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info->itemSetId); + } + } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + // Use full spell description if available (matches inventory tooltip style) + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } + } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill (e.g. "Requires Blacksmithing (300)") + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info->allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClasses[] = { + { 1, "Warrior" }, + { 2, "Paladin" }, + { 4, "Hunter" }, + { 8, "Rogue" }, + { 16, "Priest" }, + { 32, "Death Knight" }, + { 64, "Shaman" }, + { 128, "Mage" }, + { 256, "Warlock" }, + { 1024, "Druid" }, + }; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction (e.g. "Races: Night Elf, Human") + if (info->allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRaces[] = { + { 1, "Human" }, + { 2, "Orc" }, + { 4, "Dwarf" }, + { 8, "Night Elf" }, + { 16, "Undead" }, + { 32, "Tauren" }, + { 64, "Gnome" }, + { 128, "Troll" }, + { 512, "Blood Elf" }, + { 1024, "Draenei" }, + }; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } if (info->sellPrice > 0) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; @@ -1478,7 +1847,15 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { float speed = static_cast(eq->item.delayMs) / 1000.0f; float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); } if (eq->item.armor > 0) { ImGui::Text("%d Armor", eq->item.armor); @@ -1492,6 +1869,28 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (!eqBonusLine.empty()) { ImGui::TextColored(green, "%s", eqBonusLine.c_str()); } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } } } ImGui::EndTooltip(); @@ -2375,6 +2774,9 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { showAchievementWindow_ = !showAchievementWindow_; } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SKILLS)) { + showSkillsWindow_ = !showSkillsWindow_; + } // Toggle Titles window with H (hero/title screen — no conflicting keybinding) if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { @@ -2388,9 +2790,43 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS }; const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlDown = input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL); const auto& bar = gameHandler.getActionBar(); + + // Ctrl+1..Ctrl+8 → switch stance/form/presence (WoW default bindings). + // Only fires for classes that use a stance bar; same slot ordering as + // renderStanceBar: Warrior, DK, Druid, Rogue, Priest. + if (ctrlDown) { + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + static const uint32_t rogueForms[] = { 1784 }; + static const uint32_t priestForms[] = { 15473 }; + const uint32_t* stArr = nullptr; int stCnt = 0; + switch (gameHandler.getPlayerClass()) { + case 1: stArr = warriorStances; stCnt = 3; break; + case 6: stArr = dkPresences; stCnt = 3; break; + case 11: stArr = druidForms; stCnt = 9; break; + case 4: stArr = rogueForms; stCnt = 1; break; + case 5: stArr = priestForms; stCnt = 1; break; + } + if (stArr) { + const auto& known = gameHandler.getKnownSpells(); + // Build available list (same order as UI) + std::vector avail; + avail.reserve(stCnt); + for (int i = 0; i < stCnt; ++i) + if (known.count(stArr[i])) avail.push_back(stArr[i]); + // Ctrl+1 = first stance, Ctrl+2 = second, … + for (int i = 0; i < static_cast(avail.size()) && i < 8; ++i) { + if (input.isKeyJustPressed(actionBarKeys[i])) + gameHandler.castSpell(avail[i]); + } + } + } + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (input.isKeyJustPressed(actionBarKeys[i])) { + if (!ctrlDown && input.isKeyJustPressed(actionBarKeys[i])) { int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; @@ -3363,6 +3799,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. bool autocastOn = gameHandler.isPetSpellAutocast(actionId); + // Cooldown tracking for pet spells (actionId > 6 are spell IDs) + float petCd = (actionId > 6) ? gameHandler.getSpellCooldown(actionId) : 0.0f; + bool petOnCd = (petCd > 0.0f); + ImGui::PushID(i); if (rendered > 0) ImGui::SameLine(0.0f, spacing); @@ -3377,9 +3817,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { else if (actionId == 6) builtinLabel = "Agg"; else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); - // Tint green when autocast is on. - ImVec4 tint = autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) - : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + // Dim when on cooldown; tint green when autocast is on + ImVec4 tint = petOnCd + ? ImVec4(0.35f, 0.35f, 0.35f, 0.7f) + : (autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 1.0f, 1.0f, 1.0f)); bool clicked = false; if (iconTex) { clicked = ImGui::ImageButton("##pa", @@ -3397,14 +3838,35 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100); else snprintf(label, sizeof(label), "%.3s", nm.c_str()); } - ImGui::PushStyleColor(ImGuiCol_Button, - autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) - : ImVec4(0.2f,0.2f,0.3f,0.9f)); + ImVec4 btnCol = petOnCd ? ImVec4(0.1f,0.1f,0.15f,0.9f) + : (autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) + : ImVec4(0.2f,0.2f,0.3f,0.9f)); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); ImGui::PopStyleColor(); } - if (clicked) { + // Cooldown overlay: dark fill + time text centered on the button + if (petOnCd && !builtinLabel) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* cdDL = ImGui::GetWindowDrawList(); + cdDL->AddRectFilled(bMin, bMax, IM_COL32(0, 0, 0, 140)); + char cdTxt[8]; + if (petCd >= 60.0f) + snprintf(cdTxt, sizeof(cdTxt), "%dm", static_cast(petCd / 60.0f)); + else if (petCd >= 1.0f) + snprintf(cdTxt, sizeof(cdTxt), "%d", static_cast(petCd)); + else + snprintf(cdTxt, sizeof(cdTxt), "%.1f", petCd); + ImVec2 tsz = ImGui::CalcTextSize(cdTxt); + float cx = (bMin.x + bMax.x) * 0.5f; + float cy = (bMin.y + bMax.y) * 0.5f; + cdDL->AddText(ImVec2(cx - tsz.x * 0.5f, cy - tsz.y * 0.5f), + IM_COL32(255, 255, 255, 230), cdTxt); + } + + if (clicked && !petOnCd) { // Send pet action; use current target for spells. uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; gameHandler.sendPetAction(slotVal, targetGuid); @@ -3432,6 +3894,15 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { } if (autocastOn) ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); + if (petOnCd) { + if (petCd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", + static_cast(petCd) / 60, static_cast(petCd) % 60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %.1f sec", petCd); + } ImGui::EndTooltip(); } } @@ -3830,14 +4301,19 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float castPct = gameHandler.getTargetCastProgress(); float castLeft = gameHandler.getTargetCastTimeRemaining(); uint32_t tspell = gameHandler.getTargetCastSpellId(); + bool interruptible = gameHandler.isTargetCastInterruptible(); const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; - // Pulse bright orange when cast is > 80% complete — interrupt window closing + // Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80% ImVec4 castBarColor; if (castPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - castBarColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + if (interruptible) + castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse + else + castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse } else { - castBarColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + castBarColor = interruptible ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) // green = can interrupt + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); char castLabel[72]; @@ -4003,6 +4479,32 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { std::chrono::steady_clock::now().time_since_epoch()).count()); int32_t tRemainMs = aura.getRemainingMs(tNowMs); + // Clock-sweep overlay (elapsed = dark area, WoW style) + if (tRemainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 tIconMin = ImGui::GetItemRectMin(); + ImVec2 tIconMax = ImGui::GetItemRectMax(); + float tcx = (tIconMin.x + tIconMax.x) * 0.5f; + float tcy = (tIconMin.y + tIconMax.y) * 0.5f; + float tR = (tIconMax.x - tIconMin.x) * 0.5f; + float tTot = static_cast(aura.maxDurationMs); + float tFrac = std::clamp( + 1.0f - static_cast(tRemainMs) / tTot, 0.0f, 1.0f); + if (tFrac > 0.005f) { + constexpr int TSEGS = 24; + float tSa = -IM_PI * 0.5f; + float tEa = tSa + tFrac * 2.0f * IM_PI; + ImVec2 tPts[TSEGS + 2]; + tPts[0] = ImVec2(tcx, tcy); + for (int s = 0; s <= TSEGS; ++s) { + float a = tSa + (tEa - tSa) * s / static_cast(TSEGS); + tPts[s + 1] = ImVec2(tcx + std::cos(a) * tR, + tcy + std::sin(a) * tR); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + tPts, TSEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + // Duration countdown overlay if (tRemainMs > 0) { ImVec2 iconMin = ImGui::GetItemRectMin(); @@ -4156,16 +4658,20 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - // ToT cast bar — orange-yellow, pulses when near completion + // ToT cast bar — green if interruptible, red if not; pulses near completion if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { float totCastPct = (totCs->timeTotal > 0.0f) ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; ImVec4 tcColor; if (totCastPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - tcColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + tcColor = totCs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); } else { - tcColor = ImVec4(0.8f, 0.5f, 0.1f, 1.0f); + tcColor = totCs->interruptible + ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); char tcLabel[48]; @@ -6224,6 +6730,28 @@ ImVec4 GameScreen::getChatTypeColor(game::ChatType type) const { return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink case game::ChatType::ACHIEVEMENT: return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow + case game::ChatType::GUILD_ACHIEVEMENT: + return ImVec4(1.0f, 0.84f, 0.0f, 1.0f); // Gold + case game::ChatType::SKILL: + return ImVec4(0.0f, 0.8f, 1.0f, 1.0f); // Cyan + case game::ChatType::LOOT: + return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple + case game::ChatType::MONSTER_WHISPER: + case game::ChatType::RAID_BOSS_WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) + case game::ChatType::RAID_BOSS_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::MONSTER_PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) + case game::ChatType::BG_SYSTEM_NEUTRAL: + return ImVec4(1.0f, 0.84f, 0.0f, 1.0f); // Gold + case game::ChatType::BG_SYSTEM_ALLIANCE: + return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue + case game::ChatType::BG_SYSTEM_HORDE: + return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red + case game::ChatType::AFK: + case game::ChatType::DND: + return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray default: return ImVec4(0.7f, 0.7f, 0.7f, 1.0f); // Gray } @@ -6553,6 +7081,36 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { wm->setPartyDots(std::move(dots)); } + // Taxi node markers on world map + { + std::vector taxiNodes; + const auto& nodes = gameHandler.getTaxiNodes(); + taxiNodes.reserve(nodes.size()); + for (const auto& [id, node] : nodes) { + rendering::WorldMapTaxiNode wtn; + wtn.id = node.id; + wtn.mapId = node.mapId; + wtn.wowX = node.x; + wtn.wowY = node.y; + wtn.wowZ = node.z; + wtn.name = node.name; + wtn.known = gameHandler.isKnownTaxiNode(id); + taxiNodes.push_back(std::move(wtn)); + } + wm->setTaxiNodes(std::move(taxiNodes)); + } + + // Corpse marker: show skull X on world map when ghost with unclaimed corpse + { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + bool ghostWithCorpse = gameHandler.isPlayerGhost() && + gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY); + glm::vec3 corpseRender = ghostWithCorpse + ? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)) + : glm::vec3{}; + wm->setCorpsePos(ghostWithCorpse, corpseRender); + } + glm::vec3 playerPos = renderer->getCharacterPosition(); float playerYaw = renderer->getCharacterYaw(); auto* window = app.getWindow(); @@ -7337,6 +7895,142 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } +// ============================================================ +// Stance / Form / Presence Bar +// Shown for Warriors (stances), Death Knights (presences), +// Druids (shapeshift forms), Rogues (stealth), Priests (Shadowform). +// Buttons display the player's known stance/form spells. +// Active form is detected by checking permanent player auras. +// ============================================================ + +void GameScreen::renderStanceBar(game::GameHandler& gameHandler) { + uint8_t playerClass = gameHandler.getPlayerClass(); + + // Stance/form spell IDs per class (ordered by display priority) + // Class IDs: 1=Warrior, 4=Rogue, 5=Priest, 6=DeathKnight, 11=Druid + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; // Battle, Defensive, Berserker + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; // Blood, Frost, Unholy + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + // Bear, DireBear, Cat, Travel, Aquatic, Moonkin, Tree, Flight, SwiftFlight + static const uint32_t rogueForms[] = { 1784 }; // Stealth + static const uint32_t priestForms[] = { 15473 }; // Shadowform + + const uint32_t* stanceArr = nullptr; + int stanceCount = 0; + switch (playerClass) { + case 1: stanceArr = warriorStances; stanceCount = 3; break; + case 6: stanceArr = dkPresences; stanceCount = 3; break; + case 11: stanceArr = druidForms; stanceCount = 9; break; + case 4: stanceArr = rogueForms; stanceCount = 1; break; + case 5: stanceArr = priestForms; stanceCount = 1; break; + default: return; + } + + // Filter to spells the player actually knows + const auto& known = gameHandler.getKnownSpells(); + std::vector available; + available.reserve(stanceCount); + for (int i = 0; i < stanceCount; ++i) + if (known.count(stanceArr[i])) available.push_back(stanceArr[i]); + + if (available.empty()) return; + + // Detect active stance from permanent player auras (maxDurationMs == -1) + uint32_t activeStance = 0; + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.isEmpty() || aura.maxDurationMs != -1) continue; + for (uint32_t sid : available) { + if (aura.spellId == sid) { activeStance = sid; break; } + } + if (activeStance) break; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + // Match the action bar slot size so they align neatly + float slotSize = 38.0f; + float spacing = 4.0f; + float padding = 6.0f; + int count = static_cast(available.size()); + + float barW = count * slotSize + (count - 1) * spacing + padding * 2.0f; + float barH = slotSize + padding * 2.0f; + + // Position the stance bar immediately to the left of the action bar + float actionSlot = 48.0f * pendingActionBarScale; + float actionBarW = 12.0f * actionSlot + 11.0f * 4.0f + 8.0f * 2.0f; + float actionBarX = (screenW - actionBarW) / 2.0f; + float actionBarH = actionSlot + 24.0f; + float actionBarY = screenH - actionBarH; + + float barX = actionBarX - barW - 8.0f; + float barY = actionBarY + (actionBarH - barH) / 2.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##StanceBar", nullptr, flags)) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < count; ++i) { + if (i > 0) ImGui::SameLine(0.0f, spacing); + ImGui::PushID(i); + + uint32_t spellId = available[i]; + bool isActive = (spellId == activeStance); + + VkDescriptorSet iconTex = assetMgr ? getSpellIcon(spellId, assetMgr) : VK_NULL_HANDLE; + + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImVec2 posEnd = ImVec2(pos.x + slotSize, pos.y + slotSize); + + // Background — green tint when active + ImU32 bgCol = isActive ? IM_COL32(30, 70, 30, 230) : IM_COL32(20, 20, 20, 220); + ImU32 borderCol = isActive ? IM_COL32(80, 220, 80, 255) : IM_COL32(80, 80, 80, 200); + dl->AddRectFilled(pos, posEnd, bgCol, 4.0f); + + if (iconTex) { + dl->AddImage((ImTextureID)(uintptr_t)iconTex, pos, posEnd); + // Darken inactive buttons slightly + if (!isActive) + dl->AddRectFilled(pos, posEnd, IM_COL32(0, 0, 0, 70), 4.0f); + } + dl->AddRect(pos, posEnd, borderCol, 4.0f, 0, 2.0f); + + ImGui::InvisibleButton("##btn", ImVec2(slotSize, slotSize)); + + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) + gameHandler.castSpell(spellId); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) ImGui::TextUnformatted(name.c_str()); + else ImGui::Text("Spell #%u", spellId); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); +} + // ============================================================ // Bag Bar // ============================================================ @@ -7622,8 +8316,12 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { - uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); - if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) + uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); + uint32_t playerLevel = gameHandler.getPlayerLevel(); + // At max level, server sends nextLevelXp=0. Only skip entirely when we have + // no level info at all (not yet logged in / no update-field data). + const bool isMaxLevel = (nextLevelXp == 0 && playerLevel > 0); + if (nextLevelXp == 0 && !isMaxLevel) return; uint32_t currentXp = gameHandler.getPlayerXp(); uint32_t restedXp = gameHandler.getPlayerRestedXp(); @@ -7669,15 +8367,32 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); if (ImGui::Begin("##XpBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* drawList = ImGui::GetWindowDrawList(); + + if (isMaxLevel) { + // Max-level bar: fully filled in muted gold with "Max Level" label + ImU32 bgML = IM_COL32(15, 12, 5, 220); + ImU32 fgML = IM_COL32(180, 140, 40, 200); + drawList->AddRectFilled(barMin, barMax, bgML, 2.0f); + drawList->AddRectFilled(barMin, barMax, fgML, 2.0f); + drawList->AddRect(barMin, barMax, IM_COL32(100, 80, 20, 220), 2.0f); + const char* mlLabel = "Max Level"; + ImVec2 mlSz = ImGui::CalcTextSize(mlLabel); + drawList->AddText( + ImVec2(barMin.x + (barSize.x - mlSz.x) * 0.5f, + barMin.y + (barSize.y - mlSz.y) * 0.5f), + IM_COL32(255, 230, 120, 255), mlLabel); + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Level %u — Maximum level reached", playerLevel); + } else { float pct = static_cast(currentXp) / static_cast(nextLevelXp); if (pct > 1.0f) pct = 1.0f; // Custom segmented XP bar (20 bubbles) - ImVec2 barMin = ImGui::GetCursorScreenPos(); - ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); - ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); - auto* drawList = ImGui::GetWindowDrawList(); - ImU32 bg = IM_COL32(15, 15, 20, 220); ImU32 fg = IM_COL32(148, 51, 238, 255); ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion @@ -7753,6 +8468,7 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } } + } ImGui::End(); ImGui::PopStyleColor(2); @@ -7917,9 +8633,20 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ? (1.0f - gameHandler.getCastProgress()) : gameHandler.getCastProgress(); - ImVec4 barColor = channeling - ? ImVec4(0.3f, 0.6f, 0.9f, 1.0f) // blue for channels - : ImVec4(0.8f, 0.6f, 0.2f, 1.0f); // gold for casts + // Color by spell school for cast identification; channels always blue + ImVec4 barColor; + if (channeling) { + barColor = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); // blue for channels + } else { + uint32_t school = (currentSpellId != 0) ? gameHandler.getSpellSchoolMask(currentSpellId) : 0; + if (school & 0x04) barColor = ImVec4(0.95f, 0.40f, 0.10f, 1.0f); // Fire: orange-red + else if (school & 0x10) barColor = ImVec4(0.30f, 0.65f, 0.95f, 1.0f); // Frost: icy blue + else if (school & 0x20) barColor = ImVec4(0.55f, 0.15f, 0.70f, 1.0f); // Shadow: purple + else if (school & 0x40) barColor = ImVec4(0.65f, 0.30f, 0.85f, 1.0f); // Arcane: violet + else if (school & 0x08) barColor = ImVec4(0.20f, 0.75f, 0.25f, 1.0f); // Nature: green + else if (school & 0x02) barColor = ImVec4(0.90f, 0.80f, 0.30f, 1.0f); // Holy: golden + else barColor = ImVec4(0.80f, 0.60f, 0.20f, 1.0f); // Physical/default: gold + } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); char overlay[96]; @@ -8574,11 +9301,16 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) : ImVec4(0.5f, 0.9f, 1.0f, alpha); break; - case game::CombatTextEntry::REFLECT: - snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) : ImVec4(0.75f, 0.85f, 1.0f, alpha); break; + } case game::CombatTextEntry::PROC_TRIGGER: { const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; if (!procName.empty()) @@ -8626,6 +9358,22 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) : ImVec4(1.0f, 0.1f, 0.1f, alpha); break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for honor + break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : // Outgoing glancing = muted yellow + ImVec4(0.75f, 0.35f, 0.35f, alpha); // Incoming glancing = muted red + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : // Outgoing crushing = orange + ImVec4(1.0f, 0.15f, 0.15f, alpha); // Incoming crushing = bright red + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -8696,6 +9444,8 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { case game::CombatTextEntry::SPELL_DAMAGE: case game::CombatTextEntry::CRIT_DAMAGE: case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: dpsEncounterDamage_ += static_cast(e.amount); break; case game::CombatTextEntry::HEAL: @@ -8720,6 +9470,8 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { case game::CombatTextEntry::SPELL_DAMAGE: case game::CombatTextEntry::CRIT_DAMAGE: case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: totalDamage += static_cast(e.amount); break; case game::CombatTextEntry::HEAL: @@ -8942,9 +9694,25 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); } + // Check if this unit is targeting the local player (threat indicator) + bool isTargetingPlayer = false; + if (unit->isHostile() && !isCorpse) { + const auto& fields = entityPtr->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end() && loIt->second != 0) { + uint64_t unitTarget = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + unitTarget |= (static_cast(hiIt->second) << 32); + isTargetingPlayer = (unitTarget == playerGuid); + } + } + // Border: gold = currently selected, orange = targeting player, dark = default ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) - : IM_COL32(20, 20, 20, A(180)); + : isTargetingPlayer + ? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you + : IM_COL32(20, 20, 20, A(180)); // Bar geometry const float barW = 80.0f * nameplateScale_; @@ -8994,14 +9762,18 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { castBarBaseY += snSz.y + 2.0f; } - // Cast bar background + fill (pulse orange when >80% = interrupt window closing) - ImU32 cbBg = IM_COL32(40, 30, 60, A(180)); + // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete + ImU32 cbBg = IM_COL32(30, 25, 40, A(180)); ImU32 cbFill; if (castPct > 0.8f && unit->isHostile()) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - cbFill = IM_COL32(static_cast(255 * pulse), static_cast(130 * pulse), 0, A(220)); + cbFill = cs->interruptible + ? IM_COL32(static_cast(40 * pulse), static_cast(220 * pulse), static_cast(40 * pulse), A(220)) // green pulse + : IM_COL32(static_cast(255 * pulse), static_cast(30 * pulse), static_cast(30 * pulse), A(220)); // red pulse } else { - cbFill = IM_COL32(140, 80, 220, A(200)); // purple cast bar + cbFill = cs->interruptible + ? IM_COL32(50, 190, 50, A(200)) // green = interruptible + : IM_COL32(190, 40, 40, A(200)); // red = uninterruptible } drawList->AddRectFilled(ImVec2(barX, castBarBaseY), ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); @@ -10421,13 +11193,17 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { uint32_t bspell = cs->spellId; const std::string& bcastName = (bspell != 0) ? gameHandler.getSpellName(bspell) : ""; - // Pulse bright orange when > 80% complete — interrupt window closing + // Green = interruptible, Red = immune; pulse when > 80% complete ImVec4 bcastColor; if (castPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - bcastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + bcastColor = cs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); } else { - bcastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + bcastColor = cs->interruptible + ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); char bcastLabel[72]; @@ -12485,6 +13261,32 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { std::chrono::steady_clock::now().time_since_epoch()).count()); int32_t remainMs = aura.getRemainingMs(nowMs); + // Clock-sweep overlay: dark fan shows elapsed time (WoW style) + if (remainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 iconMin2 = ImGui::GetItemRectMin(); + ImVec2 iconMax2 = ImGui::GetItemRectMax(); + float cx2 = (iconMin2.x + iconMax2.x) * 0.5f; + float cy2 = (iconMin2.y + iconMax2.y) * 0.5f; + float fanR2 = (iconMax2.x - iconMin2.x) * 0.5f; + float total2 = static_cast(aura.maxDurationMs); + float elapsedFrac2 = std::clamp( + 1.0f - static_cast(remainMs) / total2, 0.0f, 1.0f); + if (elapsedFrac2 > 0.005f) { + constexpr int SWEEP_SEGS = 24; + float sa = -IM_PI * 0.5f; + float ea = sa + elapsedFrac2 * 2.0f * IM_PI; + ImVec2 pts[SWEEP_SEGS + 2]; + pts[0] = ImVec2(cx2, cy2); + for (int s = 0; s <= SWEEP_SEGS; ++s) { + float a = sa + (ea - sa) * s / static_cast(SWEEP_SEGS); + pts[s + 1] = ImVec2(cx2 + std::cos(a) * fanR2, + cy2 + std::sin(a) * fanR2); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + pts, SWEEP_SEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + // Duration countdown overlay — always visible on the icon bottom if (remainMs > 0) { ImVec2 iconMin = ImGui::GetItemRectMin(); @@ -12682,6 +13484,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { itemName = "Item #" + std::to_string(item.itemId); } ImVec4 qColor = InventoryScreen::getQualityColor(quality); + bool startsQuest = (info && info->startQuestId != 0); // Get item icon uint32_t displayId = item.displayInfoId; @@ -12742,6 +13545,14 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), IM_COL32(80, 80, 80, 200)); } + // Quest-starter: gold outer glow border + "!" badge on top-right corner + if (startsQuest) { + drawList->AddRect(ImVec2(cursor.x - 2.0f, cursor.y - 2.0f), + ImVec2(cursor.x + iconSize + 2.0f, cursor.y + iconSize + 2.0f), + IM_COL32(255, 210, 0, 210), 0.0f, 0, 2.0f); + drawList->AddText(ImVec2(cursor.x + iconSize - 10.0f, cursor.y + 1.0f), + IM_COL32(255, 210, 0, 255), "!"); + } // Draw item name float textX = cursor.x + iconSize + 6.0f; @@ -12749,12 +13560,15 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(textX, textY), ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str()); - // Draw count if > 1 - if (item.count > 1) { + // Draw count or "Begins a Quest" label on second line + float secondLineY = textY + ImGui::GetTextLineHeight(); + if (startsQuest) { + drawList->AddText(ImVec2(textX, secondLineY), + IM_COL32(255, 210, 0, 255), "Begins a Quest"); + } else if (item.count > 1) { char countStr[32]; snprintf(countStr, sizeof(countStr), "x%u", item.count); - float countY = textY + ImGui::GetTextLineHeight(); - drawList->AddText(ImVec2(textX, countY), IM_COL32(200, 200, 200, 220), countStr); + drawList->AddText(ImVec2(textX, secondLineY), IM_COL32(200, 200, 200, 220), countStr); } ImGui::PopID(); @@ -14767,6 +15581,74 @@ void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +void GameScreen::renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showPetUnlearnDialog()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##PetUnlearnDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getPetUnlearnCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = std::string("Reset your pet's talents for ") + costStr + "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All pet talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f)); + if (ImGui::Button("Confirm##petunlearn", ImVec2(btnW, 30))) { + gameHandler.confirmPetUnlearn(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f)); + if (ImGui::Button("Cancel##petunlearn", ImVec2(btnW, 30))) { + gameHandler.cancelPetUnlearn(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Settings Window // ============================================================ @@ -15539,6 +16421,16 @@ void GameScreen::renderSettingsWindow() { } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Automatically pick up all items when looting"); + if (ImGui::Checkbox("Auto Sell Greys", &pendingAutoSellGrey)) { + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically sell all grey (poor quality) items when opening a vendor"); + if (ImGui::Checkbox("Auto Repair", &pendingAutoRepair)) { + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically repair all damaged equipment when opening an armorer vendor"); ImGui::Spacing(); ImGui::Text("Bags"); @@ -16586,6 +17478,55 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + { + const auto& bgPositions = gameHandler.getBgPlayerPositions(); + if (!bgPositions.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + // group 0 = typically ally-held flag / first list; group 1 = enemy + static const ImU32 kBgGroupColors[2] = { + IM_COL32( 80, 180, 255, 240), // group 0: blue (alliance) + IM_COL32(220, 50, 50, 240), // group 1: red (horde) + }; + for (const auto& bp : bgPositions) { + // Packet coords: wowX=canonical X (north), wowY=canonical Y (west) + glm::vec3 bpRender = core::coords::canonicalToRender(glm::vec3(bp.wowX, bp.wowY, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(bpRender, sx, sy)) continue; + + ImU32 col = kBgGroupColors[bp.group & 1]; + + // Draw a flag-like diamond icon + const float r = 5.0f; + ImVec2 top (sx, sy - r); + ImVec2 right(sx + r, sy ); + ImVec2 bot (sx, sy + r); + ImVec2 left (sx - r, sy ); + drawList->AddQuadFilled(top, right, bot, left, col); + drawList->AddQuad(top, right, bot, left, IM_COL32(255, 255, 255, 180), 1.0f); + + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + // Show entity name if available, otherwise guid + auto ent = gameHandler.getEntityManager().getEntity(bp.guid); + if (ent) { + std::string nm; + if (ent->getType() == game::ObjectType::PLAYER) { + auto pl = std::static_pointer_cast(ent); + nm = pl ? pl->getName() : ""; + } + if (!nm.empty()) + ImGui::SetTooltip("Flag carrier: %s", nm.c_str()); + else + ImGui::SetTooltip("Flag carrier"); + } else { + ImGui::SetTooltip("Flag carrier"); + } + } + } + } + } + // Corpse direction indicator — shown when player is a ghost if (gameHandler.isPlayerGhost()) { float corpseCanX = 0.0f, corpseCanY = 0.0f; @@ -16654,6 +17595,46 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Player position arrow at minimap center, pointing in camera facing direction. + // On a rotating minimap the map already turns so forward = screen-up; on a fixed + // minimap we rotate the arrow to match the player's compass heading. + { + // Compute screen-space facing direction for the arrow. + // bearing = clockwise angle from screen-north (0 = facing north/up). + float arrowAngle = 0.0f; // 0 = pointing up (north) + if (!minimap->isRotateWithCamera()) { + // Fixed minimap: arrow must show actual facing relative to north. + glm::vec3 fwd = camera->getForward(); + // +render_y = north = screen-up, +render_x = west = screen-left. + // bearing from north clockwise: atan2(-fwd.x_west, fwd.y_north) + // => sin=east component, cos=north component + // In render coords west=+x, east=-x, so sin(bearing)=east=-fwd.x + arrowAngle = std::atan2(-fwd.x, fwd.y); // clockwise from north in screen space + } + // Screen direction the arrow tip points toward + float nx = std::sin(arrowAngle); // screen +X = east + float ny = -std::cos(arrowAngle); // screen -Y = north + + // Draw a chevron-style arrow: tip, two base corners, and a notch at the back + const float tipLen = 8.0f; // tip forward distance + const float baseW = 5.0f; // half-width at base + const float notchIn = 3.0f; // how far back the center notch sits + // Perpendicular direction (rotated 90°) + float px = ny; // perpendicular x + float py = -nx; // perpendicular y + + ImVec2 tip (centerX + nx * tipLen, centerY + ny * tipLen); + ImVec2 baseL(centerX - nx * baseW + px * baseW, centerY - ny * baseW + py * baseW); + ImVec2 baseR(centerX - nx * baseW - px * baseW, centerY - ny * baseW - py * baseW); + ImVec2 notch(centerX - nx * (baseW - notchIn), centerY - ny * (baseW - notchIn)); + + // Fill: bright white with slight gold tint, dark outline for readability + drawList->AddTriangleFilled(tip, baseL, notch, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangleFilled(tip, notch, baseR, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangle(tip, baseL, notch, IM_COL32(60, 40, 0, 200), 1.2f); + drawList->AddTriangle(tip, notch, baseR, IM_COL32(60, 40, 0, 200), 1.2f); + } + // Scroll wheel over minimap → zoom in/out { float wheel = ImGui::GetIO().MouseWheel; @@ -16715,6 +17696,37 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); } + // Local time clock — displayed just below the coordinate label + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + std::tm tmLocal{}; +#if defined(_WIN32) + localtime_s(&tmLocal, &tt); +#else + localtime_r(&tt, &tmLocal); +#endif + char clockBuf[16]; + std::snprintf(clockBuf, sizeof(clockBuf), "%02d:%02d", + tmLocal.tm_hour, tmLocal.tm_min); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.9f; // slightly smaller than coords + ImVec2 clockSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, clockBuf); + + float tx = centerX - clockSz.x * 0.5f; + // Position below the coordinate line (+fontSize of coord + 2px gap) + float coordLineH = ImGui::GetFontSize(); + float ty = centerY + mapRadius + 3.0f + coordLineH + 2.0f; + + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + clockSz.x + pad, ty + clockSz.y + pad), + IM_COL32(0, 0, 0, 120), 3.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(200, 200, 220, 220), clockBuf); + } + // Zone name display — drawn inside the top edge of the minimap circle { auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; @@ -16838,8 +17850,15 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { }; // Zone name label above the minimap (centered, WoW-style) + // Prefer the server-reported zone/area name (from SMSG_INIT_WORLD_STATES) so sub-zones + // like Ironforge or Wailing Caverns display correctly; fall back to renderer zone name. { - const std::string& zoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + std::string wsZoneName; + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0) + wsZoneName = gameHandler.getWhoAreaName(wsZoneId); + const std::string& rendererZoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + const std::string& zoneName = !wsZoneName.empty() ? wsZoneName : rendererZoneName; if (!zoneName.empty()) { auto* fgDl = ImGui::GetForegroundDrawList(); float zoneTextY = centerY - mapRadius - 16.0f; @@ -17501,6 +18520,8 @@ void GameScreen::saveSettings() { // Gameplay out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; + out << "auto_sell_grey=" << (pendingAutoSellGrey ? 1 : 0) << "\n"; + out << "auto_repair=" << (pendingAutoRepair ? 1 : 0) << "\n"; out << "graphics_preset=" << static_cast(currentGraphicsPreset) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; @@ -17642,6 +18663,8 @@ void GameScreen::loadSettings() { else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); // Gameplay else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); + else if (key == "auto_sell_grey") pendingAutoSellGrey = (std::stoi(val) != 0); + else if (key == "auto_repair") pendingAutoRepair = (std::stoi(val) != 0); else if (key == "graphics_preset") { int presetVal = std::clamp(std::stoi(val), 0, 4); currentGraphicsPreset = static_cast(presetVal); @@ -19762,15 +20785,31 @@ void GameScreen::renderWhisperToasts() { // Zone discovery text — "Entering: " fades in/out at screen centre // --------------------------------------------------------------------------- -void GameScreen::renderZoneText() { - // Poll the renderer for zone name changes +void GameScreen::renderZoneText(game::GameHandler& gameHandler) { + // Poll worldStateZoneId for server-driven zone changes (fires on every zone crossing, + // including sub-zones like Ironforge within Dun Morogh). + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0 && wsZoneId != lastKnownWorldStateZoneId_) { + lastKnownWorldStateZoneId_ = wsZoneId; + std::string wsName = gameHandler.getWhoAreaName(wsZoneId); + if (!wsName.empty()) { + zoneTextName_ = wsName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } + } + + // Also poll the renderer for zone name changes (covers map-level transitions + // where worldStateZoneId may not change immediately). auto* appRenderer = core::Application::getInstance().getRenderer(); if (appRenderer) { const std::string& zoneName = appRenderer->getCurrentZoneName(); if (!zoneName.empty() && zoneName != lastKnownZoneName_) { lastKnownZoneName_ = zoneName; - zoneTextName_ = zoneName; - zoneTextTimer_ = ZONE_TEXT_DURATION; + // Only override if the worldState hasn't already queued this zone + if (zoneTextName_ != zoneName) { + zoneTextName_ = zoneName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } } } @@ -19825,6 +20864,129 @@ void GameScreen::renderZoneText() { IM_COL32(255, 255, 255, (int)(alpha * 255)), zoneTextName_.c_str()); } +// --------------------------------------------------------------------------- +// Screen-space weather overlay (rain / snow / storm) +// --------------------------------------------------------------------------- +void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { + uint32_t wType = gameHandler.getWeatherType(); + float intensity = gameHandler.getWeatherIntensity(); + if (wType == 0 || intensity < 0.05f) return; + + const ImGuiIO& io = ImGui::GetIO(); + float sw = io.DisplaySize.x; + float sh = io.DisplaySize.y; + if (sw <= 0.0f || sh <= 0.0f) return; + + ImDrawList* dl = ImGui::GetForegroundDrawList(); + const float dt = std::min(io.DeltaTime, 0.05f); // cap delta at 50ms to avoid teleporting particles + + if (wType == 1 || wType == 3) { + // ── Rain / Storm ───────────────────────────────────────────────────── + constexpr int MAX_DROPS = 300; + struct RainState { + float x[MAX_DROPS], y[MAX_DROPS]; + bool initialized = false; + uint32_t lastType = 0; + float lastW = 0.0f, lastH = 0.0f; + }; + static RainState rs; + + // Re-seed if weather type or screen size changed + if (!rs.initialized || rs.lastType != wType || + rs.lastW != sw || rs.lastH != sh) { + for (int i = 0; i < MAX_DROPS; ++i) { + rs.x[i] = static_cast(std::rand() % (static_cast(sw) + 200)) - 100.0f; + rs.y[i] = static_cast(std::rand() % static_cast(sh)); + } + rs.initialized = true; + rs.lastType = wType; + rs.lastW = sw; + rs.lastH = sh; + } + + const float fallSpeed = (wType == 3) ? 680.0f : 440.0f; + const float windSpeed = (wType == 3) ? 110.0f : 65.0f; + const int numDrops = static_cast(MAX_DROPS * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.28f + intensity * 0.38f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const ImU32 dropCol = IM_COL32(175, 195, 225, alphaU8); + const float dropLen = 7.0f + intensity * 7.0f; + // Normalised wind direction for the trail endpoint + const float invSpeed = 1.0f / std::sqrt(fallSpeed * fallSpeed + windSpeed * windSpeed); + const float trailDx = -windSpeed * invSpeed * dropLen; + const float trailDy = -fallSpeed * invSpeed * dropLen; + + for (int i = 0; i < numDrops; ++i) { + rs.x[i] += windSpeed * dt; + rs.y[i] += fallSpeed * dt; + if (rs.y[i] > sh + 10.0f) { + rs.y[i] = -10.0f; + rs.x[i] = static_cast(std::rand() % (static_cast(sw) + 200)) - 100.0f; + } + if (rs.x[i] > sw + 100.0f) rs.x[i] -= sw + 200.0f; + dl->AddLine(ImVec2(rs.x[i], rs.y[i]), + ImVec2(rs.x[i] + trailDx, rs.y[i] + trailDy), + dropCol, 1.0f); + } + + // Storm: dark fog-vignette at screen edges + if (wType == 3) { + const float vigAlpha = std::min(1.0f, 0.12f + intensity * 0.18f); + const ImU32 vigCol = IM_COL32(60, 65, 80, static_cast(vigAlpha * 255.0f)); + const float vigW = sw * 0.22f; + const float vigH = sh * 0.22f; + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vigW, sh), vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol); + dl->AddRectFilledMultiColor(ImVec2(sw-vigW, 0), ImVec2(sw, sh), IM_COL32_BLACK_TRANS, vigCol, vigCol, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(sw, vigH), vigCol, vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, sh-vigH),ImVec2(sw, sh), IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol, vigCol); + } + + } else if (wType == 2) { + // ── Snow ───────────────────────────────────────────────────────────── + constexpr int MAX_FLAKES = 120; + struct SnowState { + float x[MAX_FLAKES], y[MAX_FLAKES], phase[MAX_FLAKES]; + bool initialized = false; + float lastW = 0.0f, lastH = 0.0f; + }; + static SnowState ss; + + if (!ss.initialized || ss.lastW != sw || ss.lastH != sh) { + for (int i = 0; i < MAX_FLAKES; ++i) { + ss.x[i] = static_cast(std::rand() % static_cast(sw)); + ss.y[i] = static_cast(std::rand() % static_cast(sh)); + ss.phase[i] = static_cast(std::rand() % 628) * 0.01f; + } + ss.initialized = true; + ss.lastW = sw; + ss.lastH = sh; + } + + const float fallSpeed = 45.0f + intensity * 45.0f; + const int numFlakes = static_cast(MAX_FLAKES * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.5f + intensity * 0.3f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const float radius = 1.5f + intensity * 1.5f; + const float time = static_cast(ImGui::GetTime()); + + for (int i = 0; i < numFlakes; ++i) { + float sway = std::sin(time * 0.7f + ss.phase[i]) * 18.0f; + ss.x[i] += sway * dt; + ss.y[i] += fallSpeed * dt; + ss.phase[i] += dt * 0.25f; + if (ss.y[i] > sh + 5.0f) { + ss.y[i] = -5.0f; + ss.x[i] = static_cast(std::rand() % static_cast(sw)); + } + if (ss.x[i] < -5.0f) ss.x[i] += sw + 10.0f; + if (ss.x[i] > sw + 5.0f) ss.x[i] -= sw + 10.0f; + // Two-tone: bright centre dot + transparent outer ring for depth + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius, IM_COL32(220, 235, 255, alphaU8)); + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius * 0.45f, IM_COL32(245, 250, 255, std::min(255, alphaU8 + 30))); + } + } +} + // --------------------------------------------------------------------------- // Dungeon Finder window (toggle with hotkey or bag-bar button) // --------------------------------------------------------------------------- @@ -20448,7 +21610,7 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { using T = game::CombatTextEntry; return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || - t == T::ENVIRONMENTAL; + t == T::ENVIRONMENTAL || t == T::GLANCING || t == T::CRUSHING; }; auto isHealType = [](game::CombatTextEntry::Type t) { using T = game::CombatTextEntry; @@ -20718,6 +21880,20 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); break; + case T::HONOR_GAIN: + snprintf(desc, sizeof(desc), "You gain %d honor", e.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, 1.0f); + break; + case T::GLANCING: + snprintf(desc, sizeof(desc), "%s glances %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.75f, 0.75f, 0.5f, 1.0f) + : ImVec4(0.75f, 0.4f, 0.4f, 1.0f); + break; + case T::CRUSHING: + snprintf(desc, sizeof(desc), "%s crushes %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.55f, 0.1f, 1.0f) + : ImVec4(1.0f, 0.15f, 0.15f, 1.0f); + break; default: snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); @@ -21285,11 +22461,7 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { ImGui::End(); } -// ─── Quest Objective Tracker (legacy stub — superseded by renderQuestObjectiveTracker) ─── -void GameScreen::renderObjectiveTracker(game::GameHandler&) { - // No-op: consolidated into renderQuestObjectiveTracker which renders the - // full-featured draggable tracker with context menus and item icons. -} + // ─── Book / Scroll / Note Window ────────────────────────────────────────────── void GameScreen::renderBookWindow(game::GameHandler& gameHandler) { @@ -21706,4 +22878,114 @@ void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderSkillsWindow(game::GameHandler& gameHandler) { + if (!showSkillsWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(220, 130), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Skills & Professions", &showSkillsWindow_)) { + ImGui::End(); + return; + } + + const auto& skills = gameHandler.getPlayerSkills(); + if (skills.empty()) { + ImGui::TextDisabled("No skill data received yet."); + ImGui::End(); + return; + } + + // Organise skills by category + // WoW SkillLine.dbc categories: 6=Weapon, 7=Class, 8=Armor, 9=Secondary, 11=Professions, others=Misc + struct SkillEntry { + uint32_t skillId; + const game::PlayerSkill* skill; + }; + std::map> byCategory; + for (const auto& [id, sk] : skills) { + uint32_t cat = gameHandler.getSkillCategory(id); + byCategory[cat].push_back({id, &sk}); + } + + static const struct { uint32_t cat; const char* label; } kCatOrder[] = { + {11, "Professions"}, + { 9, "Secondary Skills"}, + { 7, "Class Skills"}, + { 6, "Weapon Skills"}, + { 8, "Armor"}, + { 5, "Languages"}, + { 0, "Other"}, + }; + + // Collect handled categories to fall back to "Other" for unknowns + static const uint32_t kKnownCats[] = {11, 9, 7, 6, 8, 5}; + + // Redirect unknown categories into bucket 0 + for (auto& [cat, vec] : byCategory) { + bool known = false; + for (uint32_t kc : kKnownCats) if (cat == kc) { known = true; break; } + if (!known && cat != 0) { + auto& other = byCategory[0]; + other.insert(other.end(), vec.begin(), vec.end()); + vec.clear(); + } + } + + ImGui::BeginChild("##skillscroll", ImVec2(0, 0), false); + + for (const auto& [cat, label] : kCatOrder) { + auto it = byCategory.find(cat); + if (it == byCategory.end() || it->second.empty()) continue; + + auto& entries = it->second; + // Sort alphabetically within each category + std::sort(entries.begin(), entries.end(), [&](const SkillEntry& a, const SkillEntry& b) { + return gameHandler.getSkillName(a.skillId) < gameHandler.getSkillName(b.skillId); + }); + + if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) { + for (const auto& e : entries) { + const std::string& name = gameHandler.getSkillName(e.skillId); + const char* displayName = name.empty() ? "Unknown" : name.c_str(); + uint16_t val = e.skill->effectiveValue(); + uint16_t maxVal = e.skill->maxValue; + + ImGui::PushID(static_cast(e.skillId)); + + // Name column + ImGui::TextUnformatted(displayName); + ImGui::SameLine(170.0f); + + // Progress bar + float fraction = (maxVal > 0) ? static_cast(val) / static_cast(maxVal) : 0.0f; + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", val, maxVal); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); + ImGui::ProgressBar(fraction, ImVec2(160.0f, 14.0f), overlay); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("%s", displayName); + ImGui::Separator(); + ImGui::Text("Base: %u", e.skill->value); + if (e.skill->bonusPerm > 0) + ImGui::Text("Permanent bonus: +%u", e.skill->bonusPerm); + if (e.skill->bonusTemp > 0) + ImGui::Text("Temporary bonus: +%u", e.skill->bonusTemp); + ImGui::Text("Max: %u", maxVal); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + ImGui::Spacing(); + } + } + + ImGui::EndChild(); + ImGui::End(); +} + }} // namespace wowee::ui diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 09022ec5..083096a7 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1738,12 +1738,27 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); ImVec4 gray(0.6f, 0.6f, 0.6f, 1.0f); + static const char* kStatTooltips[5] = { + "Increases your melee attack power by 2.\nIncreases your block value.", + "Increases your Armor.\nIncreases ranged attack power by 2.\nIncreases your chance to dodge attacks and score critical strikes.", + "Increases Health by 10 per point.", + "Increases your Mana pool.\nIncreases your chance to score a critical strike with spells.", + "Increases Health and Mana regeneration." + }; + // Armor (no base) + ImGui::BeginGroup(); if (totalArmor > 0) { ImGui::TextColored(gold, "Armor: %d", totalArmor); } else { ImGui::TextColored(gray, "Armor: 0"); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("Reduces damage taken from physical attacks."); + ImGui::EndTooltip(); + } if (serverStats) { // Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus. @@ -1753,6 +1768,7 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play for (int i = 0; i < 5; ++i) { int32_t total = serverStats[i]; int32_t bonus = itemBonuses[i]; + ImGui::BeginGroup(); if (bonus > 0) { ImGui::TextColored(white, "%s: %d", statNames[i], total); ImGui::SameLine(); @@ -1760,12 +1776,19 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } else { ImGui::TextColored(gray, "%s: %d", statNames[i], total); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", kStatTooltips[i]); + ImGui::EndTooltip(); + } } } else { // Fallback: estimated base (20 + level) plus item query bonuses. int32_t baseStat = 20 + static_cast(playerLevel); - auto renderStat = [&](const char* name, int32_t equipBonus) { + auto renderStat = [&](const char* name, int32_t equipBonus, const char* tooltip) { int32_t total = baseStat + equipBonus; + ImGui::BeginGroup(); if (equipBonus > 0) { ImGui::TextColored(white, "%s: %d", name, total); ImGui::SameLine(); @@ -1773,12 +1796,18 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } else { ImGui::TextColored(gray, "%s: %d", name, total); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", tooltip); + ImGui::EndTooltip(); + } }; - renderStat("Strength", itemStr); - renderStat("Agility", itemAgi); - renderStat("Stamina", itemSta); - renderStat("Intellect", itemInt); - renderStat("Spirit", itemSpi); + renderStat("Strength", itemStr, kStatTooltips[0]); + renderStat("Agility", itemAgi, kStatTooltips[1]); + renderStat("Stamina", itemSta, kStatTooltips[2]); + renderStat("Intellect", itemInt, kStatTooltips[3]); + renderStat("Spirit", itemSpi, kStatTooltips[4]); } // Secondary stats from equipped items @@ -1789,27 +1818,34 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play if (hasSecondary) { ImGui::Spacing(); ImGui::Separator(); - auto renderSecondary = [&](const char* name, int32_t val) { + auto renderSecondary = [&](const char* name, int32_t val, const char* tooltip) { if (val > 0) { + ImGui::BeginGroup(); ImGui::TextColored(green, "+%d %s", val, name); + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", tooltip); + ImGui::EndTooltip(); + } } }; - renderSecondary("Attack Power", itemAP); - renderSecondary("Spell Power", itemSP); - renderSecondary("Hit Rating", itemHit); - renderSecondary("Crit Rating", itemCrit); - renderSecondary("Haste Rating", itemHaste); - renderSecondary("Resilience", itemResil); - renderSecondary("Expertise", itemExpertise); - renderSecondary("Defense Rating", itemDefense); - renderSecondary("Dodge Rating", itemDodge); - renderSecondary("Parry Rating", itemParry); - renderSecondary("Block Rating", itemBlock); - renderSecondary("Block Value", itemBlockVal); - renderSecondary("Armor Penetration",itemArmorPen); - renderSecondary("Spell Penetration",itemSpellPen); - renderSecondary("Mana per 5 sec", itemMp5); - renderSecondary("Health per 5 sec", itemHp5); + renderSecondary("Attack Power", itemAP, "Increases the damage of your melee and ranged attacks."); + renderSecondary("Spell Power", itemSP, "Increases the damage and healing of your spells."); + renderSecondary("Hit Rating", itemHit, "Reduces the chance your attacks will miss."); + renderSecondary("Crit Rating", itemCrit, "Increases your critical strike chance."); + renderSecondary("Haste Rating", itemHaste, "Increases attack speed and spell casting speed."); + renderSecondary("Resilience", itemResil, "Reduces the chance you will be critically hit.\nReduces damage taken from critical hits."); + renderSecondary("Expertise", itemExpertise,"Reduces the chance your attacks will be dodged or parried."); + renderSecondary("Defense Rating", itemDefense, "Reduces the chance enemies will critically hit you."); + renderSecondary("Dodge Rating", itemDodge, "Increases your chance to dodge attacks."); + renderSecondary("Parry Rating", itemParry, "Increases your chance to parry attacks."); + renderSecondary("Block Rating", itemBlock, "Increases your chance to block attacks with your shield."); + renderSecondary("Block Value", itemBlockVal, "Increases the amount of damage your shield blocks."); + renderSecondary("Armor Penetration",itemArmorPen, "Reduces the armor of your target."); + renderSecondary("Spell Penetration",itemSpellPen, "Reduces your target's resistance to your spells."); + renderSecondary("Mana per 5 sec", itemMp5, "Restores mana every 5 seconds, even while casting."); + renderSecondary("Health per 5 sec", itemHp5, "Restores health every 5 seconds."); } // Elemental resistances from server update fields @@ -2299,8 +2335,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { LOG_INFO("Right-click backpack item: name='", item.name, "' inventoryType=", (int)item.inventoryType, - " itemId=", item.itemId); - if (item.inventoryType > 0) { + " itemId=", item.itemId, + " startQuestId=", item.startQuestId); + if (item.startQuestId != 0) { + uint64_t iGuid = gameHandler_->getBackpackItemGuid(backpackIndex); + gameHandler_->offerQuestFromItem(iGuid, item.startQuestId); + } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemBySlot(backpackIndex); } else { gameHandler_->useItemBySlot(backpackIndex); @@ -2308,8 +2348,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (kind == SlotKind::BACKPACK && isBagSlot) { LOG_INFO("Right-click bag item: name='", item.name, "' inventoryType=", (int)item.inventoryType, - " bagIndex=", bagIndex, " slotIndex=", bagSlotIndex); - if (item.inventoryType > 0) { + " bagIndex=", bagIndex, " slotIndex=", bagSlotIndex, + " startQuestId=", item.startQuestId); + if (item.startQuestId != 0) { + uint64_t iGuid = gameHandler_->getBagItemGuid(bagIndex, bagSlotIndex); + gameHandler_->offerQuestFromItem(iGuid, item.startQuestId); + } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex); } else { gameHandler_->useItemInBag(bagIndex, bagSlotIndex); diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index b522e671..a7f52a3b 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -36,6 +36,7 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) + bindings_[static_cast(Action::TOGGLE_SKILLS)] = ImGuiKey_K; // WoW standard: K opens Skills/Professions } bool KeybindingManager::isActionPressed(Action action, bool repeat) { @@ -93,6 +94,7 @@ const char* KeybindingManager::getActionName(Action action) { case Action::TOGGLE_NAMEPLATES: return "Nameplates"; case Action::TOGGLE_RAID_FRAMES: return "Raid Frames"; case Action::TOGGLE_ACHIEVEMENTS: return "Achievements"; + case Action::TOGGLE_SKILLS: return "Skills / Professions"; case Action::ACTION_COUNT: break; } return "Unknown"; @@ -158,6 +160,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { else if (action == "toggle_raid_frames") actionIdx = static_cast(Action::TOGGLE_RAID_FRAMES); else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUESTS); // legacy alias else if (action == "toggle_achievements") actionIdx = static_cast(Action::TOGGLE_ACHIEVEMENTS); + else if (action == "toggle_skills") actionIdx = static_cast(Action::TOGGLE_SKILLS); if (actionIdx < 0) continue; @@ -254,6 +257,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const { {Action::TOGGLE_NAMEPLATES, "toggle_nameplates"}, {Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"}, {Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"}, + {Action::TOGGLE_SKILLS, "toggle_skills"}, }; for (const auto& [action, nameStr] : actionMap) {