diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 4ec229d5..102074be 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,8 +1,10 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 }, + "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 4549a48c..0d61eacc 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -34,6 +34,8 @@ "PLAYER_END": 1282, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 48, + "ITEM_FIELD_MAXDURABILITY": 49, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 } diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index d40a5766..5bca8165 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,8 +1,10 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 + "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215, + "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40 }, + "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index bee972ca..c6d77c76 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -33,6 +33,8 @@ "PLAYER_EXPLORED_ZONES_START": 1312, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 60, + "ITEM_FIELD_MAXDURABILITY": 61, "CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_SLOT_1": 66 } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 4e86338a..e31634e4 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,8 +1,10 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 }, + "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 393694a0..a91a314b 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -34,6 +34,8 @@ "PLAYER_END": 1282, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 48, + "ITEM_FIELD_MAXDURABILITY": 49, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 } \ No newline at end of file diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 5b500741..82252391 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,8 +1,10 @@ { "Spell": { "ID": 0, "Attributes": 4, "IconID": 133, - "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225 + "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, + "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49 }, + "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index fa4b9ada..67019c80 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -33,6 +33,8 @@ "PLAYER_EXPLORED_ZONES_START": 1041, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 60, + "ITEM_FIELD_MAXDURABILITY": 61, "CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_SLOT_1": 66 } diff --git a/README.md b/README.md index 7353ed15..ab1a90d0 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. - **Gossip** -- NPC interaction, dialogue options - **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips - **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS -- **Pets** -- Pet tracking via SMSG_PET_SPELLS, dismiss pet button +- **Pets** -- Pet tracking via SMSG_PET_SPELLS, action bar (10 slots with icon/autocast tinting/tooltips), dismiss button - **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior - **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine) - **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) diff --git a/include/core/application.hpp b/include/core/application.hpp index 0c7ca61e..570f0658 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -187,9 +187,13 @@ private: std::unordered_map creatureInstances_; // guid → render instanceId std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position - std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureWasSwimming_; // guid -> previous-frame swim state (for anim transition detection) + std::unordered_map creatureWasFlying_; // guid -> previous-frame flying state (for anim transition detection) + std::unordered_map creatureWasWalking_; // guid -> previous-frame walking state (walk vs run transition detection) std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) + std::unordered_map creatureFlyingState_; // guid -> currently flying (FLYING flag) std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8e3420c5..2307f849 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -588,10 +588,14 @@ public: const std::unordered_map& getAllTalentTabs() const { return talentTabCache_; } void loadTalentDbc(); - // Action bar — 2 bars × 12 slots = 24 total + // Action bar — 4 bars × 12 slots = 48 total + // Bar 0 (slots 0-11): main bottom bar (1-0, -, =) + // Bar 1 (slots 12-23): second bar above main (Shift+1 ... Shift+=) + // Bar 2 (slots 24-35): right side vertical bar + // Bar 3 (slots 36-47): left side vertical bar static constexpr int SLOTS_PER_BAR = 12; - static constexpr int ACTION_BARS = 2; - static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24 + static constexpr int ACTION_BARS = 4; + static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 48 std::array& getActionBar() { return actionBar; } const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); @@ -693,6 +697,11 @@ public: using WorldEntryCallback = std::function; void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } + // Knockback callback: called when server sends SMSG_MOVE_KNOCK_BACK for the player. + // Parameters: vcos, vsin (render-space direction), hspeed, vspeed (raw from packet). + using KnockBackCallback = std::function; + void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) using UnstuckCallback = std::function; void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } @@ -818,6 +827,22 @@ public: // Player GUID uint64_t getPlayerGuid() const { return playerGuid; } + + // Look up a display name for any guid: checks playerNameCache then entity manager. + // Returns empty string if unknown. Used by chat display to resolve names at render time. + const std::string& lookupName(uint64_t guid) const { + static const std::string kEmpty; + auto it = playerNameCache.find(guid); + if (it != playerNameCache.end()) return it->second; + auto entity = entityManager.getEntity(guid); + if (entity) { + if (auto* unit = dynamic_cast(entity.get())) { + if (!unit->getName().empty()) return unit->getName(); + } + } + return kEmpty; + } + uint8_t getPlayerClass() const { const Character* ch = getActiveCharacter(); return ch ? static_cast(ch->characterClass) : 0; @@ -830,6 +855,10 @@ public: bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } const std::string& getResurrectCasterName() const { return resurrectCasterName_; } + bool showTalentWipeConfirmDialog() const { return talentWipePending_; } + uint32_t getTalentWipeCost() const { return talentWipeCost_; } + void confirmTalentWipe(); + void cancelTalentWipe() { talentWipePending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ @@ -944,6 +973,7 @@ public: void lfgJoin(uint32_t dungeonId, uint8_t roles); void lfgLeave(); void lfgAcceptProposal(uint32_t proposalId, bool accept); + void lfgSetBootVote(bool vote); void lfgTeleport(bool toLfgDungeon = true); LfgState getLfgState() const { return lfgState_; } bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } @@ -1142,6 +1172,39 @@ public: bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } float getServerRunSpeed() const { return serverRunSpeed_; } + float getServerWalkSpeed() const { return serverWalkSpeed_; } + float getServerSwimSpeed() const { return serverSwimSpeed_; } + float getServerSwimBackSpeed() const { return serverSwimBackSpeed_; } + float getServerFlightSpeed() const { return serverFlightSpeed_; } + float getServerFlightBackSpeed() const { return serverFlightBackSpeed_; } + float getServerRunBackSpeed() const { return serverRunBackSpeed_; } + float getServerTurnRate() const { return serverTurnRate_; } + bool isPlayerRooted() const { + return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; + } + bool isGravityDisabled() const { + return (movementInfo.flags & static_cast(MovementFlags::LEVITATING)) != 0; + } + bool isFeatherFalling() const { + return (movementInfo.flags & static_cast(MovementFlags::FEATHER_FALL)) != 0; + } + bool isWaterWalking() const { + return (movementInfo.flags & static_cast(MovementFlags::WATER_WALK)) != 0; + } + bool isPlayerFlying() const { + const uint32_t flyMask = static_cast(MovementFlags::CAN_FLY) | + static_cast(MovementFlags::FLYING); + return (movementInfo.flags & flyMask) == flyMask; + } + bool isHovering() const { + return (movementInfo.flags & static_cast(MovementFlags::HOVER)) != 0; + } + bool isSwimming() const { + return (movementInfo.flags & static_cast(MovementFlags::SWIMMING)) != 0; + } + // Set the character pitch angle (radians) for movement packets (flight / swimming). + // Positive = nose up, negative = nose down. + void setMovementPitch(float radians) { movementInfo.pitch = radians; } void dismount(); // Taxi / Flight Paths @@ -1195,6 +1258,8 @@ public: uint32_t count = 1; }; void buyBackItem(uint32_t buybackSlot); + void repairItem(uint64_t vendorGuid, uint64_t itemGuid); + void repairAll(uint64_t vendorGuid, bool useGuildBank = false); const std::deque& getBuybackItems() const { return buybackItems_; } void autoEquipItemBySlot(int backpackIndex); void autoEquipItemInBag(int bagIndex, int slotIndex); @@ -1206,6 +1271,7 @@ public: void useItemById(uint32_t itemId); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } + void setVendorCanRepair(bool v) { currentVendorItems.canRepair = v; } // Mail bool isMailboxOpen() const { return mailboxOpen_; } @@ -1535,6 +1601,7 @@ private: void handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage); void handleForceMoveRootState(network::Packet& packet, bool rooted); void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set); + void handleMoveSetCollisionHeight(network::Packet& packet); void handleMoveKnockBack(network::Packet& packet); // ---- Area trigger detection ---- @@ -1679,6 +1746,12 @@ private: uint32_t lastMovementTimestampMs_ = 0; bool serverMovementAllowed_ = true; + // Fall/jump tracking for movement packet correctness. + // fallTime must be the elapsed ms since the FALLING flag was set; the server + // uses it for fall-damage calculations and anti-cheat validation. + bool isFalling_ = false; + uint32_t fallStartMs_ = 0; // movementInfo.time value when FALLING started + // Inventory Inventory inventory; @@ -1761,6 +1834,8 @@ private: struct OnlineItemInfo { uint32_t entry = 0; uint32_t stackCount = 1; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; @@ -1810,6 +1885,7 @@ private: // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; + KnockBackCallback knockBackCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; @@ -2312,6 +2388,10 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + // ---- Talent wipe confirm dialog ---- + bool talentWipePending_ = false; + uint64_t talentWipeNpcGuid_ = 0; + uint32_t talentWipeCost_ = 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/inventory.hpp b/include/game/inventory.hpp index b25d5234..7a3bcb8c 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -46,6 +47,16 @@ struct ItemDef { int32_t spirit = 0; uint32_t displayInfoId = 0; uint32_t sellPrice = 0; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; + uint32_t itemLevel = 0; + uint32_t requiredLevel = 0; + uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ + std::string description; // Flavor/lore text shown in tooltip (italic yellow) + // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) + struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; + std::vector extraStats; + uint32_t startQuestId = 0; // Non-zero: item begins a quest }; struct ItemSlot { diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index fd208554..67651b00 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -56,6 +56,8 @@ enum class UF : uint16_t { // Item fields ITEM_FIELD_STACK_COUNT, + ITEM_FIELD_DURABILITY, + ITEM_FIELD_MAXDURABILITY, // Container fields CONTAINER_FIELD_NUM_SLOTS, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c13659c3..5d75e887 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -395,6 +395,8 @@ enum class MovementFlags : uint32_t { ROOT = 0x00000800, FALLING = 0x00001000, FALLINGFAR = 0x00002000, + FEATHER_FALL = 0x00004000, // Slow fall / Parachute + WATER_WALK = 0x00008000, // Walk on water surface SWIMMING = 0x00200000, ASCENDING = 0x00400000, CAN_FLY = 0x00800000, @@ -613,7 +615,11 @@ enum class ChatType : uint8_t { MONSTER_WHISPER = 42, RAID_BOSS_WHISPER = 43, RAID_BOSS_EMOTE = 44, - MONSTER_PARTY = 50 + MONSTER_PARTY = 50, + // BG/Arena system messages (WoW 3.3.5a — no sender, treated as SYSTEM in display) + BG_SYSTEM_NEUTRAL = 82, + BG_SYSTEM_ALLIANCE = 83, + BG_SYSTEM_HORDE = 84 }; /** @@ -1546,6 +1552,8 @@ struct ItemQueryResponseData { int32_t intellect = 0; int32_t spirit = 0; uint32_t sellPrice = 0; + uint32_t itemLevel = 0; + uint32_t requiredLevel = 0; std::string subclassName; // Item spells (up to 5) struct ItemSpell { @@ -1553,6 +1561,12 @@ struct ItemQueryResponseData { uint32_t spellTrigger = 0; // 0=Use, 1=Equip, 2=ChanceOnHit, 5=Learn }; std::array spells{}; + uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ + std::string description; // Flavor/lore text + // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) + struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; + std::vector extraStats; + uint32_t startQuestId = 0; // Non-zero: item begins a quest bool valid = false; }; @@ -2072,6 +2086,14 @@ public: static network::Packet build(uint64_t npcGuid, uint32_t questId); }; +/** Reward item entry (shared by quest detail/offer windows) */ +struct QuestRewardItem { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t displayInfoId = 0; + uint32_t choiceSlot = 0; // Original reward slot index from server payload +}; + /** SMSG_QUESTGIVER_QUEST_DETAILS data (simplified) */ struct QuestDetailsData { uint64_t npcGuid = 0; @@ -2082,6 +2104,8 @@ struct QuestDetailsData { uint32_t suggestedPlayers = 0; uint32_t rewardMoney = 0; uint32_t rewardXp = 0; + std::vector rewardChoiceItems; // Player picks one of these + std::vector rewardItems; // These are always given }; /** SMSG_QUESTGIVER_QUEST_DETAILS parser */ @@ -2090,14 +2114,6 @@ public: static bool parse(network::Packet& packet, QuestDetailsData& data); }; -/** Reward item entry (shared by quest detail/offer windows) */ -struct QuestRewardItem { - uint32_t itemId = 0; - uint32_t count = 0; - uint32_t displayInfoId = 0; - uint32_t choiceSlot = 0; // Original reward slot index from server payload -}; - /** SMSG_QUESTGIVER_REQUEST_ITEMS data (turn-in progress check) */ struct QuestRequestItemsData { uint64_t npcGuid = 0; @@ -2173,6 +2189,7 @@ struct VendorItem { struct ListInventoryData { uint64_t vendorGuid = 0; std::vector items; + bool canRepair = false; // Set when vendor was opened via GOSSIP_OPTION_ARMORER bool isValid() const { return true; } }; diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 79a7d622..3337a755 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -92,6 +92,21 @@ public: void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } + void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } + void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } + void setSwimBackSpeedOverride(float speed) { swimBackSpeedOverride_ = speed; } + void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } + void setFlightBackSpeedOverride(float speed) { flightBackSpeedOverride_ = speed; } + void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; } + // Server turn rate in rad/s (SMSG_FORCE_TURN_RATE_CHANGE); 0 = use WOW_TURN_SPEED default + void setTurnRateOverride(float rateRadS) { turnRateOverride_ = rateRadS; } + void setMovementRooted(bool rooted) { movementRooted_ = rooted; } + bool isMovementRooted() const { return movementRooted_; } + void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } + void setFeatherFallActive(bool active) { featherFallActive_ = active; } + void setWaterWalkActive(bool active) { waterWalkActive_ = active; } + void setFlyingActive(bool active) { flyingActive_ = active; } + void setHoverActive(bool active) { hoverActive_ = active; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -103,6 +118,12 @@ public: // Trigger mount jump (applies vertical velocity for physics hop) void triggerMountJump(); + // Apply server-driven knockback impulse. + // dir: render-space 2D direction unit vector (from vcos/vsin in packet) + // hspeed: horizontal speed magnitude (units/s) + // vspeed: raw packet vspeed field (server sends negative for upward launch) + void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed); + // For first-person player hiding void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { characterRenderer = cr; @@ -235,6 +256,8 @@ private: bool wasTurningRight = false; bool wasJumping = false; bool wasFalling = false; + bool wasAscending_ = false; // Space held while flyingActive_ + bool wasDescending_ = false; // X held while flyingActive_ bool moveForwardActive = false; bool moveBackwardActive = false; bool strafeLeftActive = false; @@ -260,8 +283,27 @@ private: return std::sqrt(2.0f * std::abs(MOUNT_GRAVITY) * MOUNT_JUMP_HEIGHT); } - // Server-driven run speed override (0 = use default WOW_RUN_SPEED) + // Server-driven speed overrides (0 = use hardcoded default) float runSpeedOverride_ = 0.0f; + float walkSpeedOverride_ = 0.0f; + float swimSpeedOverride_ = 0.0f; + float swimBackSpeedOverride_ = 0.0f; + float flightSpeedOverride_ = 0.0f; + float flightBackSpeedOverride_ = 0.0f; + float runBackSpeedOverride_ = 0.0f; + float turnRateOverride_ = 0.0f; // rad/s; 0 = WOW_TURN_SPEED default (π rad/s) + // Server-driven root state: when true, block all horizontal movement input. + bool movementRooted_ = false; + // Server-driven gravity disable (levitate/hover): skip gravity accumulation. + bool gravityDisabled_ = false; + // Server-driven feather fall: cap downward velocity to slow-fall terminal. + bool featherFallActive_ = false; + // Server-driven water walk: treat water surface as ground (don't swim). + bool waterWalkActive_ = false; + // Player-controlled flight (CAN_FLY + FLYING): 3D movement, no gravity. + bool flyingActive_ = false; + // Server-driven hover (HOVER flag): float at fixed height above ground. + bool hoverActive_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; @@ -313,6 +355,14 @@ private: float cachedFloorHeight_ = 0.0f; bool hasCachedFloor_ = false; static constexpr float COLLISION_CACHE_DISTANCE = 0.15f; // Re-check every 15cm + + // Server-driven knockback state. + // When the server sends SMSG_MOVE_KNOCK_BACK, we apply horizontal + vertical + // impulse here and let the normal physics loop (gravity, collision) resolve it. + bool knockbackActive_ = false; + glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s) + // Horizontal velocity decays via WoW-like drag so the player doesn't slide forever. + static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s) }; } // namespace rendering diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 50261865..08108dc0 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -150,7 +150,8 @@ public: */ /** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */ void prepareRender(); - void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, + const glm::vec3* viewerPos = nullptr); /** * Initialize shadow pipeline (Phase 7) @@ -696,7 +697,7 @@ private: // Rendering state bool wireframeMode = false; bool frustumCulling = true; - bool portalCulling = false; // Disabled by default - needs debugging + bool portalCulling = true; // AABB transform bug fixed; conservative frustum test (no plane-side check) is visually safe bool distanceCulling = false; // Disabled - causes ground to disappear float maxGroupDistance = 500.0f; float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2 diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 15e098e7..5dc0aa6d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -112,6 +112,13 @@ private: bool pendingSeparateBags = true; bool pendingAutoLoot = false; bool pendingUseOriginalSoundtrack = true; + bool pendingShowActionBar2 = true; // Show second action bar above main bar + float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position + float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) + bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35) + bool pendingShowLeftBar = false; // Left-edge vertical action bar (bar 4, slots 36-47) + float pendingRightBarOffsetY = 0.0f; // Vertical offset from screen center + float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x bool pendingNormalMapping = true; // on by default @@ -232,6 +239,7 @@ private: void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); + void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); void renderQuestMarkers(game::GameHandler& gameHandler); diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index a6944972..77f1c2d6 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -25,6 +25,7 @@ struct SpellInfo { uint32_t manaCost = 0; // Mana cost uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy uint32_t rangeIndex = 0; // Range index from SpellRange.dbc + uint32_t schoolMask = 0; // School bitmask (1=phys,2=holy,4=fire,8=nature,16=frost,32=shadow,64=arcane) bool isPassive() const { return (attributes & 0x40) != 0; } }; @@ -43,6 +44,11 @@ public: // Spell name lookup — triggers DBC load if needed, used by action bar tooltips std::string lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager); + // Rich tooltip — renders a full spell tooltip (inside an already-open BeginTooltip block). + // Triggers DBC load if needed. Returns true if spell data was found. + bool renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, + pipeline::AssetManager* assetManager); + // Drag-and-drop state for action bar assignment bool isDraggingSpell() const { return draggingSpell_; } uint32_t getDragSpellId() const { return dragSpellId_; } diff --git a/src/core/application.cpp b/src/core/application.cpp index ca692dd6..35ebca5f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -636,6 +636,11 @@ void Application::setState(AppState newState) { renderer->triggerMeleeSwing(); } }); + gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); + } + }); } // Load quest marker models loadQuestMarkerModels(); @@ -750,8 +755,12 @@ void Application::logoutToLogin() { creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); creatureWasMoving_.clear(); + creatureWasSwimming_.clear(); + creatureWasFlying_.clear(); + creatureWasWalking_.clear(); creatureSwimmingState_.clear(); creatureWalkingState_.clear(); + creatureFlyingState_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -1000,6 +1009,42 @@ void Application::update(float deltaTime) { runInGameStage("post-update sync", [&] { if (renderer && gameHandler && renderer->getCameraController()) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); + renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); + renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); + renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed()); + renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); + renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed()); + renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); + renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate()); + renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); + renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); + renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); + renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); + renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying()); + renderer->getCameraController()->setHoverActive(gameHandler->isHovering()); + + // Sync camera forward pitch to movement packets during flight / swimming. + // The server writes the pitch field when FLYING or SWIMMING flags are set; + // without this sync it would always be 0 (horizontal), causing other + // players to see the character flying flat even when pitching up/down. + if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) { + if (auto* cam = renderer->getCamera()) { + glm::vec3 fwd = cam->getForward(); + float len = glm::length(fwd); + if (len > 1e-4f) { + float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f)); + gameHandler->setMovementPitch(pitchRad); + // Tilt the mount/character model to match flight direction + // (taxi flight uses setTaxiOrientationCallback for this instead) + if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) { + renderer->setMountPitchRoll(pitchRad, 0.0f); + } + } + } + } else if (gameHandler->isMounted()) { + // Reset mount pitch when not flying + renderer->setMountPitchRoll(0.0f, 0.0f); + } } bool onTaxi = gameHandler && @@ -1381,14 +1426,20 @@ void Application::update(float deltaTime) { } } - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Distance check uses getLatestX/Y/Z (server-authoritative destination) to + // avoid false-culling entities that moved while getX/Y/Z was stale. + // Position sync still uses getX/Y/Z to preserve smooth interpolation for + // nearby entities; distant entities (> 150u) have planarDist≈0 anyway + // so the renderer remains driven correctly by creatureMoveCallback_. + glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); float canonDistSq = 0.0f; if (havePlayerPos) { - glm::vec3 d = canonical - playerPos; + glm::vec3 d = latestCanonical - playerPos; canonDistSq = glm::dot(d, d); if (canonDistSq > syncRadiusSq) continue; } + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // Visual collision guard: keep hostile melee units from rendering inside the @@ -1469,13 +1520,22 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); - const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f); + // isEntityMoving() reflects server-authoritative move state set by + // startMoveTo() in handleMonsterMove, regardless of distance-cull. + // This correctly detects movement for distant creatures (> 150u) + // where updateMovement() is not called and getX/Y/Z() stays stale. + const bool entityIsMoving = entity->isEntityMoving(); + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); - } else if (isMovingNow) { + } else if (planarDist > 0.03f || dz > 0.08f) { + // Position changed in entity coords → drive renderer toward it. float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } + // When entity is moving but getX/Y/Z is stale (distance-culled), + // don't call moveInstanceTo — creatureMoveCallback_ already drove + // the renderer to the correct destination via the spline packet. posIt->second = renderPos; // Drive movement animation: Walk/Run/Swim (4/5/42) when moving, @@ -1485,17 +1545,37 @@ void Application::update(float deltaTime) { // Don't override Death (1) animation. const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; - bool prevMoving = creatureWasMoving_[guid]; - if (isMovingNow != prevMoving) { - creatureWasMoving_[guid] = isMovingNow; + const bool isFlyingNow = creatureFlyingState_.count(guid) > 0; + bool prevMoving = creatureWasMoving_[guid]; + bool prevSwimming = creatureWasSwimming_[guid]; + bool prevFlying = creatureWasFlying_[guid]; + bool prevWalking = creatureWasWalking_[guid]; + // Trigger animation update on any locomotion-state transition, not just + // moving/idle — e.g. creature lands while still moving → FlyForward→Run, + // or server changes WALKING flag while creature is already running → Walk. + const bool stateChanged = (isMovingNow != prevMoving) || + (isSwimmingNow != prevSwimming) || + (isFlyingNow != prevFlying) || + (isWalkingNow != prevWalking && isMovingNow); + if (stateChanged) { + creatureWasMoving_[guid] = isMovingNow; + creatureWasSwimming_[guid] = isSwimmingNow; + creatureWasFlying_[guid] = isFlyingNow; + creatureWasWalking_[guid] = isWalkingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { uint32_t targetAnim; - if (isMovingNow) - targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run - else - targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand + if (isMovingNow) { + if (isFlyingNow) targetAnim = 159u; // FlyForward + else if (isSwimmingNow) targetAnim = 42u; // Swim + else if (isWalkingNow) targetAnim = 4u; // Walk + else targetAnim = 5u; // Run + } else { + if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) + else if (isSwimmingNow) targetAnim = 41u; // SwimIdle + else targetAnim = 0u; // Stand + } charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); } } @@ -2810,10 +2890,13 @@ void Application::setupUICallbacks() { gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) { const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; + const bool isFlying = (moveFlags & static_cast(game::MovementFlags::FLYING)) != 0; if (isSwimming) creatureSwimmingState_[guid] = true; else creatureSwimmingState_.erase(guid); if (isWalking) creatureWalkingState_[guid] = true; else creatureWalkingState_.erase(guid); + if (isFlying) creatureFlyingState_[guid] = true; + else creatureFlyingState_.erase(guid); }); // Emote animation callback — play server-driven emote animations on NPCs and other players @@ -6934,6 +7017,11 @@ void Application::despawnOnlinePlayer(uint64_t guid) { pendingOnlinePlayerEquipment_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -8527,8 +8615,12 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 747a557d..e8ecdf72 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1530,10 +1530,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) handleFriendList(packet); break; - case Opcode::SMSG_IGNORE_LIST: - // Ignore list: consume to avoid spurious warnings; not parsed. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_IGNORE_LIST: { + // uint8 count + count × (uint64 guid + string name) + // Populate ignoreCache so /unignore works for pre-existing ignores. + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t ignCount = packet.readUInt8(); + for (uint8_t i = 0; i < ignCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + uint64_t ignGuid = packet.readUInt64(); + std::string ignName = packet.readString(); + if (!ignName.empty() && ignGuid != 0) { + ignoreCache[ignName] = ignGuid; + } + } + LOG_DEBUG("SMSG_IGNORE_LIST: loaded ", (int)ignCount, " ignored players"); break; + } case Opcode::MSG_RANDOM_ROLL: if (state == WorldState::IN_WORLD) { @@ -2389,9 +2401,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t guid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; float speed = packet.readFloat(); - if (guid == playerGuid && std::isfinite(speed) && speed > 0.1f && speed < 100.0f && - *logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) { - serverRunSpeed_ = speed; + if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) { + if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) + serverRunSpeed_ = speed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED) + serverRunBackSpeed_ = speed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_SPEED) + serverSwimSpeed_ = speed; } break; } @@ -2443,10 +2459,12 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::CAN_FLY), false); break; case Opcode::SMSG_MOVE_FEATHER_FALL: - handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, true); + handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, + static_cast(MovementFlags::FEATHER_FALL), true); break; case Opcode::SMSG_MOVE_WATER_WALK: - handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, true); + handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, + static_cast(MovementFlags::WATER_WALK), true); break; case Opcode::SMSG_MOVE_SET_HOVER: handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK, @@ -3385,15 +3403,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_ACTION_BUTTONS: { - // uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons // packed: bits 0-23 = actionId, bits 24-31 = type // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) + // Format differences: + // Classic 1.12: no mode byte, 120 slots (480 bytes) + // TBC 2.4.3: no mode byte, 132 slots (528 bytes) + // WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes) size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 1) break; - /*uint8_t mode =*/ packet.readUInt8(); - rem--; - constexpr int SERVER_BAR_SLOTS = 144; - for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { + const bool hasModeByteExp = isActiveExpansion("wotlk"); + int serverBarSlots; + if (isClassicLikeExpansion()) { + serverBarSlots = 120; + } else if (isActiveExpansion("tbc")) { + serverBarSlots = 132; + } else { + serverBarSlots = 144; + } + if (hasModeByteExp) { + if (rem < 1) break; + /*uint8_t mode =*/ packet.readUInt8(); + rem--; + } + for (int i = 0; i < serverBarSlots; ++i) { if (rem < 4) break; uint32_t packed = packet.readUInt32(); rem -= 4; @@ -3585,10 +3616,29 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t error = packet.readUInt8(); if (error != 0) { LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error); + // After error byte: item_guid1(8) + item_guid2(8) + bag_slot(1) = 17 bytes + uint32_t requiredLevel = 0; + if (packet.getSize() - packet.getReadPos() >= 17) { + packet.readUInt64(); // item_guid1 + packet.readUInt64(); // item_guid2 + packet.readUInt8(); // bag_slot + // Error 1 = EQUIP_ERR_LEVEL_REQ: server appends required level as uint32 + if (error == 1 && packet.getSize() - packet.getReadPos() >= 4) + requiredLevel = packet.readUInt32(); + } // InventoryResult enum (AzerothCore 3.3.5a) const char* errMsg = nullptr; + char levelBuf[64]; switch (error) { - case 1: errMsg = "You must reach level %d to use that item."; break; + case 1: + if (requiredLevel > 0) { + std::snprintf(levelBuf, sizeof(levelBuf), + "You must reach level %u to use that item.", requiredLevel); + addSystemChatMessage(levelBuf); + } else { + addSystemChatMessage("You must reach a higher level to use that item."); + } + break; case 2: errMsg = "You don't have the required skill."; break; case 3: errMsg = "That item doesn't go in that slot."; break; case 4: errMsg = "That bag is full."; break; @@ -4452,10 +4502,20 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_INSPECT_ARENA_TEAMS: LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); break; - case Opcode::MSG_TALENT_WIPE_CONFIRM: - // Talent reset confirmation payload is not needed client-side right now. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_TALENT_WIPE_CONFIRM: { + // Server sends: uint64 npcGuid + uint32 cost + // Client must respond with the same opcode containing uint64 npcGuid to confirm. + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); + break; + } + talentWipeNpcGuid_ = packet.readUInt64(); + talentWipeCost_ = packet.readUInt32(); + talentWipePending_ = true; + LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_, + std::dec, " cost=", talentWipeCost_); break; + } // ---- MSG_MOVE_* opcodes (server relays other players' movement) ---- case Opcode::MSG_MOVE_START_FORWARD: @@ -4480,6 +4540,13 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_STOP_PITCH: case Opcode::MSG_MOVE_START_ASCEND: case Opcode::MSG_MOVE_STOP_ASCEND: + case Opcode::MSG_MOVE_START_DESCEND: + case Opcode::MSG_MOVE_SET_PITCH: + case Opcode::MSG_MOVE_GRAVITY_CHNG: + case Opcode::MSG_MOVE_UPDATE_CAN_FLY: + case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + case Opcode::MSG_MOVE_ROOT: + case Opcode::MSG_MOVE_UNROOT: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } @@ -5158,17 +5225,45 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_WALK_SPEED: case Opcode::SMSG_SPLINE_SET_TURN_RATE: - case Opcode::SMSG_SPLINE_SET_PITCH_RATE: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SPLINE_SET_PITCH_RATE: { + // Minimal parse: PackedGuid + float speed + if (packet.getSize() - packet.getReadPos() < 5) break; + uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + float sSpeed = packet.readFloat(); + if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) { + if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED) + serverFlightSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED) + serverFlightBackSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED) + serverSwimBackSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_WALK_SPEED) + serverWalkSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_TURN_RATE) + serverTurnRate_ = sSpeed; // rad/s + } break; + } // ---- Spline move flag changes for other units ---- case Opcode::SMSG_SPLINE_MOVE_UNROOT: - case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER: - case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: { + // Minimal parse: PackedGuid only — no animation-relevant state change. + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); + } break; + } + case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: { + // PackedGuid + synthesised move-flags=0 → clears flying animation. + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; + unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY + break; + } // ---- Quest failure notification ---- case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { @@ -5533,15 +5628,39 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: + handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, + static_cast(MovementFlags::LEVITATING), true); + break; case Opcode::SMSG_MOVE_GRAVITY_ENABLE: + handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK, + static_cast(MovementFlags::LEVITATING), false); + break; case Opcode::SMSG_MOVE_LAND_WALK: + handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, + static_cast(MovementFlags::WATER_WALK), false); + break; case Opcode::SMSG_MOVE_NORMAL_FALL: + handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, + static_cast(MovementFlags::FEATHER_FALL), false); + break; case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY", + Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true); + break; case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY", + Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false); + break; case Opcode::SMSG_MOVE_SET_COLLISION_HGT: + handleMoveSetCollisionHeight(packet); + break; case Opcode::SMSG_MOVE_SET_FLIGHT: + handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, + static_cast(MovementFlags::FLYING), true); + break; case Opcode::SMSG_MOVE_UNSET_FLIGHT: - packet.setReadPos(packet.getSize()); + handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, + static_cast(MovementFlags::FLYING), false); break; default: @@ -6069,6 +6188,13 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementClockStart_ = std::chrono::steady_clock::now(); lastMovementTimestampMs_ = 0; movementInfo.time = nextMovementTimestampMs(); + isFalling_ = false; + fallStartMs_ = 0; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; resurrectPending_ = false; resurrectRequestPending_ = false; onTaxiFlight_ = false; @@ -7186,6 +7312,31 @@ void GameHandler::sendMovement(Opcode opcode) { break; case Opcode::MSG_MOVE_JUMP: movementInfo.flags |= static_cast(MovementFlags::FALLING); + // Record fall start and capture horizontal velocity for jump fields. + isFalling_ = true; + fallStartMs_ = movementInfo.time; + movementInfo.fallTime = 0; + // jumpVelocity: WoW convention is the upward speed at launch. + movementInfo.jumpVelocity = 7.96f; // WOW_JUMP_VELOCITY from CameraController + { + // Facing direction encodes the horizontal movement direction at launch. + const float facingRad = movementInfo.orientation; + movementInfo.jumpCosAngle = std::cos(facingRad); + movementInfo.jumpSinAngle = std::sin(facingRad); + // Horizontal speed: only non-zero when actually moving at jump time. + const uint32_t horizFlags = + static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT); + const bool movingHoriz = (movementInfo.flags & horizFlags) != 0; + if (movingHoriz) { + const bool isWalking = (movementInfo.flags & static_cast(MovementFlags::WALKING)) != 0; + movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f); + } else { + movementInfo.jumpXYSpeed = 0.0f; + } + } break; case Opcode::MSG_MOVE_START_TURN_LEFT: movementInfo.flags |= static_cast(MovementFlags::TURN_LEFT); @@ -7199,14 +7350,50 @@ void GameHandler::sendMovement(Opcode opcode) { break; case Opcode::MSG_MOVE_FALL_LAND: movementInfo.flags &= ~static_cast(MovementFlags::FALLING); + isFalling_ = false; + fallStartMs_ = 0; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; break; case Opcode::MSG_MOVE_HEARTBEAT: // No flag changes — just sends current position break; + case Opcode::MSG_MOVE_START_ASCEND: + movementInfo.flags |= static_cast(MovementFlags::ASCENDING); + break; + case Opcode::MSG_MOVE_STOP_ASCEND: + // Clears ascending (and descending) — one stop opcode for both directions + movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); + break; + case Opcode::MSG_MOVE_START_DESCEND: + // Descending: no separate flag; clear ASCENDING so they don't conflict + movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); + break; default: break; } + // Keep fallTime current: it must equal the elapsed milliseconds since FALLING + // was set, so the server can compute fall damage correctly. + if (isFalling_ && movementInfo.hasFlag(MovementFlags::FALLING)) { + // movementInfo.time is the strictly-increasing client clock (ms). + // Subtract fallStartMs_ to get elapsed fall time; clamp to non-negative. + uint32_t elapsed = (movementInfo.time >= fallStartMs_) + ? (movementInfo.time - fallStartMs_) + : 0u; + movementInfo.fallTime = elapsed; + } else if (!movementInfo.hasFlag(MovementFlags::FALLING)) { + // Ensure fallTime is zeroed whenever we're not falling. + if (isFalling_) { + isFalling_ = false; + fallStartMs_ = 0; + } + movementInfo.fallTime = 0; + } + if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) { sanitizeMovementForTaxi(); } @@ -7775,10 +7962,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(true); } } - // Determine hostility from faction template for online creatures - if (unit->getFactionTemplate() != 0) { - unit->setHostile(isHostileFaction(unit->getFactionTemplate())); - } + // Determine hostility from faction template for online creatures. + // Always call isHostileFaction — factionTemplate=0 defaults to hostile + // in the lookup rather than silently staying at the struct default (false). + unit->setHostile(isHostileFaction(unit->getFactionTemplate())); // Trigger creature spawn callback for units/players with displayId if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, @@ -7868,10 +8055,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); + auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); + auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); if (entryIt != block.fields.end() && entryIt->second != 0) { - OnlineItemInfo info; + // Preserve existing info when doing partial updates + OnlineItemInfo info = onlineItems_.count(block.guid) + ? onlineItems_[block.guid] : OnlineItemInfo{}; info.entry = entryIt->second; - info.stackCount = (stackIt != block.fields.end()) ? stackIt->second : 1; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); onlineItems_[block.guid] = info; if (isNew) newItemCreated = true; @@ -7931,6 +8124,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte & 0x01) != 0; } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce @@ -8199,6 +8395,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { bool slotsChanged = false; const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); @@ -8213,6 +8410,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerNextLevelXp_ = val; LOG_DEBUG("Next level XP updated: ", val); } + else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { + playerRestedXp_ = val; + } else if (key == ufPlayerLevel) { serverPlayerLevel_ = val; LOG_DEBUG("Level updated: ", val); @@ -8235,6 +8435,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte & 0x01) != 0; } else if (key == ufPlayerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; @@ -8262,19 +8465,31 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { extractExploredZoneFields(lastPlayerFields_); } - // Update item stack count for online items + // Update item stack count / durability for online items if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { bool inventoryChanged = false; - const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); + const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); for (const auto& [key, val] : block.fields) { + auto it = onlineItems_.find(block.guid); if (key == itemStackField) { - auto it = onlineItems_.find(block.guid); if (it != onlineItems_.end() && it->second.stackCount != val) { it->second.stackCount = val; inventoryChanged = true; } + } else if (key == itemDurField) { + if (it != onlineItems_.end() && it->second.curDurability != val) { + it->second.curDurability = val; + inventoryChanged = true; + } + } else if (key == itemMaxDurField) { + if (it != onlineItems_.end() && it->second.maxDurability != val) { + it->second.maxDurability = val; + inventoryChanged = true; + } } } // Update container slot GUIDs on bag content changes @@ -10554,6 +10769,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10573,6 +10790,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10592,6 +10818,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10611,6 +10839,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10665,6 +10902,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10684,6 +10923,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); @@ -10705,6 +10953,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10724,6 +10974,14 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.sellPrice = infoIt->second.sellPrice; def.bagSlots = infoIt->second.containerSlots; } else { @@ -10786,6 +11044,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10805,7 +11065,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; def.sellPrice = infoIt->second.sellPrice; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); @@ -11398,6 +11666,47 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* socket->send(ack); } +void GameHandler::handleMoveSetCollisionHeight(network::Packet& packet) { + // SMSG_MOVE_SET_COLLISION_HGT: packed guid + counter + float (height) + // ACK: CMSG_MOVE_SET_COLLISION_HGT_ACK = packed guid + counter + movement block + float (height) + const bool legacyGuid = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (legacyGuid ? 8u : 2u)) return; + uint64_t guid = legacyGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) return; // counter(4) + height(4) + uint32_t counter = packet.readUInt32(); + float height = packet.readFloat(); + + LOG_INFO("SMSG_MOVE_SET_COLLISION_HGT: guid=0x", std::hex, guid, std::dec, + " counter=", counter, " height=", height); + + if (guid != playerGuid) return; + if (!socket) return; + + uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_SET_COLLISION_HGT_ACK); + if (ackWire == 0xFFFF) return; + + network::Packet ack(ackWire); + const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); + if (legacyGuidAck) { + ack.writeUInt64(playerGuid); + } else { + MovementPacket::writePackedGuid(ack, playerGuid); + } + ack.writeUInt32(counter); + + MovementInfo wire = movementInfo; + wire.time = nextMovementTimestampMs(); + glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z)); + wire.x = serverPos.x; + wire.y = serverPos.y; + wire.z = serverPos.z; + if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire); + else MovementPacket::writeMovementPayload(ack, wire); + ack.writeFloat(height); + + socket->send(ack); +} + void GameHandler::handleMoveKnockBack(network::Packet& packet) { // WotLK: packed GUID; TBC/Classic: full uint64 const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); @@ -11406,16 +11715,23 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4) uint32_t counter = packet.readUInt32(); - [[maybe_unused]] float vcos = packet.readFloat(); - [[maybe_unused]] float vsin = packet.readFloat(); - [[maybe_unused]] float hspeed = packet.readFloat(); - [[maybe_unused]] float vspeed = packet.readFloat(); + float vcos = packet.readFloat(); + float vsin = packet.readFloat(); + float hspeed = packet.readFloat(); + float vspeed = packet.readFloat(); LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec, - " counter=", counter, " hspeed=", hspeed, " vspeed=", vspeed); + " counter=", counter, " vcos=", vcos, " vsin=", vsin, + " hspeed=", hspeed, " vspeed=", vspeed); if (guid != playerGuid) return; + // Apply knockback physics locally so the player visually flies through the air. + // The callback forwards to CameraController::applyKnockBack(). + if (knockBackCallback_) { + knockBackCallback_(vcos, vsin, hspeed, vspeed); + } + if (!socket) return; uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK); if (ackWire == 0xFFFF) return; @@ -11867,13 +12183,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; if (inProgress) { + lfgState_ = LfgState::Boot; addSystemChatMessage( std::string("Dungeon Finder: Vote to kick in progress (") + std::to_string(timeLeft) + "s remaining)."); - } else if (myAnswer) { - addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); } else { - addSystemChatMessage("Dungeon Finder: Vote kick failed."); + // Boot vote ended — return to InDungeon state regardless of outcome + lfgState_ = LfgState::InDungeon; + if (myAnswer) { + addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); + } else { + addSystemChatMessage("Dungeon Finder: Vote kick failed."); + } } LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, @@ -11942,6 +12263,18 @@ void GameHandler::lfgTeleport(bool toLfgDungeon) { LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon); } +void GameHandler::lfgSetBootVote(bool vote) { + if (!socket) return; + uint16_t wireOp = wireOpcode(Opcode::CMSG_LFG_SET_BOOT_VOTE); + if (wireOp == 0xFFFF) return; + + network::Packet pkt(wireOp); + pkt.writeUInt8(vote ? 1 : 0); + + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_SET_BOOT_VOTE: vote=", vote); +} + void GameHandler::loadAreaTriggerDbc() { if (areaTriggerDbcLoaded_) return; areaTriggerDbcLoaded_ = true; @@ -12245,7 +12578,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12268,6 +12601,13 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_STOP_PITCH), wireOpcode(Opcode::MSG_MOVE_START_ASCEND), wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), + wireOpcode(Opcode::MSG_MOVE_START_DESCEND), + wireOpcode(Opcode::MSG_MOVE_SET_PITCH), + wireOpcode(Opcode::MSG_MOVE_GRAVITY_CHNG), + wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_FLY), + wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY), + wireOpcode(Opcode::MSG_MOVE_ROOT), + wireOpcode(Opcode::MSG_MOVE_UNROOT), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) @@ -13160,20 +13500,35 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } void GameHandler::handleSpellCooldown(network::Packet& packet) { - SpellCooldownData data; - if (!SpellCooldownParser::parse(packet, data)) return; + // Classic 1.12: guid(8) + N×[spellId(4) + itemId(4) + cooldown(4)] — no flags byte, 12 bytes/entry + // TBC 2.4.3 / WotLK 3.3.5a: guid(8) + flags(1) + N×[spellId(4) + cooldown(4)] — 8 bytes/entry + const bool isClassicFormat = isClassicLikeExpansion(); + + if (packet.getSize() - packet.getReadPos() < 8) return; + /*data.guid =*/ packet.readUInt64(); // guid (not used further) + + if (!isClassicFormat) { + if (packet.getSize() - packet.getReadPos() < 1) return; + /*data.flags =*/ packet.readUInt8(); // flags (consumed but not stored) + } + + const size_t entrySize = isClassicFormat ? 12u : 8u; + while (packet.getSize() - packet.getReadPos() >= entrySize) { + uint32_t spellId = packet.readUInt32(); + if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used + uint32_t cooldownMs = packet.readUInt32(); - for (const auto& [spellId, cooldownMs] : data.cooldowns) { float seconds = cooldownMs / 1000.0f; spellCooldowns[spellId] = seconds; - // Update action bar cooldowns for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { - slot.cooldownTotal = seconds; + slot.cooldownTotal = seconds; slot.cooldownRemaining = seconds; } } } + LOG_DEBUG("handleSpellCooldown: parsed for ", + isClassicFormat ? "Classic" : "TBC/WotLK", " format"); } void GameHandler::handleCooldownEvent(network::Packet& packet) { @@ -13395,6 +13750,24 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { addSystemChatMessage(msg); } +void GameHandler::confirmTalentWipe() { + if (!talentWipePending_) return; + talentWipePending_ = false; + + if (state != WorldState::IN_WORLD || !socket) return; + + // Respond to MSG_TALENT_WIPE_CONFIRM with the trainer GUID to trigger the reset. + // Packet: opcode(2) + uint64 npcGuid = 10 bytes. + network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); + pkt.writeUInt64(talentWipeNpcGuid_); + socket->send(pkt); + + LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec); + addSystemChatMessage("Talent reset confirmed. The server will update your talents."); + talentWipeNpcGuid_ = 0; + talentWipeCost_ = 0; +} + // ============================================================ // Phase 4: Group/Party // ============================================================ @@ -14042,8 +14415,9 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { // animation/sound and expects the client to request the mail list. bool isMailbox = false; bool chestLike = false; - // Stock-like behavior: GO use opens GO loot context. Keep eager CMSG_LOOT only - // as Classic/Turtle fallback behavior. + // Chest-type game objects (type=3): on all expansions, also send CMSG_LOOT so + // the server opens the loot response. Other harvestable/interactive types rely + // on the server auto-sending SMSG_LOOT_RESPONSE after CMSG_GAMEOBJ_USE. bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle"); if (entity && entity->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(entity); @@ -14060,6 +14434,8 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { refreshMailList(); } else if (info && info->type == 3) { chestLike = true; + // Type-3 chests require CMSG_LOOT on all expansions (AzerothCore WotLK included) + shouldSendLoot = true; } else if (turtleMode) { // Turtle compatibility: keep eager loot open behavior. shouldSendLoot = true; @@ -14070,21 +14446,19 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); chestLike = (lower.find("chest") != std::string::npos); + if (chestLike) shouldSendLoot = true; } - // For WotLK chest-like gameobjects, report use but let server open loot. - if (!isMailbox && chestLike) { - if (isActiveExpansion("wotlk")) { - network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); - reportUse.writeUInt64(guid); - socket->send(reportUse); - } + // For WotLK chest-like gameobjects, also send CMSG_GAMEOBJ_REPORT_USE. + if (!isMailbox && chestLike && isActiveExpansion("wotlk")) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); } if (shouldSendLoot) { lootTarget(guid); } - // Retry use briefly to survive packet loss/order races. Keep loot retries only - // when we intentionally use eager loot-open mode. - const bool retryLoot = shouldSendLoot && (turtleMode || isActiveExpansion("classic")); + // Retry use briefly to survive packet loss/order races. + const bool retryLoot = shouldSendLoot; const bool retryUse = turtleMode || isActiveExpansion("classic"); if (retryUse || retryLoot) { pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot}); @@ -14610,6 +14984,26 @@ void GameHandler::buyBackItem(uint32_t buybackSlot) { socket->send(packet); } +void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_REPAIR_ITEM: npcGuid(8) + itemGuid(8) + useGuildBank(uint8) + network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(itemGuid); + packet.writeUInt8(0); // do not use guild bank + socket->send(packet); +} + +void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { + if (state != WorldState::IN_WORLD || !socket) return; + // itemGuid = 0 signals "repair all equipped" to the server + network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(0); + packet.writeUInt8(useGuildBank ? 1 : 0); + socket->send(packet); +} + void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { if (state != WorldState::IN_WORLD || !socket) return; LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid, @@ -15173,7 +15567,9 @@ void GameHandler::handleGossipComplete(network::Packet& packet) { } void GameHandler::handleListInventory(network::Packet& packet) { + bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set before openVendor() if (!ListInventoryParser::parse(packet, currentVendorItems)) return; + currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens @@ -15275,8 +15671,10 @@ void GameHandler::loadSpellNameCache() { return; } - if (dbc->getFieldCount() < 154) { - LOG_WARNING("Trainer: Spell.dbc has too few fields"); + // Classic 1.12 Spell.dbc has 148 fields; TBC/WotLK have more. + // Require at least 148 so Classic trainers can resolve spell names. + if (dbc->getFieldCount() < 148) { + LOG_WARNING("Trainer: Spell.dbc has too few fields (", dbc->getFieldCount(), ")"); return; } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 33d39b77..35dc54f4 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1242,8 +1242,8 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell @@ -1266,7 +1266,10 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } } @@ -1302,6 +1305,40 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ data.delayMs = packet.readUInt32(); } + // AmmoType + RangedModRange (2 fields, 8 bytes) + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + } + + // 2 item spells in Vanilla (3 fields each: SpellId, Trigger, Charges) + // Actually vanilla has 5 spells: SpellId, Trigger, Charges, Cooldown, Category, CatCooldown = 24 bytes each + for (int i = 0; i < 5; i++) { + if (packet.getReadPos() + 24 > packet.getSize()) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Description (flavor/lore text) + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + + // Post-description: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality, " invType=", data.inventoryType, " stack=", data.maxStack, ")"); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index d4cad578..c523df13 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -739,9 +739,15 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t choiceCount = packet.readUInt32(); for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = i; + data.rewardChoiceItems.push_back(ri); + } } } @@ -749,9 +755,14 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t rewardCount = packet.readUInt32(); for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } } } @@ -906,8 +917,8 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell @@ -931,7 +942,10 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } // TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only) @@ -963,6 +977,39 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery data.delayMs = packet.readUInt32(); } + // AmmoType + RangedModRange + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + } + + // 5 item spells + for (int i = 0; i < 5; i++) { + if (packet.getReadPos() + 24 > packet.getSize()) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Flavor/lore text + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + + // Post-description: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); LOG_DEBUG("[TBC] Item query: ", data.name, " quality=", data.quality, " invType=", data.inventoryType, " armor=", data.armor); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d47c568d..c3adbcb0 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1422,6 +1422,14 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { break; } + case ChatType::BG_SYSTEM_NEUTRAL: + case ChatType::BG_SYSTEM_ALLIANCE: + case ChatType::BG_SYSTEM_HORDE: + // BG/Arena system messages — no sender GUID or name field, just message. + // Reclassify as SYSTEM for consistent display. + data.type = ChatType::SYSTEM; + break; + default: // SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc. // All have receiverGuid (typically senderGuid repeated) @@ -2441,8 +2449,8 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell @@ -2466,7 +2474,10 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } @@ -2510,6 +2521,22 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // SpellCategoryCooldown } + // Bonding type (0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ) + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Flavor/lore text (Description cstring) + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + + // Post-description fields: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); return true; } @@ -3419,9 +3446,15 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) /*choiceCount*/ packet.readUInt32(); for (int i = 0; i < 6; i++) { if (packet.getReadPos() + 12 > packet.getSize()) break; - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = static_cast(i); + data.rewardChoiceItems.push_back(ri); + } } } @@ -3430,9 +3463,14 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) /*rewardCount*/ packet.readUInt32(); for (int i = 0; i < 4; i++) { if (packet.getReadPos() + 12 > packet.getSize()) break; - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } } } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index cd6f7c27..cfa6120a 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -217,6 +217,7 @@ void CameraController::update(float deltaTime) { bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); + bool spaceDown = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; @@ -275,8 +276,10 @@ void CameraController::update(float deltaTime) { if (mouseAutorun) { autoRunning = false; } - bool nowForward = keyW || mouseAutorun || autoRunning; - bool nowBackward = keyS; + // When the server has rooted the player, suppress all horizontal movement input. + const bool movBlocked = movementRooted_; + bool nowForward = !movBlocked && (keyW || mouseAutorun || autoRunning); + bool nowBackward = !movBlocked && keyS; bool nowStrafeLeft = false; bool nowStrafeRight = false; bool nowTurnLeft = false; @@ -285,21 +288,27 @@ void CameraController::update(float deltaTime) { // WoW-like third-person keyboard behavior: // - RMB held: A/D strafe // - RMB released: A/D turn character+camera, Q/E strafe + // Turning is allowed even while rooted; only positional movement is blocked. if (thirdPerson && !rightMouseDown) { nowTurnLeft = keyA; nowTurnRight = keyD; - nowStrafeLeft = keyQ; - nowStrafeRight = keyE; + nowStrafeLeft = !movBlocked && keyQ; + nowStrafeRight = !movBlocked && keyE; } else { - nowStrafeLeft = keyA || keyQ; - nowStrafeRight = keyD || keyE; + nowStrafeLeft = !movBlocked && (keyA || keyQ); + nowStrafeRight = !movBlocked && (keyD || keyE); } - // Keyboard turning updates camera yaw (character follows yaw in renderer) + // Keyboard turning updates camera yaw (character follows yaw in renderer). + // Use server turn rate (rad/s) when set; otherwise fall back to WOW_TURN_SPEED (deg/s). + const float activeTurnSpeedDeg = (turnRateOverride_ > 0.0f && turnRateOverride_ < 20.0f + && !std::isnan(turnRateOverride_)) + ? glm::degrees(turnRateOverride_) + : WOW_TURN_SPEED; if (nowTurnLeft && !nowTurnRight) { - yaw += WOW_TURN_SPEED * deltaTime; + yaw += activeTurnSpeedDeg * deltaTime; } else if (nowTurnRight && !nowTurnLeft) { - yaw -= WOW_TURN_SPEED * deltaTime; + yaw -= activeTurnSpeedDeg * deltaTime; } if (nowTurnLeft || nowTurnRight) { camera->setRotation(yaw, pitch); @@ -315,9 +324,12 @@ void CameraController::update(float deltaTime) { if (useWoWSpeed) { // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower) if (nowBackward && !nowForward) { - speed = WOW_BACK_SPEED; + speed = (runBackSpeedOverride_ > 0.0f && runBackSpeedOverride_ < 100.0f + && !std::isnan(runBackSpeedOverride_)) + ? runBackSpeedOverride_ : WOW_BACK_SPEED; } else if (ctrlDown) { - speed = WOW_WALK_SPEED; + speed = (walkSpeedOverride_ > 0.0f && walkSpeedOverride_ < 100.0f && !std::isnan(walkSpeedOverride_)) + ? walkSpeedOverride_ : WOW_WALK_SPEED; } else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) { speed = runSpeedOverride_; } else { @@ -406,7 +418,14 @@ void CameraController::update(float deltaTime) { constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f; constexpr float MIN_SWIM_WATER_DEPTH = 1.0f; bool inWater = false; - if (waterH && targetPos.z < *waterH) { + // Water Walk: treat water surface as ground — player walks on top, not through. + if (waterWalkActive_ && waterH && targetPos.z >= *waterH - 0.5f) { + // Clamp to water surface so the player stands on it + targetPos.z = *waterH; + verticalVelocity = 0.0f; + grounded = true; + inWater = false; + } else if (waterH && targetPos.z < *waterH) { std::optional waterType; if (waterRenderer) { waterType = waterRenderer->getWaterTypeAt(targetPos.x, targetPos.y); @@ -504,7 +523,8 @@ void CameraController::update(float deltaTime) { swimming = true; // Swim movement follows look pitch (forward/back), while strafe stays // lateral for stable control. - float swimSpeed = speed * SWIM_SPEED_FACTOR; + float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_)) + ? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR; float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z; // For auto-run/auto-swim: use character facing (immune to camera pan) @@ -523,6 +543,10 @@ void CameraController::update(float deltaTime) { // Use character's facing direction for strafe, not camera's right vector glm::vec3 swimRight = right; // Character's right (horizontal facing), not camera's + float swimBackSpeed = (swimBackSpeedOverride_ > 0.0f && swimBackSpeedOverride_ < 100.0f + && !std::isnan(swimBackSpeedOverride_)) + ? swimBackSpeedOverride_ : swimSpeed * 0.5f; + glm::vec3 swimMove(0.0f); if (nowForward) swimMove += swimForward; if (nowBackward) swimMove -= swimForward; @@ -531,7 +555,9 @@ void CameraController::update(float deltaTime) { if (glm::length(swimMove) > 0.001f) { swimMove = glm::normalize(swimMove); - targetPos += swimMove * swimSpeed * physicsDeltaTime; + // Use backward swim speed when moving backwards only (not when combining with strafe) + float applySpeed = (nowBackward && !nowForward) ? swimBackSpeed : swimSpeed; + targetPos += swimMove * applySpeed * physicsDeltaTime; } // Spacebar = swim up (continuous, not a jump) @@ -680,11 +706,60 @@ void CameraController::update(float deltaTime) { } swimming = false; + // Player-controlled flight (flying mount / druid Flight Form): + // Use 3D pitch-following movement with no gravity or grounding. + if (flyingActive_) { + grounded = true; // suppress fall-damage checks + verticalVelocity = 0.0f; + jumpBufferTimer = 0.0f; + coyoteTimer = 0.0f; + + // Forward/back follows camera 3D direction (same as swim) + glm::vec3 flyFwd = glm::normalize(forward3D); + if (glm::length(flyFwd) < 1e-4f) flyFwd = forward; + glm::vec3 flyMove(0.0f); + if (nowForward) flyMove += flyFwd; + if (nowBackward) flyMove -= flyFwd; + if (nowStrafeLeft) flyMove += right; + if (nowStrafeRight) flyMove -= right; + // Space = ascend, X = descend while airborne + bool flyDescend = !uiWantsKeyboard && xDown && mounted_; + if (nowJump) flyMove.z += 1.0f; + if (flyDescend) flyMove.z -= 1.0f; + if (glm::length(flyMove) > 0.001f) { + flyMove = glm::normalize(flyMove); + float flyFwdSpeed = (flightSpeedOverride_ > 0.0f && flightSpeedOverride_ < 200.0f + && !std::isnan(flightSpeedOverride_)) + ? flightSpeedOverride_ : speed; + float flyBackSpeed = (flightBackSpeedOverride_ > 0.0f && flightBackSpeedOverride_ < 200.0f + && !std::isnan(flightBackSpeedOverride_)) + ? flightBackSpeedOverride_ : flyFwdSpeed * 0.5f; + float flySpeed = (nowBackward && !nowForward) ? flyBackSpeed : flyFwdSpeed; + targetPos += flyMove * flySpeed * physicsDeltaTime; + } + targetPos.z += verticalVelocity * physicsDeltaTime; + // Skip all ground physics — go straight to collision/WMO sections + } else { + if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); targetPos += movement * speed * physicsDeltaTime; } + // Apply server-driven knockback horizontal velocity (decays over time). + if (knockbackActive_) { + targetPos.x += knockbackHorizVel_.x * physicsDeltaTime; + targetPos.y += knockbackHorizVel_.y * physicsDeltaTime; + // Exponential drag: reduce each frame so the player decelerates naturally. + float drag = std::exp(-KNOCKBACK_HORIZ_DRAG * physicsDeltaTime); + knockbackHorizVel_ *= drag; + // Once negligible, clear the flag so collision/grounding work normally. + if (glm::length(knockbackHorizVel_) < 0.05f) { + knockbackActive_ = false; + knockbackHorizVel_ = glm::vec2(0.0f); + } + } + // Jump with input buffering and coyote time if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME; if (grounded) coyoteTimer = COYOTE_TIME; @@ -700,10 +775,20 @@ void CameraController::update(float deltaTime) { jumpBufferTimer -= physicsDeltaTime; coyoteTimer -= physicsDeltaTime; - // Apply gravity - verticalVelocity += gravity * physicsDeltaTime; - targetPos.z += verticalVelocity * physicsDeltaTime; + // Apply gravity (skip when server has disabled gravity, e.g. Levitate spell) + if (gravityDisabled_) { + // Float in place: bleed off any downward velocity, allow upward to decay slowly + if (verticalVelocity < 0.0f) verticalVelocity = 0.0f; + else verticalVelocity *= std::max(0.0f, 1.0f - 3.0f * physicsDeltaTime); + } else { + verticalVelocity += gravity * physicsDeltaTime; + // Feather Fall / Slow Fall: cap downward terminal velocity to ~2 m/s + if (featherFallActive_ && verticalVelocity < -2.0f) + verticalVelocity = -2.0f; } + targetPos.z += verticalVelocity * physicsDeltaTime; + } // end !flyingActive_ ground physics + } // end !inWater } else { // External follow (e.g., taxi): trust server position without grounding. swimming = false; @@ -1180,7 +1265,10 @@ void CameraController::update(float deltaTime) { dz >= -0.25f && dz <= stepUp * 1.5f); if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) { - targetPos.z = *groundH; + // HOVER: float at fixed height above ground instead of standing on it + static constexpr float HOVER_HEIGHT = 4.0f; // ~4 yards above ground + const float snapH = hoverActive_ ? (*groundH + HOVER_HEIGHT) : *groundH; + targetPos.z = snapH; verticalVelocity = 0.0f; grounded = true; lastGroundZ = *groundH; @@ -1495,7 +1583,8 @@ void CameraController::update(float deltaTime) { if (inWater) { swimming = true; - float swimSpeed = speed * SWIM_SPEED_FACTOR; + float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_)) + ? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR; float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z; bool diveIntent = nowForward && (forward3D.z < -0.28f); @@ -1707,6 +1796,35 @@ void CameraController::update(float deltaTime) { } } + // Flight ascend/descend transitions (Space = ascend, X = descend while mounted+flying) + if (movementCallback && !externalFollow_) { + const bool nowAscending = flyingActive_ && spaceDown; + const bool nowDescending = flyingActive_ && xDown && mounted_; + + if (flyingActive_) { + if (nowAscending && !wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_START_ASCEND)); + } else if (!nowAscending && wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + if (nowDescending && !wasDescending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_START_DESCEND)); + } else if (!nowDescending && wasDescending_) { + // No separate STOP_DESCEND opcode; STOP_ASCEND ends all vertical movement + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + } else { + // Left flight mode: clear any lingering vertical movement states + if (wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } else if (wasDescending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + } + wasAscending_ = nowAscending; + wasDescending_ = nowDescending; + } + // Update previous-frame state wasSwimming = swimming; wasMovingForward = nowForward; @@ -2096,5 +2214,28 @@ void CameraController::triggerMountJump() { } } +void CameraController::applyKnockBack(float vcos, float vsin, float hspeed, float vspeed) { + // The server sends (vcos, vsin) as the 2D direction vector in server/wire + // coordinate space. After the server→canonical→render swaps, the direction + // in render space is simply (vcos, vsin) — the two swaps cancel each other. + knockbackHorizVel_ = glm::vec2(vcos, vsin) * hspeed; + knockbackActive_ = true; + + // vspeed in the wire packet is negative when the server wants to launch the + // player upward (matches TrinityCore: data << float(-speedZ)). Negate it + // here to obtain the correct upward initial velocity. + verticalVelocity = -vspeed; + grounded = false; + coyoteTimer = 0.0f; + jumpBufferTimer = 0.0f; + + // Notify the server that the player left the ground so the FALLING flag is + // set in subsequent movement heartbeats. The normal jump detection + // (nowJump && grounded) does not fire during a server-driven knockback. + if (movementCallback) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_JUMP)); + } +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 6da94182..71cb2a7c 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2096,8 +2096,8 @@ void Renderer::updateCharacterAnimation() { // Rider uses character facing yaw, not mount bone rotation // (rider faces character direction, seat bone only provides position) float yawRad = glm::radians(characterYaw); - float riderPitch = taxiFlight_ ? mountPitch_ * 0.35f : 0.0f; - float riderRoll = taxiFlight_ ? mountRoll_ * 0.35f : 0.0f; + float riderPitch = mountPitch_ * 0.35f; + float riderRoll = mountRoll_ * 0.35f; characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad)); } else { // Fallback to old manual positioning if attachment not found @@ -4737,7 +4737,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { auto t0 = std::chrono::steady_clock::now(); VkCommandBuffer cmd = beginSecondary(SEC_WMO); setSecondaryViewportScissor(cmd); - wmoRenderer->render(cmd, perFrameSet, *camera); + wmoRenderer->render(cmd, perFrameSet, *camera, &characterPosition); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -4905,7 +4905,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (wmoRenderer && camera && !skipWMO) { wmoRenderer->prepareRender(); auto wmoStart = std::chrono::steady_clock::now(); - wmoRenderer->render(currentCmd, perFrameSet, *camera); + wmoRenderer->render(currentCmd, perFrameSet, *camera, &characterPosition); lastWMORenderMs = std::chrono::duration( std::chrono::steady_clock::now() - wmoStart).count(); } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 4d52fd76..85f56431 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1356,7 +1356,8 @@ void WMORenderer::prepareRender() { } } -void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { +void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, + const glm::vec3* viewerPos) { if (!opaquePipeline_ || instances.empty()) { lastDrawCalls = 0; return; @@ -1380,6 +1381,11 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } glm::vec3 camPos = camera.getPosition(); + // For portal culling, use the character/player position when available. + // The 3rd-person camera can orbit outside a WMO while the character is inside, + // causing the portal traversal to start from outside and cull interior groups. + // Passing the actual character position as the viewer fixes this. + glm::vec3 portalViewerPos = viewerPos ? *viewerPos : camPos; bool doPortalCull = portalCulling; bool doDistanceCull = distanceCulling; @@ -1400,7 +1406,7 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); if (usePortalCulling) { std::unordered_set pvgSet; - glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); + glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(portalViewerPos, 1.0f); getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, instance.modelMatrix, pvgSet); portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end()); @@ -2049,12 +2055,25 @@ bool WMORenderer::isPortalVisible(const ModelData& model, uint16_t portalIndex, } center /= static_cast(portal.vertexCount); - // Transform bounds to world space for frustum test - glm::vec4 worldMin = modelMatrix * glm::vec4(pMin, 1.0f); - glm::vec4 worldMax = modelMatrix * glm::vec4(pMax, 1.0f); + // Transform all 8 corners to world space to build the correct world AABB. + // Direct transform of pMin/pMax is wrong for rotated WMOs — the matrix can + // swap or negate components, inverting min/max and causing frustum test failures. + const glm::vec3 corners[8] = { + {pMin.x, pMin.y, pMin.z}, {pMax.x, pMin.y, pMin.z}, + {pMin.x, pMax.y, pMin.z}, {pMax.x, pMax.y, pMin.z}, + {pMin.x, pMin.y, pMax.z}, {pMax.x, pMin.y, pMax.z}, + {pMin.x, pMax.y, pMax.z}, {pMax.x, pMax.y, pMax.z}, + }; + glm::vec3 worldMin( std::numeric_limits::max()); + glm::vec3 worldMax(-std::numeric_limits::max()); + for (const auto& c : corners) { + glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f)); + worldMin = glm::min(worldMin, wc); + worldMax = glm::max(worldMax, wc); + } // Check if portal AABB intersects frustum (more robust than point test) - return frustum.intersectsAABB(glm::vec3(worldMin), glm::vec3(worldMax)); + return frustum.intersectsAABB(worldMin, worldMax); } void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1a7ac0e1..3b3c7216 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -436,6 +436,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDeathScreen(gameHandler); renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); + renderTalentWipeConfirmDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -1180,6 +1181,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (!shouldShowMessage(msg, activeChatTab_)) continue; std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); + // Resolve sender name at render time in case it wasn't available at parse time. + // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. + const std::string& resolvedSenderName = [&]() -> const std::string& { + if (!msg.senderName.empty()) return msg.senderName; + if (msg.senderGuid == 0) return msg.senderName; + const std::string& cached = gameHandler.lookupName(msg.senderGuid); + if (!cached.empty()) return cached; + return msg.senderName; + }(); + ImVec4 color = getChatTypeColor(msg.type); // Optional timestamp prefix @@ -1197,37 +1208,68 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { tsPrefix = tsBuf; } + // Build chat tag prefix: , , from chatTag bitmask + std::string tagPrefix; + if (msg.chatTag & 0x04) tagPrefix = " "; + else if (msg.chatTag & 0x01) tagPrefix = " "; + else if (msg.chatTag & 0x02) tagPrefix = " "; + if (msg.type == game::ChatType::SYSTEM) { renderTextWithLinks(tsPrefix + processedMessage, color); } else if (msg.type == game::ChatType::TEXT_EMOTE) { renderTextWithLinks(tsPrefix + processedMessage, color); - } else if (!msg.senderName.empty()) { - if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - std::string fullMsg = tsPrefix + msg.senderName + " says: " + processedMessage; + } else if (!resolvedSenderName.empty()) { + if (msg.type == game::ChatType::SAY || + msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_YELL) { - std::string fullMsg = tsPrefix + msg.senderName + " yells: " + processedMessage; + } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - std::string fullMsg = tsPrefix + msg.senderName + " whispers: " + processedMessage; + } else if (msg.type == game::ChatType::WHISPER || + msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - std::string fullMsg = tsPrefix + msg.senderName + " " + processedMessage; + } else if (msg.type == game::ChatType::WHISPER_INFORM) { + // Outgoing whisper — show "To Name: message" (WoW-style) + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; + std::string fullMsg = tsPrefix + "To " + target + ": " + processedMessage; + renderTextWithLinks(fullMsg, color); + } else if (msg.type == game::ChatType::EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { int chIdx = gameHandler.getChannelIndex(msg.channelName); std::string chDisplay = chIdx > 0 ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" : "[" + msg.channelName + "]"; - std::string fullMsg = tsPrefix + chDisplay + " [" + msg.senderName + "]: " + processedMessage; + std::string fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; renderTextWithLinks(fullMsg, color); } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": " + processedMessage; + std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; renderTextWithLinks(fullMsg, color); } } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; - renderTextWithLinks(fullMsg, color); + // No sender name. For group/channel types show a bracket prefix; + // for sender-specific types (SAY, YELL, WHISPER, etc.) just show the + // raw message — these are server-side announcements without a speaker. + bool isGroupType = + msg.type == game::ChatType::PARTY || + msg.type == game::ChatType::GUILD || + msg.type == game::ChatType::OFFICER || + msg.type == game::ChatType::RAID || + msg.type == game::ChatType::RAID_LEADER || + msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::BATTLEGROUND || + msg.type == game::ChatType::BATTLEGROUND_LEADER; + if (isGroupType) { + std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; + renderTextWithLinks(fullMsg, color); + } else { + // SAY, YELL, WHISPER, unknown BG_SYSTEM_* types, etc. — no prefix + renderTextWithLinks(tsPrefix + processedMessage, color); + } } } @@ -2007,6 +2049,89 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::SmallButton("Dismiss")) { gameHandler.dismissPet(); } + + // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS + { + const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS; + // Filter to non-zero slots; lay them out as small icon/text buttons. + // Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID, + // high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type). + // Built-in commands: id=2 follow, id=3 stay/move, id=5 attack. + auto* assetMgr = core::Application::getInstance().getAssetManager(); + const float iconSz = 20.0f; + const float spacing = 2.0f; + ImGui::Separator(); + + int rendered = 0; + for (int i = 0; i < slotCount; ++i) { + uint32_t slotVal = gameHandler.getPetActionSlot(i); + if (slotVal == 0) continue; + + uint32_t actionId = slotVal & 0x00FFFFFFu; + // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. + bool autocastOn = gameHandler.isPetSpellAutocast(actionId); + + ImGui::PushID(i); + if (rendered > 0) ImGui::SameLine(0.0f, spacing); + + // Try to show spell icon; fall back to abbreviated text label. + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const char* builtinLabel = nullptr; + if (actionId == 2) builtinLabel = "Fol"; + else if (actionId == 3) builtinLabel = "Sty"; + else if (actionId == 5) builtinLabel = "Atk"; + 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); + bool clicked = false; + if (iconTex) { + clicked = ImGui::ImageButton("##pa", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(0.1f,0.1f,0.1f,0.9f), tint); + } else { + char label[8]; + if (builtinLabel) { + snprintf(label, sizeof(label), "%s", builtinLabel); + } else { + // Show first 3 chars of spell name or spell ID. + std::string nm = gameHandler.getSpellName(actionId); + 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)); + clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); + ImGui::PopStyleColor(); + } + + if (clicked) { + // Send pet action; use current target for spells. + uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; + gameHandler.sendPetAction(slotVal, targetGuid); + } + + // Tooltip: show spell name or built-in command name. + if (ImGui::IsItemHovered()) { + const char* tip = builtinLabel + ? (actionId == 5 ? "Attack" : actionId == 2 ? "Follow" : "Stay") + : nullptr; + std::string spellNm; + if (!tip && actionId > 5) { + spellNm = gameHandler.getSpellName(actionId); + if (!spellNm.empty()) tip = spellNm.c_str(); + } + if (tip) ImGui::SetTooltip("%s", tip); + } + + ImGui::PopID(); + ++rendered; + } + } } ImGui::End(); @@ -2268,20 +2393,23 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { IM_COL32(255, 220, 50, 255), chargeStr); } - // Tooltip + // Tooltip: rich spell info + remaining duration if (ImGui::IsItemHovered()) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } if (tRemainMs > 0) { int seconds = tRemainMs / 1000; - if (seconds < 60) { - ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); - } else { - ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); - } - } else { - ImGui::SetTooltip("%s", name.c_str()); + char durBuf[32]; + if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds); + else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf); } + ImGui::EndTooltip(); } ImGui::PopID(); @@ -3374,29 +3502,32 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { const char* GameScreen::getChatTypeName(game::ChatType type) const { switch (type) { - case game::ChatType::SAY: return "SAY"; - case game::ChatType::YELL: return "YELL"; - case game::ChatType::EMOTE: return "EMOTE"; - case game::ChatType::TEXT_EMOTE: return "EMOTE"; - case game::ChatType::PARTY: return "PARTY"; - case game::ChatType::GUILD: return "GUILD"; - case game::ChatType::OFFICER: return "OFFICER"; - case game::ChatType::RAID: return "RAID"; - case game::ChatType::RAID_LEADER: return "RAID LEADER"; - case game::ChatType::RAID_WARNING: return "RAID WARNING"; - case game::ChatType::BATTLEGROUND: return "BATTLEGROUND"; - case game::ChatType::BATTLEGROUND_LEADER: return "BG LEADER"; - case game::ChatType::WHISPER: return "WHISPER"; - case game::ChatType::WHISPER_INFORM: return "TO"; - case game::ChatType::SYSTEM: return "SYSTEM"; - case game::ChatType::MONSTER_SAY: return "SAY"; - case game::ChatType::MONSTER_YELL: return "YELL"; - case game::ChatType::MONSTER_EMOTE: return "EMOTE"; - case game::ChatType::CHANNEL: return "CHANNEL"; - case game::ChatType::ACHIEVEMENT: return "ACHIEVEMENT"; + case game::ChatType::SAY: return "Say"; + case game::ChatType::YELL: return "Yell"; + case game::ChatType::EMOTE: return "Emote"; + case game::ChatType::TEXT_EMOTE: return "Emote"; + case game::ChatType::PARTY: return "Party"; + case game::ChatType::GUILD: return "Guild"; + case game::ChatType::OFFICER: return "Officer"; + case game::ChatType::RAID: return "Raid"; + case game::ChatType::RAID_LEADER: return "Raid Leader"; + case game::ChatType::RAID_WARNING: return "Raid Warning"; + case game::ChatType::BATTLEGROUND: return "Battleground"; + case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; + case game::ChatType::WHISPER: return "Whisper"; + case game::ChatType::WHISPER_INFORM: return "To"; + case game::ChatType::SYSTEM: return "System"; + case game::ChatType::MONSTER_SAY: return "Say"; + case game::ChatType::MONSTER_YELL: return "Yell"; + case game::ChatType::MONSTER_EMOTE: return "Emote"; + case game::ChatType::CHANNEL: return "Channel"; + case game::ChatType::ACHIEVEMENT: return "Achievement"; case game::ChatType::DND: return "DND"; case game::ChatType::AFK: return "AFK"; - default: return "UNKNOWN"; + case game::ChatType::BG_SYSTEM_NEUTRAL: + case game::ChatType::BG_SYSTEM_ALLIANCE: + case game::ChatType::BG_SYSTEM_HORDE: return "System"; + default: return "Unknown"; } } @@ -3852,9 +3983,11 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage } void GameScreen::renderActionBar(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + // Use ImGui's display size — always in sync with the current swap-chain/frame, + // whereas window->getWidth/Height() can lag by one frame on resize events. + 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(); float slotSize = 48.0f; @@ -4011,9 +4144,15 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { // Tooltip if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { - ImGui::BeginTooltip(); if (slot.type == game::ActionBarSlot::SPELL) { - ImGui::Text("%s", getSpellName(slot.id).c_str()); + // Use the spellbook's rich tooltip (school, cost, cast time, range, description). + // Falls back to the simple name if DBC data isn't loaded yet. + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(slot.id, gameHandler, assetMgr); + if (!richOk) { + ImGui::Text("%s", getSpellName(slot.id).c_str()); + } + // Hearthstone: add location note after the spell tooltip body if (slot.id == 8690) { uint32_t mapId = 0; glm::vec3 pos; if (gameHandler.getHomeBind(mapId, pos)) { @@ -4026,25 +4165,34 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); } - ImGui::TextDisabled("Use: Teleport home"); } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { + ImGui::BeginTooltip(); if (barItemDef && !barItemDef->name.empty()) ImGui::Text("%s", barItemDef->name.c_str()); else if (!itemNameFromQuery.empty()) ImGui::Text("%s", itemNameFromQuery.c_str()); else ImGui::Text("Item #%u", slot.id); + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); } - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); - else - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Cooldown: %.1f sec", cd); - } - ImGui::EndTooltip(); } // Cooldown overlay: WoW-style clock-sweep + time text @@ -4092,13 +4240,14 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { }; // Bar 2 (slots 12-23) — only show if at least one slot is populated - { + if (pendingShowActionBar2) { bool bar2HasContent = false; for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } - float bar2Y = barY - barH - 2.0f; - ImGui::SetNextWindowPos(ImVec2(barX, bar2Y), ImGuiCond_Always); + float bar2X = barX + pendingActionBar2OffsetX; + float bar2Y = barY - barH - 2.0f + pendingActionBar2OffsetY; + ImGui::SetNextWindowPos(ImVec2(bar2X, bar2Y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); @@ -4129,6 +4278,64 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); ImGui::PopStyleVar(4); + // Right side vertical bar (bar 3, slots 24-35) + if (pendingShowRightBar) { + bool bar3HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 2 + i].isEmpty()) { bar3HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = screenW - sideBarW - 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + pendingRightBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar3HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarRight", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 2 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Left side vertical bar (bar 4, slots 36-47) + if (pendingShowLeftBar) { + bool bar4HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 3 + i].isEmpty()) { bar4HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + pendingLeftBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar4HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarLeft", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 3 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + // Handle action bar drag: render icon at cursor and detect drop outside if (actionBarDragSlot_ >= 0) { ImVec2 mousePos = ImGui::GetMousePos(); @@ -4165,9 +4372,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + 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(); float slotSize = 42.0f; @@ -4412,9 +4619,11 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { uint32_t currentXp = gameHandler.getPlayerXp(); uint32_t restedXp = gameHandler.getPlayerRestedXp(); bool isResting = gameHandler.isPlayerResting(); + 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* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + (void)window; // Not used for positioning; kept for AssetManager if needed // Position just above both action bars (bar1 at screenH-barH, bar2 above that) float slotSize = 48.0f; @@ -4426,8 +4635,17 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { float xpBarH = 20.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; - // bar1 is at screenH-barH, bar2 is at screenH-2*barH-2; XP bar sits above bar2 - float xpBarY = screenH - 2.0f * barH - 2.0f - xpBarH - 2.0f; + // XP bar sits just above whichever bar is topmost. + // bar1 top edge: screenH - barH + // bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset + float bar1TopY = screenH - barH; + float xpBarY; + if (pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH - 2.0f + pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); @@ -4518,9 +4736,9 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { void GameScreen::renderCastBar(game::GameHandler& gameHandler) { if (!gameHandler.isCasting()) 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; + 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; float barW = 300.0f; float barX = (screenW - barW) / 2.0f; @@ -4565,9 +4783,9 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + 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; static const struct { const char* label; ImVec4 color; } kTimerInfo[3] = { { "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) }, @@ -4946,16 +5164,26 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Name + level label above health bar uint32_t level = unit->getLevel(); + const std::string& unitName = unit->getName(); char labelBuf[96]; - if (level > 0) { + if (isPlayer) { + // Player nameplates: show name only (no level clutter). + // Fall back to level as placeholder while the name query is pending. + if (!unitName.empty()) + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); + else if (level > 0) + snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level); + else + snprintf(labelBuf, sizeof(labelBuf), "Player"); + } else if (level > 0) { uint32_t playerLevel = gameHandler.getPlayerLevel(); // Show skull for units more than 10 levels above the player if (playerLevel > 0 && level > playerLevel + 10) - snprintf(labelBuf, sizeof(labelBuf), "?? %s", unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str()); else - snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str()); } else { - snprintf(labelBuf, sizeof(labelBuf), "%s", unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); } ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; @@ -6169,20 +6397,23 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } } - // Tooltip with spell name and countdown + // Tooltip: rich spell info + remaining duration if (ImGui::IsItemHovered()) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } if (remainMs > 0) { int seconds = remainMs / 1000; - if (seconds < 60) { - ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); - } else { - ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); - } - } else { - ImGui::SetTooltip("%s", name.c_str()); + char durBuf[32]; + if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds); + else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf); } + ImGui::EndTooltip(); } ImGui::PopID(); @@ -6411,6 +6642,9 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { std::string processedText = replaceGenderPlaceholders(displayText, gameHandler); std::string label = std::string(icon) + " " + processedText; if (ImGui::Selectable(label.c_str())) { + if (opt.text == "GOSSIP_OPTION_ARMORER") { + gameHandler.setVendorCanRepair(true); + } gameHandler.selectGossipOption(opt.id); } ImGui::PopID(); @@ -6501,7 +6735,65 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { ImGui::TextWrapped("%s", processedObjectives.c_str()); } - // Rewards + // Choice reward items (player picks one) + auto renderQuestRewardItem = [&](const game::QuestRewardItem& ri) { + gameHandler.ensureItemInfo(ri.itemId); + auto* info = gameHandler.getItemInfo(ri.itemId); + VkDescriptorSet iconTex = VK_NULL_HANDLE; + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); + + std::string label; + ImVec4 nameCol = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (info && info->valid && !info->name.empty()) { + label = info->name; + nameCol = InventoryScreen::getQualityColor(static_cast(info->quality)); + } else { + label = "Item " + std::to_string(ri.itemId); + } + if (ri.count > 1) label += " x" + std::to_string(ri.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(nameCol, "%s", info->name.c_str()); + if (!info->description.empty()) + ImGui::TextWrapped("%s", info->description.c_str()); + ImGui::EndTooltip(); + } + ImGui::SameLine(); + } + ImGui::TextColored(nameCol, " %s", label.c_str()); + if (ImGui::IsItemHovered() && info && info->valid && !info->description.empty()) { + ImGui::BeginTooltip(); + ImGui::TextColored(nameCol, "%s", info->name.c_str()); + ImGui::TextWrapped("%s", info->description.c_str()); + ImGui::EndTooltip(); + } + }; + + if (!quest.rewardChoiceItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose one reward:"); + for (const auto& ri : quest.rewardChoiceItems) { + renderQuestRewardItem(ri); + } + } + + // Fixed reward items (always given) + if (!quest.rewardItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will receive:"); + for (const auto& ri : quest.rewardItems) { + renderQuestRewardItem(ri); + } + } + + // XP and money rewards if (quest.rewardXp > 0 || quest.rewardMoney > 0) { ImGui::Spacing(); ImGui::Separator(); @@ -6667,6 +6959,35 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } // Choice rewards (pick one) + // Trigger item info fetch for all reward items + for (const auto& item : quest.choiceRewards) gameHandler.ensureItemInfo(item.itemId); + for (const auto& item : quest.fixedRewards) gameHandler.ensureItemInfo(item.itemId); + + // Helper: resolve icon tex + quality color for a reward item + auto resolveRewardItemVis = [&](const game::QuestRewardItem& ri) + -> std::pair + { + auto* info = gameHandler.getItemInfo(ri.itemId); + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + ImVec4 col = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + return {iconTex, col}; + }; + + // Helper: show item tooltip + auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 nameCol) { + auto* info = gameHandler.getItemInfo(ri.itemId); + if (!info || !info->valid) return; + ImGui::BeginTooltip(); + ImGui::TextColored(nameCol, "%s", info->name.c_str()); + if (!info->description.empty()) + ImGui::TextWrapped("%s", info->description.c_str()); + ImGui::EndTooltip(); + }; + if (!quest.choiceRewards.empty()) { ImGui::Spacing(); ImGui::Separator(); @@ -6675,48 +6996,29 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { const auto& item = quest.choiceRewards[i]; auto* info = gameHandler.getItemInfo(item.itemId); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); bool selected = (selectedChoice == static_cast(i)); - - // Get item icon if we have displayInfoId - VkDescriptorSet iconTex = VK_NULL_HANDLE; - if (info && info->valid && info->displayInfoId != 0) { - iconTex = inventoryScreen.getItemIcon(info->displayInfoId); - } - - // Quality color - ImVec4 qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White (poor) - if (info && info->valid) { - switch (info->quality) { - case 1: qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // Common (white) - case 2: qualityColor = ImVec4(0.0f, 1.0f, 0.0f, 1.0f); break; // Uncommon (green) - case 3: qualityColor = ImVec4(0.0f, 0.5f, 1.0f, 1.0f); break; // Rare (blue) - case 4: qualityColor = ImVec4(0.64f, 0.21f, 0.93f, 1.0f); break; // Epic (purple) - case 5: qualityColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // Legendary (orange) - } - } - - // Render item with icon + visible selectable label ImGui::PushID(static_cast(i)); - std::string label; - if (info && info->valid && !info->name.empty()) { - label = info->name; - } else { - label = "Item " + std::to_string(item.itemId); + + // Icon then selectable on same line + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(20, 20)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); } - if (item.count > 1) { - label += " x" + std::to_string(item.count); - } - if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 24))) { + ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); + if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { selectedChoice = static_cast(i); } - if (ImGui::IsItemHovered() && iconTex) { - ImGui::SetTooltip("Reward option"); - } - if (iconTex) { - ImGui::SameLine(); - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::PopID(); } } @@ -6728,10 +7030,20 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will also receive:"); for (const auto& item : quest.fixedRewards) { auto* info = gameHandler.getItemInfo(item.itemId); - if (info && info->valid) - ImGui::Text(" %s x%u", info->name.c_str(), item.count); - else - ImGui::Text(" Item %u x%u", item.itemId, item.count); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); + } + ImGui::TextColored(qualityColor, " %s", label.c_str()); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); } } @@ -6809,6 +7121,17 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + + if (vendor.canRepair) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.0f); + if (ImGui::SmallButton("Repair All")) { + gameHandler.repairAll(vendor.vendorGuid, false); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Repair all equipped items"); + } + } ImGui::Separator(); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell"); @@ -7525,6 +7848,80 @@ void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Talent Wipe Confirm Dialog +// ============================================================ + +void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showTalentWipeConfirmDialog()) 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("##TalentWipeDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getTalentWipeCost(); + 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 = "Reset your talents for "; + text += costStr; + text += "?"; + 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 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", ImVec2(btnW, 30))) { + gameHandler.confirmTalentWipe(); + } + 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", ImVec2(btnW, 30))) { + gameHandler.cancelTalentWipe(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Settings Window // ============================================================ @@ -7876,6 +8273,70 @@ void GameScreen::renderSettingsWindow() { ImGui::EndTabItem(); } + // ============================================================ + // INTERFACE TAB + // ============================================================ + if (ImGui::BeginTabItem("Interface")) { + ImGui::Spacing(); + ImGui::BeginChild("InterfaceSettings", ImVec2(0, 360), true); + + ImGui::SeparatorText("Action Bars"); + ImGui::Spacing(); + + if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Shift+1 through Shift+=)"); + + if (pendingShowActionBar2) { + ImGui::Spacing(); + ImGui::TextUnformatted("Second Bar Position Offset"); + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Horizontal##bar2x", &pendingActionBar2OffsetX, -600.0f, 600.0f, "%.0f px")) { + saveSettings(); + } + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical##bar2y", &pendingActionBar2OffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + if (ImGui::Button("Reset Position##bar2")) { + pendingActionBar2OffsetX = 0.0f; + pendingActionBar2OffsetY = 0.0f; + saveSettings(); + } + } + + ImGui::Spacing(); + if (ImGui::Checkbox("Show Right Side Bar", &pendingShowRightBar)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Slots 25-36)"); + if (pendingShowRightBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##rbar", &pendingRightBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + } + + ImGui::Spacing(); + if (ImGui::Checkbox("Show Left Side Bar", &pendingShowLeftBar)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Slots 37-48)"); + if (pendingShowLeftBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##lbar", &pendingLeftBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + // ============================================================ // AUDIO TAB // ============================================================ @@ -8935,6 +9396,13 @@ void GameScreen::saveSettings() { out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n"; out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; + out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; + out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; + out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; + out << "show_right_bar=" << (pendingShowRightBar ? 1 : 0) << "\n"; + out << "show_left_bar=" << (pendingShowLeftBar ? 1 : 0) << "\n"; + out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; + out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -9024,6 +9492,20 @@ void GameScreen::loadSettings() { } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); + } else if (key == "show_action_bar2") { + pendingShowActionBar2 = (std::stoi(val) != 0); + } else if (key == "action_bar2_offset_x") { + pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); + } else if (key == "action_bar2_offset_y") { + pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "show_right_bar") { + pendingShowRightBar = (std::stoi(val) != 0); + } else if (key == "show_left_bar") { + pendingShowLeftBar = (std::stoi(val) != 0); + } else if (key == "right_bar_offset_y") { + pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "left_bar_offset_y") { + pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } // Audio else if (key == "sound_muted") { @@ -10283,77 +10765,34 @@ void GameScreen::renderDingEffect() { dingTimer_ -= dt; if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; - float alpha = dingTimer_ < 0.8f ? (dingTimer_ / 0.8f) : 1.0f; // fade out last 0.8s - float elapsed = DING_DURATION - dingTimer_; // 0 → DING_DURATION + // Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s. + // The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2). + constexpr float kFadeTime = 0.5f; + float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f; + if (alpha <= 0.0f) return; ImGuiIO& io = ImGui::GetIO(); float cx = io.DisplaySize.x * 0.5f; - float cy = io.DisplaySize.y * 0.5f; + float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float baseSize = ImGui::GetFontSize(); + float fontSize = baseSize * 1.8f; - // ---- Golden radial ring burst (3 waves staggered by 0.45s) ---- - { - constexpr float kMaxRadius = 420.0f; - constexpr float kRingWidth = 18.0f; - constexpr float kWaveLen = 1.4f; // each wave lasts 1.4s - constexpr int kNumWaves = 3; - constexpr float kStagger = 0.45f; // seconds between waves + char buf[64]; + snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_); - for (int w = 0; w < kNumWaves; ++w) { - float waveElapsed = elapsed - w * kStagger; - if (waveElapsed <= 0.0f || waveElapsed >= kWaveLen) continue; + ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); + float tx = cx - sz.x * 0.5f; + float ty = cy - sz.y * 0.5f; - float t = waveElapsed / kWaveLen; // 0 → 1 - float radius = t * kMaxRadius; - float ringAlpha = (1.0f - t) * alpha; // fades as it expands - - ImU32 outerCol = IM_COL32(255, 215, 60, (int)(ringAlpha * 200)); - ImU32 innerCol = IM_COL32(255, 255, 150, (int)(ringAlpha * 120)); - - draw->AddCircle(ImVec2(cx, cy), radius, outerCol, 64, kRingWidth); - draw->AddCircle(ImVec2(cx, cy), radius * 0.92f, innerCol, 64, kRingWidth * 0.5f); - } - } - - // ---- Full-screen golden flash on first frame ---- - if (elapsed < 0.15f) { - float flashA = (1.0f - elapsed / 0.15f) * 0.45f; - draw->AddRectFilled(ImVec2(0, 0), io.DisplaySize, - IM_COL32(255, 200, 50, (int)(flashA * 255))); - } - - // "LEVEL X!" text — visible for first 2.2s - if (dingTimer_ > 0.8f) { - ImFont* font = ImGui::GetFont(); - float baseSize = ImGui::GetFontSize(); - float fontSize = baseSize * 2.8f; - - char buf[32]; - snprintf(buf, sizeof(buf), "LEVEL %u!", dingLevel_); - - ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); - float tx = cx - sz.x * 0.5f; - float ty = cy - sz.y * 0.5f - 20.0f; - - // Drop shadow - draw->AddText(font, fontSize, ImVec2(tx + 3, ty + 3), - IM_COL32(0, 0, 0, (int)(alpha * 200)), buf); - // Gold text - draw->AddText(font, fontSize, ImVec2(tx, ty), - IM_COL32(255, 215, 0, (int)(alpha * 255)), buf); - - // "DING!" subtitle - const char* ding = "DING!"; - float dingSize = baseSize * 1.8f; - ImVec2 dingSz = font->CalcTextSizeA(dingSize, FLT_MAX, 0.0f, ding); - float dx = cx - dingSz.x * 0.5f; - float dy = ty + sz.y + 6.0f; - draw->AddText(font, dingSize, ImVec2(dx + 2, dy + 2), - IM_COL32(0, 0, 0, (int)(alpha * 180)), ding); - draw->AddText(font, dingSize, ImVec2(dx, dy), - IM_COL32(255, 255, 150, (int)(alpha * 255)), ding); - } + // Slight black outline for readability + draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2), + IM_COL32(0, 0, 0, (int)(alpha * 180)), buf); + // Gold text + draw->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); } void GameScreen::triggerAchievementToast(uint32_t achievementId) { @@ -10583,6 +11022,20 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::Separator(); } + // ---- Vote-to-kick buttons ---- + if (state == LfgState::Boot) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Vote to kick in progress:"); + ImGui::Spacing(); + if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(true); + } + ImGui::SameLine(); + if (ImGui::Button("Vote No (keep)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(false); + } + ImGui::Separator(); + } + // ---- Teleport button (in dungeon) ---- if (state == LfgState::InDungeon) { if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) { diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 320fc316..8567c3ce 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1594,6 +1594,20 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite IM_COL32(255, 255, 255, 220), countStr); } + // Durability bar on equipment slots (3px strip at bottom of slot icon) + if (kind == SlotKind::EQUIPMENT && item.maxDurability > 0) { + float durPct = static_cast(item.curDurability) / + static_cast(item.maxDurability); + ImU32 durCol; + if (durPct > 0.5f) durCol = IM_COL32(0, 200, 0, 220); + else if (durPct > 0.25f) durCol = IM_COL32(220, 220, 0, 220); + else durCol = IM_COL32(220, 40, 40, 220); + float barW = size * durPct; + drawList->AddRectFilled(ImVec2(pos.x, pos.y + size - 3.0f), + ImVec2(pos.x + barW, pos.y + size), + durCol); + } + ImGui::InvisibleButton("slot", ImVec2(size, size)); // Left mouse: hold to pick up, release to drop/swap @@ -1700,6 +1714,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImVec4 qColor = getQualityColor(item.quality); ImGui::TextColored(qColor, "%s", item.name.c_str()); + if (item.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel); + } + + // Binding type + switch (item.bindType) { + case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; + case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break; + case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break; + case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break; + default: break; + } if (item.itemId == 6948 && gameHandler_) { uint32_t mapId = 0; @@ -1776,13 +1802,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I }; const bool isWeapon = isWeaponInventoryType(item.inventoryType); - // Compact stats view for weapons: DPS + condensed stat bonuses. - // Non-weapons keep armor/sell info visible. + // Compact stats view for weapons: damage range + speed + DPS ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) { float speed = static_cast(item.delayMs) / 1000.0f; float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + ImGui::Text("%.0f - %.0f Damage", item.damageMin, item.damageMax); + ImGui::SameLine(160.0f); + ImGui::TextDisabled("Speed %.2f", speed); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps); } // Armor appears before stat bonuses — matches WoW tooltip order @@ -1805,6 +1833,101 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (!bonusLine.empty()) { ImGui::TextColored(green, "%s", bonusLine.c_str()); } + + // Extra stats (hit, crit, haste, AP, SP, etc.) — one line each + for (const auto& es : item.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 0: statName = "Mana"; break; + case 1: statName = "Health"; break; + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 15: statName = "Block Rating"; break; + case 16: statName = "Hit Rating"; break; + case 17: statName = "Hit Rating"; break; + case 18: statName = "Hit Rating"; break; + case 19: statName = "Crit Rating"; break; + case 20: statName = "Crit Rating"; break; + case 21: statName = "Crit Rating"; break; + case 28: statName = "Haste Rating"; break; + case 29: statName = "Haste Rating"; break; + case 30: statName = "Haste Rating"; break; + case 31: statName = "Hit Rating"; break; + case 32: statName = "Crit Rating"; break; + case 35: statName = "Resilience"; break; + case 36: statName = "Haste Rating"; break; + case 37: statName = "Expertise Rating"; break; + case 38: statName = "Attack Power"; break; + case 39: statName = "Ranged Attack Power"; break; + case 41: statName = "Healing Power"; break; + case 42: statName = "Spell Damage"; break; + case 43: statName = "Mana per 5 sec"; break; + case 44: statName = "Armor Penetration"; break; + case 45: statName = "Spell Power"; break; + case 46: statName = "Health per 5 sec"; break; + case 47: statName = "Spell Penetration"; break; + case 48: statName = "Block Value"; break; + default: statName = nullptr; break; + } + char buf[64]; + if (statName) { + std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName); + } else { + std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType); + } + ImGui::TextColored(green, "%s", buf); + } + + if (item.requiredLevel > 1) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel); + } + if (item.maxDurability > 0) { + float durPct = static_cast(item.curDurability) / static_cast(item.maxDurability); + ImVec4 durColor; + if (durPct > 0.5f) durColor = ImVec4(0.1f, 1.0f, 0.1f, 1.0f); // green + else if (durPct > 0.25f) durColor = ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // yellow + else durColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); // red + ImGui::TextColored(durColor, "Durability %u / %u", + item.curDurability, item.maxDurability); + } + // Item spell effects (Use/Equip/Chance on Hit) + if (gameHandler_) { + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info) { + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* trigger = nullptr; + switch (sp.spellTrigger) { + case 0: trigger = "Use"; break; + case 1: trigger = "Equip"; break; + case 2: trigger = "Chance on Hit"; break; + case 6: trigger = "Soulstone"; break; + default: break; + } + if (!trigger) continue; + const std::string& spName = gameHandler_->getSpellName(sp.spellId); + if (!spName.empty()) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: %s", trigger, spName.c_str()); + } else { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: Spell #%u", trigger, sp.spellId); + } + } + } + } + + // "Begins a Quest" line (shown in yellow-green like the game) + if (item.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + + // Flavor / lore text (italic yellow in WoW, just yellow here) + if (!item.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", item.description.c_str()); + } + if (item.sellPrice > 0) { uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; @@ -1824,23 +1947,79 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - 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); + // Helper: render a numeric stat diff line + auto showDiff = [](const char* label, float newVal, float eqVal) { + if (newVal == 0.0f && eqVal == 0.0f) return; + float diff = newVal - eqVal; + char buf[128]; + if (diff > 0.0f) { + std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, newVal, diff); + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "%s", buf); + } else if (diff < 0.0f) { + std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, newVal, -diff); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", buf); + } else { + std::snprintf(buf, sizeof(buf), "%s: %.0f", label, newVal); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf); + } + }; + + // DPS comparison for weapons + if (isWeaponInventoryType(item.inventoryType) && isWeaponInventoryType(eq->item.inventoryType)) { + float newDps = 0.0f, eqDps = 0.0f; + if (item.damageMax > 0.0f && item.delayMs > 0) + newDps = ((item.damageMin + item.damageMax) * 0.5f) / (item.delayMs / 1000.0f); + if (eq->item.damageMax > 0.0f && eq->item.delayMs > 0) + eqDps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / (eq->item.delayMs / 1000.0f); + showDiff("DPS", newDps, eqDps); } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); + + // Armor + showDiff("Armor", static_cast(item.armor), static_cast(eq->item.armor)); + + // Primary stats + showDiff("Str", static_cast(item.strength), static_cast(eq->item.strength)); + showDiff("Agi", static_cast(item.agility), static_cast(eq->item.agility)); + showDiff("Sta", static_cast(item.stamina), static_cast(eq->item.stamina)); + showDiff("Int", static_cast(item.intellect), static_cast(eq->item.intellect)); + showDiff("Spi", static_cast(item.spirit), static_cast(eq->item.spirit)); + + // Extra stats diff — union of stat types from both items + auto findExtraStat = [](const game::ItemDef& it, uint32_t type) -> int32_t { + for (const auto& es : it.extraStats) + if (es.statType == type) return es.statValue; + return 0; + }; + // Collect all extra stat types + std::vector allTypes; + for (const auto& es : item.extraStats) allTypes.push_back(es.statType); + for (const auto& es : eq->item.extraStats) { + bool found = false; + for (uint32_t t : allTypes) if (t == es.statType) { found = true; break; } + if (!found) allTypes.push_back(es.statType); } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + for (uint32_t t : allTypes) { + int32_t nv = findExtraStat(item, t); + int32_t ev = findExtraStat(eq->item, t); + // Find a label for this stat type + const char* lbl = nullptr; + switch (t) { + case 31: lbl = "Hit"; break; + case 32: lbl = "Crit"; break; + case 35: lbl = "Resilience"; break; + case 36: lbl = "Haste"; break; + case 37: lbl = "Expertise"; break; + case 38: lbl = "Attack Power"; break; + case 39: lbl = "Ranged AP"; break; + case 43: lbl = "MP5"; break; + case 44: lbl = "Armor Pen"; break; + case 45: lbl = "Spell Power"; break; + case 46: lbl = "HP5"; break; + case 48: lbl = "Block Value"; break; + default: lbl = nullptr; break; + } + if (!lbl) continue; + showDiff(lbl, static_cast(nv), static_cast(ev)); } } } diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 6e857d73..f90090f7 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -45,18 +45,66 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { } uint32_t fieldCount = dbc->getFieldCount(); - if (fieldCount < 154) { - LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, expected 234+"); + // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~220+ (SchoolMask at 215), WotLK has 234. + // Require at least 148 fields so all expansions can load spell names/icons via the DBC layout. + if (fieldCount < 148) { + LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, too few to load"); return; } const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + // Load SpellCastTimes.dbc: field 0=ID, field 1=Base(ms), field 2=PerLevel, field 3=Minimum + std::unordered_map castTimeMap; // index → base ms + auto castTimeDbc = assetManager->loadDBC("SpellCastTimes.dbc"); + if (castTimeDbc && castTimeDbc->isLoaded()) { + for (uint32_t i = 0; i < castTimeDbc->getRecordCount(); ++i) { + uint32_t id = castTimeDbc->getUInt32(i, 0); + int32_t base = static_cast(castTimeDbc->getUInt32(i, 1)); + if (id > 0 && base > 0) + castTimeMap[id] = static_cast(base); + } + } + + // Load SpellRange.dbc. Field layout differs by expansion: + // Classic 1.12: 0=ID, 1=MinRange, 2=MaxRange, 3=Flags, 4+=strings + // TBC / WotLK: 0=ID, 1=MinRangeFriendly, 2=MinRangeHostile, + // 3=MaxRangeFriendly, 4=MaxRangeHostile, 5=Flags, 6+=strings + // The correct field is declared in each expansion's dbc_layouts.json. + uint32_t spellRangeMaxField = 4; // WotLK / TBC default: MaxRangeHostile + const auto* spellRangeL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") + : nullptr; + if (spellRangeL) { + try { spellRangeMaxField = (*spellRangeL)["MaxRange"]; } catch (...) {} + } + std::unordered_map rangeMap; // index → max yards + auto rangeDbc = assetManager->loadDBC("SpellRange.dbc"); + if (rangeDbc && rangeDbc->isLoaded()) { + uint32_t rangeFieldCount = rangeDbc->getFieldCount(); + if (rangeFieldCount > spellRangeMaxField) { + for (uint32_t i = 0; i < rangeDbc->getRecordCount(); ++i) { + uint32_t id = rangeDbc->getUInt32(i, 0); + float maxRange = rangeDbc->getFloat(i, spellRangeMaxField); + if (id > 0 && maxRange > 0.0f) + rangeMap[id] = maxRange; + } + } + } + + // schoolField / isSchoolEnum are declared before the lambda so the WotLK fallback path + // can override them before the second tryLoad call. + uint32_t schoolField_ = UINT32_MAX; + bool isSchoolEnum_ = false; + auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField, uint32_t nameField, uint32_t rankField, uint32_t tooltipField, + uint32_t powerTypeField, uint32_t manaCostField, + uint32_t castTimeIndexField, uint32_t rangeIndexField, const char* label) { spellData.clear(); uint32_t count = dbc->getRecordCount(); + const uint32_t fc = dbc->getFieldCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t spellId = dbc->getUInt32(i, idField); if (spellId == 0) continue; @@ -66,8 +114,31 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { info.attributes = dbc->getUInt32(i, attrField); info.iconId = dbc->getUInt32(i, iconField); info.name = dbc->getString(i, nameField); - info.rank = dbc->getString(i, rankField); - info.description = dbc->getString(i, tooltipField); + if (rankField < fc) info.rank = dbc->getString(i, rankField); + if (tooltipField < fc) info.description = dbc->getString(i, tooltipField); + // Optional fields: only read if field index is valid for this DBC version + if (powerTypeField < fc) info.powerType = dbc->getUInt32(i, powerTypeField); + if (manaCostField < fc) info.manaCost = dbc->getUInt32(i, manaCostField); + if (castTimeIndexField < fc) { + uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField); + if (ctIdx > 0) { + auto ctIt = castTimeMap.find(ctIdx); + if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second; + } + } + if (rangeIndexField < fc) { + uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField); + if (rangeIdx > 0) { + auto rangeIt = rangeMap.find(rangeIdx); + if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast(rangeIt->second); + } + } + if (schoolField_ < fc) { + uint32_t raw = dbc->getUInt32(i, schoolField_); + // Classic/Turtle use a 0-6 school enum; TBC/WotLK use a bitmask. + // enum→mask: schoolEnum N maps to bit (1u << N), e.g. 0→1 (physical), 4→16 (frost). + info.schoolMask = isSchoolEnum_ ? (raw <= 6 ? (1u << raw) : 0u) : raw; + } if (!info.name.empty()) { spellData[spellId] = std::move(info); @@ -77,21 +148,51 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { }; if (spellL) { - uint32_t tooltipField = 139; - // Try to get Tooltip field from layout, fall back to 139 - try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + // Default to UINT32_MAX for optional fields; tryLoad will skip them if >= fieldCount. + // Avoids reading wrong data from expansion DBCs that lack these fields (e.g. Classic/TBC). + uint32_t tooltipField = UINT32_MAX; + uint32_t powerTypeField = UINT32_MAX; + uint32_t manaCostField = UINT32_MAX; + uint32_t castTimeIdxField = UINT32_MAX; + uint32_t rangeIdxField = UINT32_MAX; + try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + try { powerTypeField = (*spellL)["PowerType"]; } catch (...) {} + try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {} + try { castTimeIdxField = (*spellL)["CastingTimeIndex"]; } catch (...) {} + try { rangeIdxField = (*spellL)["RangeIndex"]; } catch (...) {} + // Try SchoolMask (TBC/WotLK bitmask) then SchoolEnum (Classic/Turtle 0-6 value) + schoolField_ = UINT32_MAX; + isSchoolEnum_ = false; + try { schoolField_ = (*spellL)["SchoolMask"]; } catch (...) {} + if (schoolField_ == UINT32_MAX) { + try { schoolField_ = (*spellL)["SchoolEnum"]; isSchoolEnum_ = true; } catch (...) {} + } tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], - (*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout"); + (*spellL)["Name"], (*spellL)["Rank"], tooltipField, + powerTypeField, manaCostField, castTimeIdxField, rangeIdxField, + "expansion layout"); } if (spellData.empty() && fieldCount >= 200) { LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); - tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback"); + // WotLK Spell.dbc field indices (verified against 3.3.5a schema); SchoolMask at field 225 + schoolField_ = 225; + isSchoolEnum_ = false; + tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback"); } dbcLoaded = !spellData.empty(); } +bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, + pipeline::AssetManager* assetManager) { + if (!dbcLoadAttempted) loadSpellDBC(assetManager); + const SpellInfo* info = getSpellInfo(spellId); + if (!info) return false; + renderSpellTooltip(info, gameHandler); + return true; +} + std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager) { if (!dbcLoadAttempted) { loadSpellDBC(assetManager); @@ -363,6 +464,81 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive"); } + // Spell school — only show for non-physical schools (physical is the default/implicit) + if (info->schoolMask != 0 && info->schoolMask != 1 /*physical*/) { + struct SchoolEntry { uint32_t mask; const char* name; ImVec4 color; }; + static constexpr SchoolEntry kSchools[] = { + { 2, "Holy", { 1.0f, 1.0f, 0.6f, 1.0f } }, + { 4, "Fire", { 1.0f, 0.5f, 0.1f, 1.0f } }, + { 8, "Nature", { 0.4f, 0.9f, 0.3f, 1.0f } }, + { 16, "Frost", { 0.5f, 0.8f, 1.0f, 1.0f } }, + { 32, "Shadow", { 0.7f, 0.4f, 1.0f, 1.0f } }, + { 64, "Arcane", { 0.9f, 0.5f, 1.0f, 1.0f } }, + }; + bool first = true; + for (const auto& s : kSchools) { + if (info->schoolMask & s.mask) { + if (!first) ImGui::SameLine(0, 0); + if (first) { + ImGui::TextColored(s.color, "%s", s.name); + first = false; + } else { + ImGui::SameLine(0, 2); + ImGui::TextColored(s.color, "/%s", s.name); + } + } + } + } + + // Resource cost + cast time on same row (WoW style) + if (!info->isPassive()) { + // Left: resource cost + char costBuf[64] = ""; + if (info->manaCost > 0) { + const char* powerName = "Mana"; + switch (info->powerType) { + case 1: powerName = "Rage"; break; + case 3: powerName = "Energy"; break; + case 4: powerName = "Focus"; break; + default: break; + } + std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName); + } + + // Right: cast time + char castBuf[32] = ""; + if (info->castTimeMs == 0) { + std::snprintf(castBuf, sizeof(castBuf), "Instant cast"); + } else { + float secs = info->castTimeMs / 1000.0f; + std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs); + } + + if (costBuf[0] || castBuf[0]) { + float wrapW = 320.0f; + if (costBuf[0] && castBuf[0]) { + float castW = ImGui::CalcTextSize(castBuf).x; + ImGui::Text("%s", costBuf); + ImGui::SameLine(wrapW - castW); + ImGui::Text("%s", castBuf); + } else if (castBuf[0]) { + ImGui::Text("%s", castBuf); + } else { + ImGui::Text("%s", costBuf); + } + } + + // Range + if (info->rangeIndex > 0) { + char rangeBuf[32]; + if (info->rangeIndex <= 5) + std::snprintf(rangeBuf, sizeof(rangeBuf), "Melee range"); + else + std::snprintf(rangeBuf, sizeof(rangeBuf), "%u yd range", info->rangeIndex); + ImGui::Text("%s", rangeBuf); + } + } + // Cooldown if active float cd = gameHandler.getSpellCooldown(info->spellId); if (cd > 0.0f) {